From a96c3fdadd3f2084c16950b1fdb661ac96c29bec Mon Sep 17 00:00:00 2001 From: Trezy Date: Thu, 30 Jul 2026 13:48:29 -0500 Subject: [PATCH] feat: add support for linked repos Signed-off-by: Trezy --- .../20260727000000_create_linked_repos.sql | 31 + ...0_add_origin_to_linked_repo_auth_state.sql | 12 + ...000_rename_linked_repo_label_to_reason.sql | 4 + .../20260727000000_create_linked_repos.sql | 31 + ...0_add_origin_to_linked_repo_auth_state.sql | 12 + ...000_rename_linked_repo_label_to_reason.sql | 4 + packages/docs/content/docs/guides/meta.json | 1 + .../docs/content/docs/guides/permissions.md | 22 +- src/admin/linked_repos.rs | 234 ++ src/admin/mod.rs | 25 + src/admin/permissions.rs | 34 + src/auth/mod.rs | 1 + src/auth/oauth_store.rs | 25 +- src/auth/routes.rs | 106 +- src/auth/state_gc.rs | 78 + src/jobs/worker.rs | 5 + src/lib.rs | 2 + src/linked_repos/client.rs | 76 + src/linked_repos/db.rs | 220 ++ src/linked_repos/flow.rs | 644 +++++ src/linked_repos/mod.rs | 7 + src/linked_repos/pds.rs | 281 ++ src/linked_repos/scope.rs | 381 +++ src/linked_repos/types.rs | 20 + src/linked_repos/worker.rs | 132 + src/lua/atproto_api.rs | 19 + src/lua/db_api.rs | 19 + src/lua/execute.rs | 48 + src/lua/http_api.rs | 19 + src/lua/jobs_api.rs | 38 + src/lua/linked_repos_api.rs | 219 ++ src/lua/mod.rs | 1 + src/lua/scripts.rs | 170 +- src/lua/spaces_api.rs | 35 + src/lua/xrpc_api.rs | 19 + src/main.rs | 46 +- src/server.rs | 24 + src/spaces/service.rs | 16 + test-results/.last-run.json | 4 + tests/common/app.rs | 20 +- tests/common/db.rs | 6 +- tests/e2e_linked_repos.rs | 2484 +++++++++++++++++ tests/lua_atproto_api.rs | 14 + tests/lua_db_api.rs | 14 + tests/lua_linked_repos_api.rs | 605 ++++ tests/lua_record_api.rs | 14 + web/playwright.config.ts | 11 + .../dashboard/settings/linked-repos/page.tsx | 280 ++ web/src/app/link/result/page.tsx | 136 + web/src/app/link/start/page.tsx | 284 ++ web/src/components/app-sidebar.tsx | 7 + .../linked-repos/link-repo-dialog.tsx | 295 ++ .../linked-repos/linked-repo-detail.tsx | 290 ++ .../components/linked-repos/scope-builder.tsx | 345 +++ web/src/lib/api.ts | 84 + web/src/lib/linked-repo-scopes.ts | 100 + web/src/types/linked-repos.ts | 23 + web/tests/e2e/link-invite-pages.spec.ts | 127 + web/tests/e2e/linked-repos-oauth.spec.ts | 180 ++ web/tests/e2e/linked-repos.spec.ts | 208 ++ 60 files changed, 8568 insertions(+), 24 deletions(-) create mode 100644 migrations/postgres/20260727000000_create_linked_repos.sql create mode 100644 migrations/postgres/20260729000000_add_origin_to_linked_repo_auth_state.sql create mode 100644 migrations/postgres/20260731000000_rename_linked_repo_label_to_reason.sql create mode 100644 migrations/sqlite/20260727000000_create_linked_repos.sql create mode 100644 migrations/sqlite/20260729000000_add_origin_to_linked_repo_auth_state.sql create mode 100644 migrations/sqlite/20260731000000_rename_linked_repo_label_to_reason.sql create mode 100644 src/admin/linked_repos.rs create mode 100644 src/auth/state_gc.rs create mode 100644 src/linked_repos/client.rs create mode 100644 src/linked_repos/db.rs create mode 100644 src/linked_repos/flow.rs create mode 100644 src/linked_repos/mod.rs create mode 100644 src/linked_repos/pds.rs create mode 100644 src/linked_repos/scope.rs create mode 100644 src/linked_repos/types.rs create mode 100644 src/linked_repos/worker.rs create mode 100644 src/lua/linked_repos_api.rs create mode 100644 test-results/.last-run.json create mode 100644 tests/e2e_linked_repos.rs create mode 100644 tests/lua_linked_repos_api.rs create mode 100644 web/src/app/dashboard/settings/linked-repos/page.tsx create mode 100644 web/src/app/link/result/page.tsx create mode 100644 web/src/app/link/start/page.tsx create mode 100644 web/src/components/linked-repos/link-repo-dialog.tsx create mode 100644 web/src/components/linked-repos/linked-repo-detail.tsx create mode 100644 web/src/components/linked-repos/scope-builder.tsx create mode 100644 web/src/lib/linked-repo-scopes.ts create mode 100644 web/src/types/linked-repos.ts create mode 100644 web/tests/e2e/link-invite-pages.spec.ts create mode 100644 web/tests/e2e/linked-repos-oauth.spec.ts create mode 100644 web/tests/e2e/linked-repos.spec.ts diff --git a/migrations/postgres/20260727000000_create_linked_repos.sql b/migrations/postgres/20260727000000_create_linked_repos.sql new file mode 100644 index 0000000..03b7785 --- /dev/null +++ b/migrations/postgres/20260727000000_create_linked_repos.sql @@ -0,0 +1,31 @@ +CREATE TABLE happyview_linked_repos ( + id TEXT PRIMARY KEY, + did TEXT UNIQUE, + handle TEXT, + label TEXT, + scopes TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + last_error TEXT, + last_refreshed_at TEXT, + authorized_at TEXT, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_happyview_linked_repos_status ON happyview_linked_repos (status); + +CREATE TABLE happyview_linked_repo_sessions ( + did TEXT PRIMARY KEY, + session_data TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE happyview_linked_repo_auth_state ( + state TEXT PRIMARY KEY, + grant_id TEXT NOT NULL, + token_hash TEXT, + expires_at TEXT NOT NULL +); + +CREATE INDEX idx_happyview_linked_repo_auth_state_grant + ON happyview_linked_repo_auth_state (grant_id); diff --git a/migrations/postgres/20260729000000_add_origin_to_linked_repo_auth_state.sql b/migrations/postgres/20260729000000_add_origin_to_linked_repo_auth_state.sql new file mode 100644 index 0000000..4df08da --- /dev/null +++ b/migrations/postgres/20260729000000_add_origin_to_linked_repo_auth_state.sql @@ -0,0 +1,12 @@ +-- Records how an in-flight linked-repo authorization was started, so the OAuth +-- callback knows where to send the person completing it. +-- +-- 'admin' — an admin ran the flow inline from the dashboard; send them back to +-- the Linked Repos page. +-- 'invite' — someone opened an invite link; they are very likely NOT an admin of +-- this instance, so sending them to the dashboard would bounce them +-- to /login. They get a public result page instead. +-- +-- Nullable, and treated as 'admin' when absent, so rows written before this +-- migration keep their previous behavior. +ALTER TABLE happyview_linked_repo_auth_state ADD COLUMN origin TEXT; diff --git a/migrations/postgres/20260731000000_rename_linked_repo_label_to_reason.sql b/migrations/postgres/20260731000000_rename_linked_repo_label_to_reason.sql new file mode 100644 index 0000000..075714d --- /dev/null +++ b/migrations/postgres/20260731000000_rename_linked_repo_label_to_reason.sql @@ -0,0 +1,4 @@ +-- `label` was an admin's private note, but it is shown to whoever opens an +-- invite link as the stated reason for the request. Those were never two things +-- — the field only ever had one audience — so it is named for that audience. +ALTER TABLE happyview_linked_repos RENAME COLUMN label TO reason; diff --git a/migrations/sqlite/20260727000000_create_linked_repos.sql b/migrations/sqlite/20260727000000_create_linked_repos.sql new file mode 100644 index 0000000..46f669a --- /dev/null +++ b/migrations/sqlite/20260727000000_create_linked_repos.sql @@ -0,0 +1,31 @@ +CREATE TABLE happyview_linked_repos ( + id TEXT PRIMARY KEY, + did TEXT UNIQUE, + handle TEXT, + label TEXT, + scopes TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + last_error TEXT, + last_refreshed_at TEXT, + authorized_at TEXT, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_happyview_linked_repos_status ON happyview_linked_repos (status); + +CREATE TABLE happyview_linked_repo_sessions ( + did TEXT PRIMARY KEY, + session_data TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE happyview_linked_repo_auth_state ( + state TEXT PRIMARY KEY, + grant_id TEXT NOT NULL, + token_hash TEXT, + expires_at TEXT NOT NULL +); + +CREATE INDEX idx_happyview_linked_repo_auth_state_grant + ON happyview_linked_repo_auth_state (grant_id); diff --git a/migrations/sqlite/20260729000000_add_origin_to_linked_repo_auth_state.sql b/migrations/sqlite/20260729000000_add_origin_to_linked_repo_auth_state.sql new file mode 100644 index 0000000..4df08da --- /dev/null +++ b/migrations/sqlite/20260729000000_add_origin_to_linked_repo_auth_state.sql @@ -0,0 +1,12 @@ +-- Records how an in-flight linked-repo authorization was started, so the OAuth +-- callback knows where to send the person completing it. +-- +-- 'admin' — an admin ran the flow inline from the dashboard; send them back to +-- the Linked Repos page. +-- 'invite' — someone opened an invite link; they are very likely NOT an admin of +-- this instance, so sending them to the dashboard would bounce them +-- to /login. They get a public result page instead. +-- +-- Nullable, and treated as 'admin' when absent, so rows written before this +-- migration keep their previous behavior. +ALTER TABLE happyview_linked_repo_auth_state ADD COLUMN origin TEXT; diff --git a/migrations/sqlite/20260731000000_rename_linked_repo_label_to_reason.sql b/migrations/sqlite/20260731000000_rename_linked_repo_label_to_reason.sql new file mode 100644 index 0000000..075714d --- /dev/null +++ b/migrations/sqlite/20260731000000_rename_linked_repo_label_to_reason.sql @@ -0,0 +1,4 @@ +-- `label` was an admin's private note, but it is shown to whoever opens an +-- invite link as the stated reason for the request. Those were never two things +-- — the field only ever had one audience — so it is named for that audience. +ALTER TABLE happyview_linked_repos RENAME COLUMN label TO reason; diff --git a/packages/docs/content/docs/guides/meta.json b/packages/docs/content/docs/guides/meta.json index eb9dc9c..1f16a70 100644 --- a/packages/docs/content/docs/guides/meta.json +++ b/packages/docs/content/docs/guides/meta.json @@ -8,6 +8,7 @@ "label-scripts", "lua-scripting", "api-clients", + "linked-repos", "attestation-signing", "labelers", "plugins", diff --git a/packages/docs/content/docs/guides/permissions.md b/packages/docs/content/docs/guides/permissions.md index 3722fce..b9a3ca8 100644 --- a/packages/docs/content/docs/guides/permissions.md +++ b/packages/docs/content/docs/guides/permissions.md @@ -6,7 +6,7 @@ HappyView uses a granular permission system to control access to the admin API. ## Permission list -HappyView defines 44 permissions organized by category: +HappyView defines 49 permissions organized by category: ### Lexicons @@ -114,6 +114,24 @@ HappyView defines 44 permissions organized by category: | `spaces:manage-records` | Read and write records within spaces | | `spaces:manage-credentials` | Issue and revoke space access credentials | +### Jobs + +| Permission | Description | +| -------------- | ------------------------------------------- | +| `jobs:read` | View background job status and progress | +| `jobs:create` | Queue new background jobs | +| `jobs:manage` | Cancel, pause, and resume background jobs | + +### Linked Repos + +| Permission | Description | +| --------------------- | ------------------------------------------------------------ | +| `linked-repos:read` | View linked repos, their status, and their outstanding invites | +| `linked-repos:create` | Add linked repos, start authorizations, mint and revoke invites | +| `linked-repos:delete` | Revoke linked repos and delete their stored sessions | + +Revoking an invite uses `linked-repos:create` rather than `linked-repos:delete`, because minting and revoking are one lifecycle. `linked-repos:delete` is reserved for destroying a grant and the OAuth session behind it. See [Linked Repos](./linked-repos.md). + ### System | Permission | Description | @@ -145,7 +163,7 @@ Adds: `lexicons:create`, `lexicons:delete`, `scripts:manage`, `script-variables: ### Full Access -All 44 permissions. Equivalent to granting every permission individually (but still not a super user). +All 49 permissions. Equivalent to granting every permission individually (but still not a super user). ## Super user diff --git a/src/admin/linked_repos.rs b/src/admin/linked_repos.rs new file mode 100644 index 0000000..7d89469 --- /dev/null +++ b/src/admin/linked_repos.rs @@ -0,0 +1,234 @@ +use axum::Json; +use axum::extract::{Path, State}; +use serde::Deserialize; + +use crate::AppState; +use crate::error::AppError; +use crate::linked_repos::{db, scope}; + +use super::auth::UserAuth; +use super::permissions::Permission; + +#[derive(Deserialize)] +pub struct CreateLinkedRepoBody { + pub handle: Option, + pub reason: Option, + pub scopes: String, +} + +fn validate_scopes(input: &str) -> Result { + let mut parsed = scope::parse(input); + if parsed.is_empty() { + return Err(AppError::BadRequest("scopes must not be empty".into())); + } + for s in &parsed { + scope::validate(s).map_err(|e| AppError::BadRequest(format!("invalid scope {s}: {e}")))?; + } + if !parsed.iter().any(|s| s == "atproto") { + parsed.insert(0, "atproto".to_string()); + } + Ok(parsed.join(" ")) +} + +pub async fn list_linked_repos( + State(state): State, + auth: UserAuth, +) -> Result, AppError> { + auth.require(Permission::LinkedReposRead).await?; + let grants = db::list(&state).await?; + Ok(Json(serde_json::json!({ "linked_repos": grants }))) +} + +pub async fn create_linked_repo( + State(state): State, + auth: UserAuth, + Json(body): Json, +) -> Result, AppError> { + auth.require(Permission::LinkedReposCreate).await?; + + let scopes = validate_scopes(&body.scopes)?; + + let (did, handle) = match body + .handle + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + Some(input) => { + let resolved = crate::linked_repos::flow::resolve_identifier(&state, input).await?; + (Some(resolved.did), resolved.handle) + } + None => (None, None), + }; + + if let Some(ref did) = did + && db::get_by_did(&state, did).await?.is_some() + { + return Err(AppError::Conflict("this repo is already linked".into())); + } + + let grant = db::create( + &state, + did.as_deref(), + handle.as_deref(), + body.reason.as_deref(), + &scopes, + &auth.did, + ) + .await?; + + Ok(Json(serde_json::to_value(grant).unwrap())) +} + +pub async fn authorize_linked_repo( + State(state): State, + auth: UserAuth, + Path(id): Path, +) -> Result, AppError> { + auth.require(Permission::LinkedReposCreate).await?; + + let grant = db::get(&state, &id) + .await? + .ok_or_else(|| AppError::NotFound("linked repo not found".into()))?; + + let identifier = grant + .handle + .clone() + .or_else(|| grant.did.clone()) + .ok_or_else(|| { + AppError::BadRequest( + "this grant is open — use an invite link, or recreate it with a handle".into(), + ) + })?; + + let url = crate::linked_repos::flow::start_authorization( + &state, + &grant, + &identifier, + crate::linked_repos::flow::AuthOrigin::Admin, + ) + .await?; + Ok(Json(serde_json::json!({ "authorize_url": url }))) +} + +#[derive(Deserialize, Default)] +pub struct InviteBody { + pub expires_in: Option, +} + +pub async fn invite_linked_repo( + State(state): State, + auth: UserAuth, + Path(id): Path, + body: axum::body::Bytes, +) -> Result, AppError> { + auth.require(Permission::LinkedReposCreate).await?; + + db::get(&state, &id) + .await? + .ok_or_else(|| AppError::NotFound("linked repo not found".into()))?; + + let expires_in = if body.is_empty() { + None + } else { + serde_json::from_slice::(&body) + .map_err(|e| AppError::BadRequest(format!("invalid request body: {e}")))? + .expires_in + }; + let invite = + crate::linked_repos::flow::mint_invite_with_expiry(&state, &id, expires_in).await?; + let base = state.config.effective_public_url(); + let invite_url = format!( + "{}/auth/linked-repo/start?token={}", + base.trim_end_matches('/'), + invite.token + ); + + Ok(Json(serde_json::json!({ + "invite_url": invite_url, + "expires_at": invite.expires_at, + }))) +} + +pub async fn list_linked_repo_invites( + State(state): State, + auth: UserAuth, + Path(id): Path, +) -> Result, AppError> { + auth.require(Permission::LinkedReposRead).await?; + + db::get(&state, &id) + .await? + .ok_or_else(|| AppError::NotFound("linked repo not found".into()))?; + + let invites = crate::linked_repos::flow::list_invites(&state, &id).await?; + Ok(Json(serde_json::json!({ "invites": invites }))) +} + +pub async fn revoke_linked_repo_invite( + State(state): State, + auth: UserAuth, + Path((id, invite_id)): Path<(String, String)>, +) -> Result, AppError> { + auth.require(Permission::LinkedReposCreate).await?; + + db::get(&state, &id) + .await? + .ok_or_else(|| AppError::NotFound("linked repo not found".into()))?; + + if !crate::linked_repos::flow::revoke_invite(&state, &id, &invite_id).await? { + return Err(AppError::NotFound( + "invite not found — it may have been used, revoked, or expired".into(), + )); + } + + Ok(Json(serde_json::json!({ "revoked": true }))) +} + +pub async fn delete_linked_repo( + State(state): State, + auth: UserAuth, + Path(id): Path, +) -> Result, AppError> { + auth.require(Permission::LinkedReposDelete).await?; + + if !db::delete(&state, &id).await? { + return Err(AppError::NotFound("linked repo not found".into())); + } + + Ok(Json(serde_json::json!({ "deleted": true }))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_scopes_adds_atproto_when_absent() { + let out = validate_scopes("repo:com.example.note?action=create").unwrap(); + assert_eq!(out, "atproto repo:com.example.note?action=create"); + } + + #[test] + fn validate_scopes_does_not_duplicate_atproto() { + let out = validate_scopes("atproto repo:com.example.note").unwrap(); + assert_eq!(out, "atproto repo:com.example.note"); + assert_eq!(out.matches("atproto").count(), 1); + } + + #[test] + fn validate_scopes_keeps_atproto_wherever_the_caller_put_it() { + let out = validate_scopes("repo:com.example.note atproto").unwrap(); + assert_eq!(out, "repo:com.example.note atproto"); + } + + #[test] + fn validate_scopes_rejects_empty_input() { + assert!(validate_scopes(" \n ").is_err()); + } + + #[test] + fn validate_scopes_rejects_a_malformed_token() { + assert!(validate_scopes("repo:com.example.note?action=frobnicate").is_err()); + } +} diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 210323f..553305b 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -9,6 +9,7 @@ mod feature_flags; mod jobs; mod labelers; mod lexicons; +mod linked_repos; mod network_lexicons; pub(crate) mod permissions; mod plugins; @@ -69,6 +70,30 @@ pub fn admin_routes(_state: AppState) -> Router { .route("/jobs/{id}/pause", post(jobs::pause_job)) .route("/jobs/{id}/resume", post(jobs::resume_job)) .route("/jobs/{id}/logs", get(jobs::list_job_logs)) + .route( + "/linked-repos", + get(linked_repos::list_linked_repos).post(linked_repos::create_linked_repo), + ) + .route( + "/linked-repos/{id}", + delete(linked_repos::delete_linked_repo), + ) + .route( + "/linked-repos/{id}/authorize", + post(linked_repos::authorize_linked_repo), + ) + .route( + "/linked-repos/{id}/invite", + post(linked_repos::invite_linked_repo), + ) + .route( + "/linked-repos/{id}/invites", + get(linked_repos::list_linked_repo_invites), + ) + .route( + "/linked-repos/{id}/invites/{invite_id}", + delete(linked_repos::revoke_linked_repo_invite), + ) .route("/events", get(events::list_events)) .route("/users", post(users::create_user).get(users::list_users)) .route("/users/transfer-super", post(users::transfer_super)) diff --git a/src/admin/permissions.rs b/src/admin/permissions.rs index f2cb1e7..ce1e507 100644 --- a/src/admin/permissions.rs +++ b/src/admin/permissions.rs @@ -120,6 +120,13 @@ pub enum Permission { JobsCreate, #[serde(rename = "jobs:manage")] JobsManage, + + #[serde(rename = "linked-repos:read")] + LinkedReposRead, + #[serde(rename = "linked-repos:create")] + LinkedReposCreate, + #[serde(rename = "linked-repos:delete")] + LinkedReposDelete, } impl Permission { @@ -172,6 +179,9 @@ impl Permission { Self::JobsRead => "jobs:read", Self::JobsCreate => "jobs:create", Self::JobsManage => "jobs:manage", + Self::LinkedReposRead => "linked-repos:read", + Self::LinkedReposCreate => "linked-repos:create", + Self::LinkedReposDelete => "linked-repos:delete", } } @@ -453,6 +463,24 @@ impl Permission { description: "Cancel, pause, and resume background jobs", category: "Jobs", }, + Self::LinkedReposRead => PermissionInfo { + key: "linked-repos:read", + name: "View Linked Repos", + description: "View linked repos, their status, and their scopes", + category: "Linked Repos", + }, + Self::LinkedReposCreate => PermissionInfo { + key: "linked-repos:create", + name: "Link Repos", + description: "Add linked repos and start authorization flows", + category: "Linked Repos", + }, + Self::LinkedReposDelete => PermissionInfo { + key: "linked-repos:delete", + name: "Unlink Repos", + description: "Revoke linked repos and delete their stored sessions", + category: "Linked Repos", + }, } } @@ -505,6 +533,9 @@ impl Permission { Self::JobsRead, Self::JobsCreate, Self::JobsManage, + Self::LinkedReposRead, + Self::LinkedReposCreate, + Self::LinkedReposDelete, ]) } } @@ -559,6 +590,9 @@ pub fn catalog() -> Vec { JobsRead, JobsCreate, JobsManage, + LinkedReposRead, + LinkedReposCreate, + LinkedReposDelete, ] .iter() .map(|p| p.info()) diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 15e6d20..5b41652 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -3,6 +3,7 @@ pub mod middleware; pub mod oauth_store; pub mod routes; pub mod service_auth; +pub mod state_gc; pub use client_registry::OAuthClientRegistry; pub use middleware::Claims; diff --git a/src/auth/oauth_store.rs b/src/auth/oauth_store.rs index 3bb216b..f92af6d 100644 --- a/src/auth/oauth_store.rs +++ b/src/auth/oauth_store.rs @@ -50,11 +50,20 @@ impl From for StoreError { pub struct DbSessionStore { pool: AnyPool, backend: DatabaseBackend, + table: &'static str, } impl DbSessionStore { pub fn new(pool: AnyPool, backend: DatabaseBackend) -> Self { - Self { pool, backend } + Self::new_with_table(pool, backend, "happyview_oauth_sessions") + } + + pub fn new_with_table(pool: AnyPool, backend: DatabaseBackend, table: &'static str) -> Self { + Self { + pool, + backend, + table, + } } } @@ -63,7 +72,7 @@ impl Store for DbSessionStore { async fn get(&self, key: &Did) -> Result, Self::Error> { let row: Option<(String,)> = crate::db::query_as(&adapt_sql( - "SELECT session_data FROM happyview_oauth_sessions WHERE did = ?", + &format!("SELECT session_data FROM {} WHERE did = ?", self.table), self.backend, )) .bind(key.as_ref()) @@ -79,8 +88,12 @@ impl Store for DbSessionStore { async fn set(&self, key: Did, value: Session) -> Result<(), Self::Error> { let json = serde_json::to_string(&value)?; crate::db::query(&adapt_sql( - "INSERT INTO happyview_oauth_sessions (did, session_data, updated_at) VALUES (?, ?, datetime('now')) - ON CONFLICT (did) DO UPDATE SET session_data = EXCLUDED.session_data, updated_at = datetime('now')", + &format!( + "INSERT INTO {} (did, session_data, updated_at) VALUES (?, ?, datetime('now')) \ + ON CONFLICT (did) DO UPDATE SET session_data = EXCLUDED.session_data, \ + updated_at = datetime('now')", + self.table + ), self.backend, )) .bind(key.as_ref()) @@ -92,7 +105,7 @@ impl Store for DbSessionStore { async fn del(&self, key: &Did) -> Result<(), Self::Error> { crate::db::query(&adapt_sql( - "DELETE FROM happyview_oauth_sessions WHERE did = ?", + &format!("DELETE FROM {} WHERE did = ?", self.table), self.backend, )) .bind(key.as_ref()) @@ -102,7 +115,7 @@ impl Store for DbSessionStore { } async fn clear(&self) -> Result<(), Self::Error> { - crate::db::query("DELETE FROM happyview_oauth_sessions") + crate::db::query(&format!("DELETE FROM {}", self.table)) .execute(&self.pool) .await?; Ok(()) diff --git a/src/auth/routes.rs b/src/auth/routes.rs index 90d2c2c..715b0d7 100644 --- a/src/auth/routes.rs +++ b/src/auth/routes.rs @@ -6,7 +6,7 @@ use atrium_oauth::{AuthorizeOptions, KnownScope, Scope}; use axum::{ Json, Router, extract::{Query, State}, - response::Redirect, + response::{IntoResponse, Redirect, Response}, routing::{get, post}, }; use axum_extra::extract::cookie::{Cookie, Key, SignedCookieJar}; @@ -160,7 +160,7 @@ async fn callback( State(state): State, jar: SignedCookieJar, Query(query): Query, -) -> Result<(SignedCookieJar, Redirect), AppError> { +) -> Result { // The callback sets the session cookie; refuse when its signing key is not // secure (mirrors the guard in `login`). if !state.config.session_secret_secure() { @@ -171,6 +171,25 @@ async fn callback( tracing::debug!(state = ?query.state, "callback received"); + if let Some(ref oauth_state) = query.state { + use crate::linked_repos::flow::PendingGrant; + match crate::linked_repos::flow::take_pending_grant(&state, oauth_state).await? { + PendingGrant::Grant { grant_id, origin } => { + return linked_repo_callback(&state, &grant_id, origin, query).await; + } + PendingGrant::Expired => { + tracing::warn!( + state = %oauth_state, + "linked-repo callback arrived for an expired or already-claimed authorization" + ); + return Ok(crate::linked_repos::flow::link_result_redirect( + &state, "expired", None, + )); + } + PendingGrant::NotLinked => {} + } + } + // Look up the redirect URI and client_id from the database before the OAuth library consumes the state let (redirect_url, client_id) = if let Some(oauth_state) = &query.state { let sql = adapt_sql( @@ -267,7 +286,7 @@ async fn callback( .as_ref() .map(|bp| format!("{}/login?error=not_authorized", bp)) .unwrap_or_else(|| "/login?error=not_authorized".into()); - return Ok((jar, Redirect::to(&login_url))); + return Ok((jar, Redirect::to(&login_url)).into_response()); } } } @@ -330,7 +349,86 @@ async fn callback( jar.add(session_cookie) }; - Ok((jar, Redirect::to(&redirect_url))) + Ok((jar, Redirect::to(&redirect_url)).into_response()) +} + +async fn linked_repo_callback( + state: &AppState, + grant_id: &str, + origin: crate::linked_repos::flow::AuthOrigin, + query: CallbackQuery, +) -> Result { + use crate::linked_repos::flow; + use crate::linked_repos::flow::AuthOrigin; + + let finish = |status: &str, handle: Option<&str>| -> Response { + match origin { + AuthOrigin::Admin if status == "success" => { + let dashboard = format!( + "{}/dashboard/settings/linked-repos", + state.config.effective_public_url().trim_end_matches('/') + ); + Redirect::to(&dashboard).into_response() + } + _ => flow::link_result_redirect(state, status, handle), + } + }; + + let grant = match crate::linked_repos::db::get(state, grant_id).await? { + Some(grant) => grant, + None => return Ok(finish("gone", None)), + }; + + let client = flow::client_for_grant(state, &grant)?; + + let params = atrium_oauth::CallbackParams { + code: query.code, + state: query.state, + iss: query.iss, + }; + + let (session, _app_state) = match client.callback(params).await { + Ok(v) => v, + Err(e) => { + tracing::warn!(%grant_id, error = %e, "linked repo OAuth callback failed"); + return Ok(finish("failed", None)); + } + }; + + use atrium_api::agent::SessionManager; + let did = session + .did() + .await + .ok_or_else(|| AppError::Internal("linked repo session has no DID".into()))? + .as_ref() + .to_string(); + + if let Err(e) = flow::complete(state, grant_id, &did).await { + if let Err(cleanup) = flow::discard_session(state, &did).await { + tracing::error!(%did, error = %cleanup, "failed to discard refused linked-repo session"); + } + let status = match &e { + AppError::Conflict(_) => "already_linked", + _ => "mismatch", + }; + tracing::warn!(%grant_id, %did, error = %e, "linked repo completion refused"); + return Ok(finish(status, None)); + } + + match flow::invalidate_invites_for_grant(state, grant_id).await { + Ok(n) if n > 0 => { + tracing::debug!(%grant_id, count = n, "cleared invites for a now-linked grant") + } + Ok(_) => {} + Err(e) => { + tracing::error!(%grant_id, error = %e, "failed to clear invites after linking") + } + } + + Ok(finish( + "success", + grant.handle.as_deref().or(Some(did.as_str())), + )) } async fn logout( diff --git a/src/auth/state_gc.rs b/src/auth/state_gc.rs new file mode 100644 index 0000000..8b05d05 --- /dev/null +++ b/src/auth/state_gc.rs @@ -0,0 +1,78 @@ +//! Reaper for expired OAuth-flow state rows. +//! +//! Two tables accumulate short-lived rows that are only ever cleared on the +//! happy path — when the flow they belong to completes: +//! +//! - `happyview_auth_login_redirects` — dashboard-login redirect targets +//! - `happyview_linked_repo_auth_state` — linked-repo invites and in-flight +//! authorizations +//! +//! Abandoned flows leave rows behind in both. Nothing stale is ever *honoured* +//! (every read filters on `expires_at`), so this is unbounded growth of inert +//! rows rather than a correctness problem — but on a busy instance it is growth +//! with no ceiling, so it gets swept. +//! +//! They share a reaper because they share a shape: a TEXT `expires_at` holding +//! an RFC3339 timestamp with a `+00:00` offset. Comparison is lexicographic, +//! which is only correct because every writer uses that same offset form rather +//! than a `Z` suffix — `'+' < 'Z'`, so a mix would silently invert the compare. +//! See the note on `linked_repos::flow::expires_at`. + +use sqlx::AnyPool; + +use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; + +/// Tables swept, in the order they are swept. +const EXPIRING_STATE_TABLES: &[&str] = &[ + "happyview_auth_login_redirects", + "happyview_linked_repo_auth_state", +]; + +/// Delete every row whose `expires_at` has passed. Returns rows removed. +/// +/// `table` is a `&'static str` from the list above and never derived from user +/// input, so interpolating it is not an injection vector. +async fn purge_expired(pool: &AnyPool, backend: DatabaseBackend, table: &'static str) -> u64 { + let sql = adapt_sql( + &format!("DELETE FROM {table} WHERE expires_at < ?"), + backend, + ); + match crate::db::query(&sql) + .bind(now_rfc3339()) + .execute(pool) + .await + { + Ok(result) => result.rows_affected(), + Err(e) => { + tracing::warn!(table, error = %e, "failed to purge expired state rows"); + 0 + } + } +} + +/// Sweep once. Exposed so tests can drive a pass without the loop. +pub async fn purge_expired_state(pool: &AnyPool, backend: DatabaseBackend) -> u64 { + let mut total = 0; + for table in EXPIRING_STATE_TABLES { + let removed = purge_expired(pool, backend, table).await; + if removed > 0 { + tracing::debug!(table, removed, "purged expired state rows"); + } + total += removed; + } + total +} + +/// Background loop. Spawned once from `main`. +pub async fn run_expired_state_gc(pool: AnyPool, backend: DatabaseBackend) { + tracing::info!("starting expired OAuth state cleanup task"); + let interval = tokio::time::Duration::from_secs(3600); // 1 hour + + loop { + tokio::time::sleep(interval).await; + let removed = purge_expired_state(&pool, backend).await; + if removed > 0 { + tracing::info!(removed, "purged expired OAuth state rows"); + } + } +} diff --git a/src/jobs/worker.rs b/src/jobs/worker.rs index 5fb921f..93277b5 100644 --- a/src/jobs/worker.rs +++ b/src/jobs/worker.rs @@ -201,6 +201,11 @@ async fn execute_job(state: &AppState, job: &super::Job) { let _ = db::set_error(state, &job.id, &format!("spaces write api: {e}")).await; return; } + if let Err(e) = crate::lua::linked_repos_api::register_linked_repos_api(&lua, state_arc.clone()) + { + let _ = db::set_error(state, &job.id, &format!("linked repos api: {e}")).await; + return; + } if let (Some(c), Some(p)) = (&claims, &pds_auth_arc) && let Err(e) = crate::lua::atproto_api::register_atproto_blob_api( &lua, diff --git a/src/lib.rs b/src/lib.rs index fd117e8..fce8eb4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ pub mod jetstream; pub mod jobs; pub mod labeler; pub mod lexicon; +pub mod linked_repos; pub mod lua; pub mod lua_analysis; pub mod oauth; @@ -81,6 +82,7 @@ pub struct AppState { pub rate_limiter: Arc, pub oauth: Arc, pub oauth_state_store: DbStateStore, + pub linked_repos_client: Arc, pub cookie_key: axum_extra::extract::cookie::Key, pub plugin_registry: Arc, pub wasm_runtime: Arc, diff --git a/src/linked_repos/client.rs b/src/linked_repos/client.rs new file mode 100644 index 0000000..17af749 --- /dev/null +++ b/src/linked_repos/client.rs @@ -0,0 +1,76 @@ +use std::sync::Arc; + +use atrium_identity::did::{CommonDidResolver, CommonDidResolverConfig}; +use atrium_identity::handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}; +use atrium_oauth::{ + AtprotoClientMetadata, AtprotoLocalhostClientMetadata, AuthMethod, DefaultHttpClient, + GrantType, OAuthClientConfig, OAuthResolverConfig, Scope, +}; + +use crate::HappyViewOAuthClient; +use crate::auth::oauth_store::{DbSessionStore, DbStateStore}; +use crate::db::DatabaseBackend; +use crate::dns::NativeDnsResolver; + +pub const LINKED_SESSIONS_TABLE: &str = "happyview_linked_repo_sessions"; + +#[allow(clippy::too_many_arguments)] +pub fn build( + plc_url: &str, + client_id: &str, + client_uri: &str, + redirect_uri: String, + is_loopback: bool, + scopes: Vec, + state_store: DbStateStore, + pool: sqlx::AnyPool, + backend: DatabaseBackend, +) -> Result { + let http = Arc::new(DefaultHttpClient::default()); + let resolver = OAuthResolverConfig { + did_resolver: CommonDidResolver::new(CommonDidResolverConfig { + plc_directory_url: plc_url.to_string(), + http_client: Arc::clone(&http), + }), + handle_resolver: AtprotoHandleResolver::new(AtprotoHandleResolverConfig { + dns_txt_resolver: NativeDnsResolver::new(), + http_client: Arc::clone(&http), + }), + authorization_server_metadata: Default::default(), + protected_resource_metadata: Default::default(), + }; + + let session_store = DbSessionStore::new_with_table(pool, backend, LINKED_SESSIONS_TABLE); + + let client = if is_loopback { + atrium_oauth::OAuthClient::new(OAuthClientConfig { + client_metadata: AtprotoLocalhostClientMetadata { + redirect_uris: Some(vec![redirect_uri]), + scopes: Some(scopes), + }, + keys: None, + state_store, + session_store, + resolver, + }) + } else { + atrium_oauth::OAuthClient::new(OAuthClientConfig { + client_metadata: AtprotoClientMetadata { + client_id: client_id.to_string(), + client_uri: Some(client_uri.to_string()), + redirect_uris: vec![redirect_uri], + token_endpoint_auth_method: AuthMethod::None, + grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], + scopes, + jwks_uri: None, + token_endpoint_auth_signing_alg: None, + }, + keys: None, + state_store, + session_store, + resolver, + }) + }; + + client.map_err(|e| format!("failed to create linked-repo OAuth client: {e}")) +} diff --git a/src/linked_repos/db.rs b/src/linked_repos/db.rs new file mode 100644 index 0000000..93b5c15 --- /dev/null +++ b/src/linked_repos/db.rs @@ -0,0 +1,220 @@ +use sqlx::Row; +use uuid::Uuid; + +use crate::AppState; +use crate::db::{adapt_sql, now_rfc3339}; +use crate::error::AppError; + +use super::types::{LinkedRepo, STATUS_ACTIVE, STATUS_NEEDS_REAUTH, STATUS_PENDING}; + +const COLUMNS: &str = "id, did, handle, reason, scopes, status, last_error, \ + last_refreshed_at, authorized_at, created_by, created_at"; + +fn row_to_grant(row: &sqlx::any::AnyRow) -> LinkedRepo { + LinkedRepo { + id: row.get("id"), + did: row.get("did"), + handle: row.get("handle"), + reason: row.get("reason"), + scopes: row.get("scopes"), + status: row.get("status"), + last_error: row.get("last_error"), + last_refreshed_at: row.get("last_refreshed_at"), + authorized_at: row.get("authorized_at"), + created_by: row.get("created_by"), + created_at: row.get("created_at"), + } +} + +pub async fn create( + state: &AppState, + did: Option<&str>, + handle: Option<&str>, + reason: Option<&str>, + scopes: &str, + created_by: &str, +) -> Result { + let id = Uuid::new_v4().to_string(); + let now = now_rfc3339(); + + let sql = adapt_sql( + "INSERT INTO happyview_linked_repos \ + (id, did, handle, reason, scopes, status, created_by, created_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + state.db_backend, + ); + + crate::db::query(&sql) + .bind(&id) + .bind(did) + .bind(handle) + .bind(reason) + .bind(scopes) + .bind(STATUS_PENDING) + .bind(created_by) + .bind(&now) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to create linked repo: {e}")))?; + + get(state, &id) + .await? + .ok_or_else(|| AppError::Internal("linked repo vanished after insert".into())) +} + +pub async fn list(state: &AppState) -> Result, AppError> { + let sql = adapt_sql( + &format!("SELECT {COLUMNS} FROM happyview_linked_repos ORDER BY created_at DESC"), + state.db_backend, + ); + let rows = crate::db::query(&sql) + .fetch_all(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to list linked repos: {e}")))?; + Ok(rows.iter().map(row_to_grant).collect()) +} + +pub async fn get(state: &AppState, id: &str) -> Result, AppError> { + let sql = adapt_sql( + &format!("SELECT {COLUMNS} FROM happyview_linked_repos WHERE id = ?"), + state.db_backend, + ); + let row = crate::db::query(&sql) + .bind(id) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to load linked repo: {e}")))?; + Ok(row.as_ref().map(row_to_grant)) +} + +pub async fn get_by_did(state: &AppState, did: &str) -> Result, AppError> { + let sql = adapt_sql( + &format!("SELECT {COLUMNS} FROM happyview_linked_repos WHERE did = ?"), + state.db_backend, + ); + let row = crate::db::query(&sql) + .bind(did) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to load linked repo by did: {e}")))?; + Ok(row.as_ref().map(row_to_grant)) +} + +pub async fn delete(state: &AppState, id: &str) -> Result { + let Some(grant) = get(state, id).await? else { + return Ok(false); + }; + + let mut tx = state + .db + .begin() + .await + .map_err(|e| AppError::Internal(format!("failed to begin transaction: {e}")))?; + + if let Some(ref did) = grant.did { + let sql = adapt_sql( + "DELETE FROM happyview_linked_repo_sessions WHERE did = ?", + state.db_backend, + ); + crate::db::query(&sql) + .bind(did) + .execute(&mut *tx) + .await + .map_err(|e| AppError::Internal(format!("failed to delete linked session: {e}")))?; + } + + let state_sql = adapt_sql( + "DELETE FROM happyview_linked_repo_auth_state \ + WHERE grant_id = ? AND token_hash IS NOT NULL", + state.db_backend, + ); + crate::db::query(&state_sql) + .bind(id) + .execute(&mut *tx) + .await + .map_err(|e| AppError::Internal(format!("failed to delete linked auth state: {e}")))?; + + let sql = adapt_sql( + "DELETE FROM happyview_linked_repos WHERE id = ?", + state.db_backend, + ); + let result = crate::db::query(&sql) + .bind(id) + .execute(&mut *tx) + .await + .map_err(|e| AppError::Internal(format!("failed to delete linked repo: {e}")))?; + + tx.commit() + .await + .map_err(|e| AppError::Internal(format!("failed to commit transaction: {e}")))?; + + Ok(result.rows_affected() > 0) +} + +pub async fn bind_did( + state: &AppState, + id: &str, + did: &str, + handle: Option<&str>, +) -> Result<(), AppError> { + let now = now_rfc3339(); + let sql = adapt_sql( + "UPDATE happyview_linked_repos \ + SET did = ?, handle = COALESCE(?, handle), status = ?, \ + authorized_at = ?, last_refreshed_at = ?, last_error = NULL \ + WHERE id = ?", + state.db_backend, + ); + crate::db::query(&sql) + .bind(did) + .bind(handle) + .bind(STATUS_ACTIVE) + .bind(&now) + .bind(&now) + .bind(id) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to bind linked repo did: {e}")))?; + Ok(()) +} + +pub async fn mark_needs_reauth(state: &AppState, id: &str, error: &str) -> Result<(), AppError> { + let sql = adapt_sql( + "UPDATE happyview_linked_repos SET status = ?, last_error = ? WHERE id = ?", + state.db_backend, + ); + crate::db::query(&sql) + .bind(STATUS_NEEDS_REAUTH) + .bind(error) + .bind(id) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to mark needs_reauth: {e}")))?; + Ok(()) +} + +pub async fn touch_refreshed(state: &AppState, id: &str) -> Result<(), AppError> { + let sql = adapt_sql( + "UPDATE happyview_linked_repos SET last_refreshed_at = ?, last_error = NULL WHERE id = ?", + state.db_backend, + ); + crate::db::query(&sql) + .bind(now_rfc3339()) + .bind(id) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to touch linked repo: {e}")))?; + Ok(()) +} + +pub async fn all_scopes(state: &AppState) -> Result, AppError> { + let sql = adapt_sql( + "SELECT scopes FROM happyview_linked_repos", + state.db_backend, + ); + let rows: Vec<(String,)> = crate::db::query_as(&sql) + .fetch_all(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to load linked repo scopes: {e}")))?; + Ok(rows.into_iter().map(|(s,)| s).collect()) +} diff --git a/src/linked_repos/flow.rs b/src/linked_repos/flow.rs new file mode 100644 index 0000000..2104248 --- /dev/null +++ b/src/linked_repos/flow.rs @@ -0,0 +1,644 @@ +use std::sync::Arc; + +use atrium_identity::handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}; +use atrium_oauth::{AuthorizeOptions, DefaultHttpClient, Scope}; +use axum::extract::{Query, State}; +use axum::response::{IntoResponse, Redirect, Response}; +use base64::Engine; +use rand::Rng; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +use crate::AppState; +use crate::HappyViewOAuthClient; +use crate::db::{adapt_sql, now_rfc3339}; +use crate::dns::NativeDnsResolver; +use crate::error::AppError; + +use super::db; +use super::types::LinkedRepo; + +/// How long an invite link stays valid. +const INVITE_TTL_SECS: i64 = 60 * 60 * 24 * 7; +/// How long an in-flight OAuth state row stays valid. +const STATE_TTL_SECS: i64 = 60 * 15; + +fn hash_token(token: &str) -> String { + hex::encode(Sha256::digest(token.as_bytes())) +} + +fn random_token() -> String { + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +fn expires_at(secs: i64) -> String { + (chrono::Utc::now() + chrono::Duration::seconds(secs)).to_rfc3339() +} + +fn is_loopback_url(url: &str) -> bool { + url.contains("127.0.0.1") || url.contains("[::1]") || url.contains("localhost") +} + +pub struct ResolvedIdentifier { + pub did: String, + pub handle: Option, +} + +pub async fn resolve_identifier( + _state: &AppState, + input: &str, +) -> Result { + if input.starts_with("did:") { + let did = atrium_api::types::string::Did::new(input.to_string()) + .map_err(|e| AppError::BadRequest(format!("invalid DID {input}: {e}")))?; + return Ok(ResolvedIdentifier { + did: did.as_str().to_string(), + handle: None, + }); + } + + let handle = atrium_api::types::string::Handle::new(input.to_string()) + .map_err(|_| AppError::BadRequest(format!("invalid handle: {input}")))?; + + let resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { + dns_txt_resolver: NativeDnsResolver::new(), + http_client: Arc::new(DefaultHttpClient::default()), + }); + + use atrium_common::resolver::Resolver; + let did = resolver + .resolve(&handle) + .await + .map_err(|e| AppError::BadRequest(format!("could not resolve handle {input}: {e}")))?; + + Ok(ResolvedIdentifier { + did: did.as_ref().to_string(), + handle: Some(input.to_string()), + }) +} + +pub fn client_for_grant( + state: &AppState, + grant: &LinkedRepo, +) -> Result, AppError> { + if !is_loopback_url(&state.config.public_url) { + return Ok(Arc::clone(&state.linked_repos_client)); + } + + let scopes: Vec = crate::auth::parse_scope_string(&grant.scopes); + let public_url = state.config.effective_public_url(); + let base = public_url.trim_end_matches('/'); + + super::client::build( + &state.config.plc_url, + &format!("{base}/oauth-client-metadata.json"), + &public_url, + format!("{base}/auth/callback"), + true, + scopes, + state.oauth_state_store.clone(), + state.db.clone(), + state.db_backend, + ) + .map(Arc::new) + .map_err(AppError::Internal) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthOrigin { + Admin, + Invite, +} + +impl AuthOrigin { + pub fn as_str(&self) -> &'static str { + match self { + Self::Admin => "admin", + Self::Invite => "invite", + } + } + + pub fn parse(raw: Option<&str>) -> Self { + match raw { + Some("invite") => Self::Invite, + _ => Self::Admin, + } + } +} + +pub async fn start_authorization( + state: &AppState, + grant: &LinkedRepo, + identifier: &str, + origin: AuthOrigin, +) -> Result { + let scopes = crate::auth::parse_scope_string(&grant.scopes); + let client = client_for_grant(state, grant)?; + + let guard = state.oauth_state_store.authorize_lock.lock().await; + + let url = client + .authorize( + identifier, + AuthorizeOptions { + scopes, + ..Default::default() + }, + ) + .await + .map_err(|e| { + let msg = format!("{e}"); + if msg.contains("invalid_scope") { + AppError::BadRequest(format!( + "the authorization server rejected the requested scopes ({msg}). \ + Either it does not accept one of this grant's scopes — in which \ + case the grant must be recreated with scopes it does accept — or \ + it is serving a cached copy of our client metadata, which a retry \ + in a minute will clear." + )) + } else { + AppError::Internal(format!("authorize failed: {msg}")) + } + })?; + + let oauth_state = state + .oauth_state_store + .take_last_state_key() + .ok_or_else(|| AppError::Internal("no OAuth state captured for linked repo".into()))?; + + drop(guard); + + let sql = adapt_sql( + "INSERT INTO happyview_linked_repo_auth_state (state, grant_id, expires_at, origin) \ + VALUES (?, ?, ?, ?)", + state.db_backend, + ); + crate::db::query(&sql) + .bind(&oauth_state) + .bind(&grant.id) + .bind(expires_at(STATE_TTL_SECS)) + .bind(origin.as_str()) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to store linked auth state: {e}")))?; + + Ok(url.to_string()) +} + +pub struct MintedInvite { + pub token: String, + pub expires_at: String, +} + +pub const MIN_INVITE_TTL_SECS: i64 = 60; +pub const MAX_INVITE_TTL_SECS: i64 = 60 * 60 * 24 * 30; + +pub async fn mint_invite_with_expiry( + state: &AppState, + grant_id: &str, + ttl_secs: Option, +) -> Result { + let ttl = match ttl_secs { + Some(t) if !(MIN_INVITE_TTL_SECS..=MAX_INVITE_TTL_SECS).contains(&t) => { + return Err(AppError::BadRequest(format!( + "expires_in must be between {MIN_INVITE_TTL_SECS} and {MAX_INVITE_TTL_SECS} seconds" + ))); + } + Some(t) => t, + None => INVITE_TTL_SECS, + }; + + let token = random_token(); + let hash = hash_token(&token); + let expires = expires_at(ttl); + + let sql = adapt_sql( + "INSERT INTO happyview_linked_repo_auth_state (state, grant_id, token_hash, expires_at) \ + VALUES (?, ?, ?, ?)", + state.db_backend, + ); + crate::db::query(&sql) + .bind(format!("invite:{hash}")) + .bind(grant_id) + .bind(&hash) + .bind(&expires) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to store invite: {e}")))?; + + Ok(MintedInvite { + token, + expires_at: expires, + }) +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct InviteSummary { + pub invite_id: String, + pub expires_at: String, +} + +pub async fn list_invites( + state: &AppState, + grant_id: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT token_hash, expires_at FROM happyview_linked_repo_auth_state \ + WHERE grant_id = ? AND token_hash IS NOT NULL AND expires_at > ? \ + ORDER BY expires_at", + state.db_backend, + ); + let rows: Vec<(String, String)> = crate::db::query_as(&sql) + .bind(grant_id) + .bind(now_rfc3339()) + .fetch_all(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to list invites: {e}")))?; + + Ok(rows + .into_iter() + .map(|(invite_id, expires_at)| InviteSummary { + invite_id, + expires_at, + }) + .collect()) +} + +pub async fn revoke_invite( + state: &AppState, + grant_id: &str, + invite_id: &str, +) -> Result { + let sql = adapt_sql( + "DELETE FROM happyview_linked_repo_auth_state \ + WHERE grant_id = ? AND token_hash = ? AND token_hash IS NOT NULL", + state.db_backend, + ); + let result = crate::db::query(&sql) + .bind(grant_id) + .bind(invite_id) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to revoke invite: {e}")))?; + + Ok(result.rows_affected() > 0) +} + +pub async fn invite_exists(state: &AppState, token: &str) -> Result { + let hash = hash_token(token); + let sql = adapt_sql( + "SELECT 1 FROM happyview_linked_repo_auth_state \ + WHERE token_hash = ? AND expires_at > ?", + state.db_backend, + ); + let row: Option<(i32,)> = crate::db::query_as(&sql) + .bind(&hash) + .bind(now_rfc3339()) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to look up invite: {e}")))?; + Ok(row.is_some()) +} + +pub async fn invite_grant_id(state: &AppState, token: &str) -> Result, AppError> { + let hash = hash_token(token); + let sql = adapt_sql( + "SELECT grant_id FROM happyview_linked_repo_auth_state \ + WHERE token_hash = ? AND expires_at > ?", + state.db_backend, + ); + let row: Option<(String,)> = crate::db::query_as(&sql) + .bind(&hash) + .bind(now_rfc3339()) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to look up invite: {e}")))?; + Ok(row.map(|(grant_id,)| grant_id)) +} + +pub async fn invalidate_invites_for_grant( + state: &AppState, + grant_id: &str, +) -> Result { + let sql = adapt_sql( + "DELETE FROM happyview_linked_repo_auth_state \ + WHERE grant_id = ? AND token_hash IS NOT NULL", + state.db_backend, + ); + let result = crate::db::query(&sql) + .bind(grant_id) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to invalidate invites: {e}")))?; + Ok(result.rows_affected()) +} + +#[derive(Debug, PartialEq, Eq)] +pub enum PendingGrant { + NotLinked, + Expired, + Grant { + grant_id: String, + origin: AuthOrigin, + }, +} + +pub async fn take_pending_grant( + state: &AppState, + oauth_state: &str, +) -> Result { + let sql = adapt_sql( + "SELECT grant_id, expires_at, origin FROM happyview_linked_repo_auth_state \ + WHERE state = ? AND token_hash IS NULL", + state.db_backend, + ); + let row: Option<(String, String, Option)> = crate::db::query_as(&sql) + .bind(oauth_state) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to look up linked auth state: {e}")))?; + + let Some((grant_id, expires, origin)) = row else { + return Ok(PendingGrant::NotLinked); + }; + let origin = AuthOrigin::parse(origin.as_deref()); + + let del = adapt_sql( + "DELETE FROM happyview_linked_repo_auth_state WHERE state = ? AND token_hash IS NULL", + state.db_backend, + ); + let result = crate::db::query(&del) + .bind(oauth_state) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to clear linked auth state: {e}")))?; + + if result.rows_affected() == 0 { + return Ok(PendingGrant::Expired); + } + + if expires.as_str() <= now_rfc3339().as_str() { + return Ok(PendingGrant::Expired); + } + + Ok(PendingGrant::Grant { grant_id, origin }) +} + +pub async fn complete(state: &AppState, grant_id: &str, did: &str) -> Result<(), AppError> { + let grant = db::get(state, grant_id) + .await? + .ok_or_else(|| AppError::NotFound("linked repo not found".into()))?; + + if let Some(ref expected) = grant.did + && expected != did + { + tracing::warn!( + grant_id = %grant.id, + expected = %expected, + authorized_by = %did, + "refusing linked-repo completion: pinned grant authorized by a different account" + ); + flag_session_lost( + state, + did, + "another account's authorization overwrote this session", + ) + .await; + return Err(AppError::BadRequest( + "this link was issued for a different account".into(), + )); + } + + if grant.did.is_none() + && let Some(existing) = db::get_by_did(state, did).await? + && existing.id != grant.id + { + tracing::warn!( + grant_id = %grant.id, + existing_grant_id = %existing.id, + %did, + "refusing linked-repo completion: DID already linked to another grant" + ); + flag_session_lost( + state, + did, + "another authorization for this account overwrote this session", + ) + .await; + return Err(AppError::Conflict( + "this account is already linked to this instance".into(), + )); + } + + db::bind_did(state, grant_id, did, None).await +} + +async fn flag_session_lost(state: &AppState, did: &str, reason: &str) { + let existing = match db::get_by_did(state, did).await { + Ok(Some(grant)) => grant, + Ok(None) => return, + Err(e) => { + tracing::error!(%did, error = %e, "failed to look up grant for a refused authorization"); + return; + } + }; + if let Err(e) = db::mark_needs_reauth(state, &existing.id, reason).await { + tracing::error!(grant_id = %existing.id, error = %e, "failed to flag grant as needing reauth"); + } +} + +pub async fn discard_session(state: &AppState, did: &str) -> Result<(), AppError> { + let sql = adapt_sql( + "DELETE FROM happyview_linked_repo_sessions WHERE did = ?", + state.db_backend, + ); + crate::db::query(&sql) + .bind(did) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to discard linked session: {e}")))?; + Ok(()) +} + +#[derive(Deserialize)] +pub struct StartQuery { + pub token: String, + pub handle: Option, +} + +#[derive(serde::Serialize)] +pub struct InviteInfo { + pub valid: bool, + pub app_name: String, + pub logo_url: Option, + pub scopes: Vec, + pub reason: Option, + pub pinned_identifier: Option, + pub expires_at: Option, +} + +fn invalid_invite_info() -> InviteInfo { + InviteInfo { + valid: false, + app_name: String::new(), + logo_url: None, + scopes: Vec::new(), + reason: None, + pinned_identifier: None, + expires_at: None, + } +} + +pub async fn invite_info_handler( + State(state): State, + Query(query): Query, +) -> Result, AppError> { + let hash = hash_token(&query.token); + let sql = adapt_sql( + "SELECT grant_id, expires_at FROM happyview_linked_repo_auth_state \ + WHERE token_hash = ? AND expires_at > ?", + state.db_backend, + ); + let row: Option<(String, String)> = crate::db::query_as(&sql) + .bind(&hash) + .bind(now_rfc3339()) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to look up invite: {e}")))?; + + let Some((grant_id, expires_at)) = row else { + return Ok(axum::Json(invalid_invite_info())); + }; + + let Some(grant) = db::get(&state, &grant_id).await? else { + // The grant was deleted from a live invite + return Ok(axum::Json(invalid_invite_info())); + }; + + let app_name = crate::admin::settings::get_setting(&state.db, "app_name", state.db_backend) + .await + .unwrap_or_else(|| "HappyView".to_string()); + + let logo_url = crate::admin::settings::get_setting(&state.db, "logo_data", state.db_backend) + .await + .map(|_| { + format!( + "{}/settings/logo", + state.config.effective_public_url().trim_end_matches('/') + ) + }) + .or(crate::admin::settings::get_setting(&state.db, "logo_uri", state.db_backend).await); + + Ok(axum::Json(InviteInfo { + valid: true, + app_name, + logo_url, + scopes: super::scope::parse(&grant.scopes), + reason: grant.reason.clone(), + pinned_identifier: grant.handle.clone().or_else(|| grant.did.clone()), + expires_at: Some(expires_at), + })) +} + +#[derive(Deserialize)] +pub struct InviteInfoQuery { + pub token: String, +} + +fn link_page(state: &AppState, path: &str) -> String { + format!( + "{}/link/{path}", + state.config.effective_public_url().trim_end_matches('/') + ) +} + +pub fn link_result_redirect(state: &AppState, status: &str, handle: Option<&str>) -> Response { + let mut url = format!( + "{}?status={}", + link_page(state, "result"), + urlencoding::encode(status) + ); + if let Some(handle) = handle { + url.push_str(&format!("&handle={}", urlencoding::encode(handle))); + } + Redirect::to(&url).into_response() +} + +pub async fn start_handler( + State(state): State, + Query(query): Query, +) -> Result { + let Some(handle) = query + .handle + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + else { + return Ok(Redirect::to(&format!( + "{}?token={}", + link_page(&state, "start"), + urlencoding::encode(&query.token) + )) + .into_response()); + }; + + if !invite_exists(&state, &query.token).await? { + return Err(AppError::BadRequest( + "this link is invalid, expired, or has already been used".into(), + )); + } + + let resolved = resolve_identifier(&state, handle).await?; + let identifier = resolved.handle.as_deref().unwrap_or(&resolved.did); + + let Some(grant_id) = invite_grant_id(&state, &query.token).await? else { + return Err(AppError::BadRequest( + "this link is invalid, expired, or has already been used".into(), + )); + }; + + let grant = match db::get(&state, &grant_id).await { + Ok(Some(grant)) => grant, + Ok(None) => return Err(AppError::NotFound("linked repo not found".into())), + Err(e) => return Err(e), + }; + + let url = start_authorization(&state, &grant, identifier, AuthOrigin::Invite).await?; + Ok(Redirect::to(&url).into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_token_is_stable_and_hex() { + let a = hash_token("hello"); + assert_eq!(a, hash_token("hello")); + assert_eq!(a.len(), 64); + assert!(a.chars().all(|c| c.is_ascii_hexdigit())); + assert_ne!(a, hash_token("hellp")); + } + + #[test] + fn random_token_is_url_safe_and_unique() { + let a = random_token(); + let b = random_token(); + assert_ne!(a, b); + assert!( + a.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), + "token must be URL-safe, got {a}" + ); + } + + #[test] + fn is_loopback_url_matches_local_hosts() { + assert!(is_loopback_url("http://localhost:3000")); + assert!(is_loopback_url("http://127.0.0.1:3000")); + assert!(is_loopback_url("http://[::1]:3000")); + assert!(!is_loopback_url("https://appview.example.com")); + } +} diff --git a/src/linked_repos/mod.rs b/src/linked_repos/mod.rs new file mode 100644 index 0000000..5f085f4 --- /dev/null +++ b/src/linked_repos/mod.rs @@ -0,0 +1,7 @@ +pub mod client; +pub mod db; +pub mod flow; +pub mod pds; +pub mod scope; +pub mod types; +pub mod worker; diff --git a/src/linked_repos/pds.rs b/src/linked_repos/pds.rs new file mode 100644 index 0000000..142c631 --- /dev/null +++ b/src/linked_repos/pds.rs @@ -0,0 +1,281 @@ +use atrium_api::types::string::Did; +use atrium_xrpc::http::Method; +use atrium_xrpc::{InputDataOrBytes, OutputDataOrBytes, XrpcClient, XrpcRequest}; +use serde_json::{Value, json}; + +use crate::AppState; +use crate::HappyViewOAuthSession; +use crate::error::AppError; + +use super::flow; +use super::scope::{self, RepoAction}; +use super::types::{LinkedRepo, STATUS_NEEDS_REAUTH}; + +fn require_did(grant: &LinkedRepo) -> Result<&str, AppError> { + grant.did.as_deref().ok_or_else(|| { + AppError::BadRequest(format!( + "linked repo {} has not been authorized yet", + grant.id + )) + }) +} + +fn require_repo_scope( + grant: &LinkedRepo, + collection: &str, + action: RepoAction, +) -> Result<(), AppError> { + let scopes = scope::parse(&grant.scopes); + if scope::allows_repo(&scopes, collection, action) { + return Ok(()); + } + Err(AppError::Forbidden(format!( + "linked repo {} lacks the scope needed to {} {} — grant it repo:{}?action={}", + grant.did.as_deref().unwrap_or(&grant.id), + action.as_str(), + collection, + collection, + action.as_str(), + ))) +} + +fn require_put_scope(grant: &LinkedRepo, collection: &str, has_swap: bool) -> Result<(), AppError> { + let scopes = scope::parse(&grant.scopes); + + if has_swap { + if scope::allows_repo(&scopes, collection, RepoAction::Update) { + return Ok(()); + } + return Err(AppError::Forbidden(format!( + "linked repo {} lacks the scope needed to put {} — putRecord with swap_cid can only \ + update an existing record (the PDS enforces the compare-and-swap), so it requires \ + repo:{}?action=update", + grant.did.as_deref().unwrap_or(&grant.id), + collection, + collection, + ))); + } + + let can_create = scope::allows_repo(&scopes, collection, RepoAction::Create); + let can_update = scope::allows_repo(&scopes, collection, RepoAction::Update); + if can_create && can_update { + return Ok(()); + } + Err(AppError::Forbidden(format!( + "linked repo {} lacks the scope needed to put {} — putRecord can create a new record \ + as well as update an existing one, so it requires both repo:{}?action=create and \ + repo:{}?action=update, not just one of them; passing swap_cid would narrow this to \ + just repo:{}?action=update", + grant.did.as_deref().unwrap_or(&grant.id), + collection, + collection, + collection, + collection, + ))) +} + +fn require_blob_scope(grant: &LinkedRepo, mime: &str) -> Result<(), AppError> { + let scopes = scope::parse(&grant.scopes); + if scope::allows_blob(&scopes, mime) { + return Ok(()); + } + Err(AppError::Forbidden(format!( + "linked repo {} lacks a blob scope covering {mime}", + grant.did.as_deref().unwrap_or(&grant.id), + ))) +} + +pub async fn session_for( + state: &AppState, + grant: &LinkedRepo, +) -> Result { + let did_str = require_did(grant)?; + + if grant.status == STATUS_NEEDS_REAUTH { + return Err(AppError::Auth(format!( + "linked repo {did_str} needs reauthorization" + ))); + } + + let did = Did::new(did_str.to_string()) + .map_err(|_| AppError::Internal(format!("invalid DID on grant: {did_str}")))?; + + let client = flow::client_for_grant(state, grant)?; + + match client.restore(&did).await { + Ok(session) => Ok(session), + Err(e) => { + let message = format!("{e}"); + super::db::mark_needs_reauth(state, &grant.id, &message).await?; + Err(AppError::Auth(format!( + "linked repo {did_str} needs reauthorization: {message}" + ))) + } + } +} + +async fn post_json( + session: &HappyViewOAuthSession, + nsid: &str, + body: &Value, +) -> Result { + let request = XrpcRequest { + method: Method::POST, + nsid: nsid.to_string(), + parameters: None::<()>, + input: Some(InputDataOrBytes::Data(body.clone())), + encoding: Some("application/json".to_string()), + }; + + let result: Result, atrium_xrpc::Error> = + session.send_xrpc(&request).await; + + match result { + Ok(OutputDataOrBytes::Data(data)) => Ok(data), + Ok(OutputDataOrBytes::Bytes(bytes)) => serde_json::from_slice(&bytes) + .map_err(|e| AppError::Internal(format!("PDS returned non-JSON: {e}"))), + Err(e) => Err(AppError::Internal(format!("{nsid} failed: {e}"))), + } +} + +fn extract_uri_cid(value: &Value, nsid: &str) -> Result<(String, String), AppError> { + let uri = value + .get("uri") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::Internal(format!("{nsid} response had no uri")))?; + let cid = value + .get("cid") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::Internal(format!("{nsid} response had no cid")))?; + Ok((uri.to_string(), cid.to_string())) +} + +pub async fn create_record( + state: &AppState, + grant: &LinkedRepo, + collection: &str, + rkey: Option<&str>, + record: Value, +) -> Result<(String, String), AppError> { + require_repo_scope(grant, collection, RepoAction::Create)?; + let session = session_for(state, grant).await?; + let did = grant.did.as_deref().expect("session_for requires a did"); + + let mut body = json!({ + "repo": did, + "collection": collection, + "record": record, + }); + if let Some(rkey) = rkey { + body["rkey"] = Value::String(rkey.to_string()); + } + + let out = post_json(&session, "com.atproto.repo.createRecord", &body).await?; + extract_uri_cid(&out, "createRecord") +} + +pub async fn put_record( + state: &AppState, + grant: &LinkedRepo, + collection: &str, + rkey: &str, + record: Value, + swap_cid: Option<&str>, +) -> Result<(String, String), AppError> { + require_put_scope(grant, collection, swap_cid.is_some())?; + let session = session_for(state, grant).await?; + let did = grant.did.as_deref().expect("session_for requires a did"); + + let mut body = json!({ + "repo": did, + "collection": collection, + "rkey": rkey, + "record": record, + }); + if let Some(swap) = swap_cid { + body["swapRecord"] = Value::String(swap.to_string()); + } + + let out = post_json(&session, "com.atproto.repo.putRecord", &body).await?; + extract_uri_cid(&out, "putRecord") +} + +pub async fn delete_record( + state: &AppState, + grant: &LinkedRepo, + collection: &str, + rkey: &str, +) -> Result<(), AppError> { + require_repo_scope(grant, collection, RepoAction::Delete)?; + let session = session_for(state, grant).await?; + let did = grant.did.as_deref().expect("session_for requires a did"); + + let body = json!({ + "repo": did, + "collection": collection, + "rkey": rkey, + }); + + post_json(&session, "com.atproto.repo.deleteRecord", &body).await?; + Ok(()) +} + +pub async fn upload_blob( + state: &AppState, + grant: &LinkedRepo, + mime: &str, + bytes: Vec, +) -> Result { + require_blob_scope(grant, mime)?; + let session = session_for(state, grant).await?; + + let request = XrpcRequest { + method: Method::POST, + nsid: "com.atproto.repo.uploadBlob".to_string(), + parameters: None::<()>, + input: Some(InputDataOrBytes::<()>::Bytes(bytes)), + encoding: Some(mime.to_string()), + }; + + let result: Result, atrium_xrpc::Error> = + session.send_xrpc(&request).await; + + match result { + Ok(OutputDataOrBytes::Data(data)) => Ok(data), + Ok(OutputDataOrBytes::Bytes(b)) => serde_json::from_slice(&b) + .map_err(|e| AppError::Internal(format!("uploadBlob returned non-JSON: {e}"))), + Err(e) => Err(AppError::Internal(format!("uploadBlob failed: {e}"))), + } +} + +pub async fn call( + state: &AppState, + grant: &LinkedRepo, + nsid: &str, + params: Option, + input: Option, +) -> Result { + let session = session_for(state, grant).await?; + + if let Some(input) = input { + return post_json(&session, nsid, &input).await; + } + + let request = XrpcRequest { + method: Method::GET, + nsid: nsid.to_string(), + parameters: params, + input: None::>, + encoding: None, + }; + + let result: Result, atrium_xrpc::Error> = + session.send_xrpc(&request).await; + + match result { + Ok(OutputDataOrBytes::Data(data)) => Ok(data), + Ok(OutputDataOrBytes::Bytes(b)) => serde_json::from_slice(&b) + .map_err(|e| AppError::Internal(format!("{nsid} returned non-JSON: {e}"))), + Err(e) => Err(AppError::Internal(format!("{nsid} failed: {e}"))), + } +} diff --git a/src/linked_repos/scope.rs b/src/linked_repos/scope.rs new file mode 100644 index 0000000..88f844b --- /dev/null +++ b/src/linked_repos/scope.rs @@ -0,0 +1,381 @@ +use atrium_api::types::string::Nsid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RepoAction { + Create, + Update, + Delete, +} + +impl RepoAction { + pub fn as_str(&self) -> &'static str { + match self { + Self::Create => "create", + Self::Update => "update", + Self::Delete => "delete", + } + } +} + +pub fn parse(input: &str) -> Vec { + let mut seen = Vec::new(); + for token in input.split_whitespace() { + let token = token.to_string(); + if !seen.contains(&token) { + seen.push(token); + } + } + seen +} + +pub fn validate(scope: &str) -> Result<(), String> { + if scope.is_empty() { + return Err("scope must not be empty".into()); + } + + if scope == "atproto" { + return Ok(()); + } + + let (prefix, rest) = match scope.split_once(':') { + Some(parts) => parts, + None => return Err(format!("unknown scope: {scope}")), + }; + + match prefix { + "repo" => validate_repo(rest), + "blob" => validate_blob(rest), + "rpc" | "identity" | "account" | "transition" | "include" => { + if rest.is_empty() { + Err(format!("{prefix} scope requires a value")) + } else { + Ok(()) + } + } + other => Err(format!("unknown scope prefix: {other}")), + } +} + +fn validate_repo(rest: &str) -> Result<(), String> { + let (target, query) = split_query(rest); + if target.is_empty() { + return Err("repo scope requires a collection or *".into()); + } + if target != "*" { + Nsid::new(target.to_string()).map_err(|_| format!("invalid NSID: {target}"))?; + } + let Some(query) = query else { return Ok(()) }; + + let Some(actions) = query.strip_prefix("action=") else { + return Err(format!("unsupported repo scope parameter: {query}")); + }; + if actions.is_empty() { + return Err("repo scope action list must not be empty".into()); + } + for action in actions.split(',') { + if !matches!(action, "create" | "update" | "delete") { + return Err(format!("unknown repo action: {action}")); + } + } + Ok(()) +} + +fn validate_blob(rest: &str) -> Result<(), String> { + let (target, _) = split_query(rest); + let Some((ty, subtype)) = target.split_once('/') else { + return Err("blob scope must be type/subtype".into()); + }; + if ty.is_empty() || subtype.is_empty() { + return Err("blob scope must be type/subtype".into()); + } + Ok(()) +} + +fn split_query(rest: &str) -> (&str, Option<&str>) { + match rest.split_once('?') { + Some((target, query)) => (target, Some(query)), + None => (rest, None), + } +} + +pub fn client_base_scopes(scope: Option<&str>, client_id: &str) -> String { + if let Some(s) = scope.map(str::trim).filter(|s| !s.is_empty()) { + return s.to_string(); + } + + let Some((_, query)) = client_id.split_once('?') else { + return String::new(); + }; + for pair in query.split('&') { + let Some(value) = pair.strip_prefix("scope=") else { + continue; + }; + let value = value.replace('+', " "); + return urlencoding::decode(&value) + .map(|s| s.into_owned()) + .unwrap_or(value); + } + String::new() +} + +pub fn union(scope_strings: &[String], base: &str) -> String { + let mut all: Vec = parse(base); + for grant_scopes in scope_strings { + for scope in parse(grant_scopes) { + if !all.contains(&scope) { + all.push(scope); + } + } + } + all.sort(); + all.join(" ") +} + +pub fn allows_repo(scopes: &[String], collection: &str, action: RepoAction) -> bool { + scopes.iter().any(|scope| { + let Some(rest) = scope.strip_prefix("repo:") else { + return false; + }; + let (target, query) = split_query(rest); + + if target != "*" && target != collection { + return false; + } + + match query { + // No query at all means all actions. + None => true, + Some(q) => match q.strip_prefix("action=") { + Some(actions) => actions.split(',').any(|a| a == action.as_str()), + None => false, + }, + } + }) +} + +pub fn allows_blob(scopes: &[String], mime: &str) -> bool { + let mime = mime.split(';').next().unwrap_or(mime).trim(); + let Some((mime_ty, mime_sub)) = mime.split_once('/') else { + return false; + }; + + let mime_ty = mime_ty.to_ascii_lowercase(); + let mime_sub = mime_sub.to_ascii_lowercase(); + + scopes.iter().any(|scope| { + let Some(rest) = scope.strip_prefix("blob:") else { + return false; + }; + let (target, _) = split_query(rest); + let Some((ty, sub)) = target.split_once('/') else { + return false; + }; + let ty = ty.to_ascii_lowercase(); + let sub = sub.to_ascii_lowercase(); + (ty == "*" || ty == mime_ty) && (sub == "*" || sub == mime_sub) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_splits_and_dedupes() { + let out = parse(" atproto repo:com.example.a \n atproto "); + assert_eq!(out, vec!["atproto", "repo:com.example.a"]); + } + + #[test] + fn parse_empty_input_yields_empty() { + assert!(parse(" \n\t ").is_empty()); + } + + #[test] + fn validate_accepts_known_shapes() { + for s in [ + "atproto", + "identity:*", + "account:email", + "repo:*", + "repo:com.example.note", + "repo:com.example.note?action=create", + "repo:com.example.note?action=create,update,delete", + "blob:*/*", + "blob:image/*", + "blob:image/png", + "rpc:*", + "rpc:com.example.doThing?aud=did:web:example.com", + "transition:generic", + "transition:chat.bsky", + "include:com.example.permissionSet", + ] { + assert!(validate(s).is_ok(), "expected {s} to validate"); + } + } + + #[test] + fn validate_rejects_malformed() { + for s in [ + "", + "repo:", + "repo:com.example.note?action=", + "repo:com.example.note?action=frobnicate", + "blob:image", + "blob:", + "nonsense:thing", + "transition:", + "include:", + ] { + assert!(validate(s).is_err(), "expected {s} to be rejected"); + } + } + + #[test] + fn union_merges_dedupes_and_sorts() { + let grants = vec![ + "repo:com.example.b atproto".to_string(), + "repo:com.example.a atproto".to_string(), + ]; + let out = union(&grants, "atproto identity:*"); + assert_eq!( + out, + "atproto identity:* repo:com.example.a repo:com.example.b" + ); + } + + #[test] + fn union_with_no_grants_is_just_base() { + assert_eq!(union(&[], "atproto identity:*"), "atproto identity:*"); + } + + #[test] + fn client_base_scopes_prefers_the_metadata_field() { + assert_eq!( + client_base_scopes(Some("atproto identity:*"), "https://example.com/x.json"), + "atproto identity:*" + ); + } + + #[test] + fn client_base_scopes_falls_back_to_a_loopback_client_id() { + let client_id = "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fauth%2Fcallback&scope=atproto+identity%3A*"; + assert_eq!( + client_base_scopes(None, client_id), + "atproto identity:*", + "a loopback client's scopes must not vanish from the advertised doc" + ); + } + + #[test] + fn client_base_scopes_is_empty_when_there_is_nowhere_to_read_it() { + assert_eq!(client_base_scopes(None, "http://localhost"), ""); + assert_eq!(client_base_scopes(Some(" "), "http://localhost"), ""); + } + + #[test] + fn union_carries_every_base_scope_through() { + let out = union( + &["repo:com.example.a".to_string()], + "atproto identity:* account:email", + ); + assert_eq!(out, "account:email atproto identity:* repo:com.example.a"); + } + + #[test] + fn allows_repo_defaults_to_all_actions() { + let scopes = parse("repo:com.example.note"); + assert!(allows_repo(&scopes, "com.example.note", RepoAction::Create)); + assert!(allows_repo(&scopes, "com.example.note", RepoAction::Update)); + assert!(allows_repo(&scopes, "com.example.note", RepoAction::Delete)); + } + + #[test] + fn allows_repo_honours_action_list() { + let scopes = parse("repo:com.example.note?action=create,update"); + assert!(allows_repo(&scopes, "com.example.note", RepoAction::Create)); + assert!(allows_repo(&scopes, "com.example.note", RepoAction::Update)); + assert!(!allows_repo( + &scopes, + "com.example.note", + RepoAction::Delete + )); + } + + #[test] + fn allows_repo_wildcard_covers_any_collection() { + let scopes = parse("repo:*"); + assert!(allows_repo( + &scopes, + "com.example.anything", + RepoAction::Delete + )); + } + + #[test] + fn allows_repo_rejects_other_collections() { + let scopes = parse("repo:com.example.note"); + assert!(!allows_repo( + &scopes, + "com.example.other", + RepoAction::Create + )); + } + + #[test] + fn allows_repo_is_false_without_any_repo_scope() { + let scopes = parse("atproto identity:*"); + assert!(!allows_repo( + &scopes, + "com.example.note", + RepoAction::Create + )); + } + + #[test] + fn allows_blob_matches_wildcards() { + assert!(allows_blob(&parse("blob:*/*"), "image/png")); + assert!(allows_blob(&parse("blob:image/*"), "image/png")); + assert!(allows_blob(&parse("blob:image/png"), "image/png")); + assert!(!allows_blob(&parse("blob:image/*"), "video/mp4")); + assert!(!allows_blob(&parse("blob:image/png"), "image/jpeg")); + assert!(!allows_blob(&parse("atproto"), "image/png")); + } + + #[test] + fn allows_blob_ignores_mime_parameters() { + assert!(allows_blob( + &parse("blob:text/plain"), + "text/plain; charset=utf-8" + )); + } + + #[test] + fn allows_repo_rejects_unrecognized_query() { + let scopes = parse("repo:com.example.note?foo=bar"); + assert!(!allows_repo( + &scopes, + "com.example.note", + RepoAction::Create + )); + } + + #[test] + fn allows_blob_matches_case_insensitively() { + assert!(allows_blob(&parse("blob:image/PNG"), "image/png")); + assert!(allows_blob(&parse("blob:image/png"), "image/PNG")); + } + + #[test] + fn validate_repo_rejects_malformed_nsid() { + for s in ["repo:not an nsid", "repo:com"] { + assert!(validate(s).is_err(), "expected {s} to be rejected"); + } + } + + #[test] + fn validate_repo_accepts_wildcard_and_valid_nsid() { + assert!(validate("repo:*").is_ok()); + assert!(validate("repo:com.example.note").is_ok()); + } +} diff --git a/src/linked_repos/types.rs b/src/linked_repos/types.rs new file mode 100644 index 0000000..201a400 --- /dev/null +++ b/src/linked_repos/types.rs @@ -0,0 +1,20 @@ +use serde::Serialize; + +pub const STATUS_PENDING: &str = "pending"; +pub const STATUS_ACTIVE: &str = "active"; +pub const STATUS_NEEDS_REAUTH: &str = "needs_reauth"; + +#[derive(Debug, Clone, Serialize)] +pub struct LinkedRepo { + pub id: String, + pub did: Option, + pub handle: Option, + pub reason: Option, + pub scopes: String, + pub status: String, + pub last_error: Option, + pub last_refreshed_at: Option, + pub authorized_at: Option, + pub created_by: String, + pub created_at: String, +} diff --git a/src/linked_repos/worker.rs b/src/linked_repos/worker.rs new file mode 100644 index 0000000..3a195cf --- /dev/null +++ b/src/linked_repos/worker.rs @@ -0,0 +1,132 @@ +use atrium_api::types::string::Did; + +use crate::AppState; +use crate::error::AppError; + +use super::db; +use super::flow; +use super::types::{LinkedRepo, STATUS_ACTIVE}; + +/// How often the loop wakes. +const TICK_SECS: u64 = 30 * 60; +/// Only touch grants that have not been refreshed within this window. +const STALE_AFTER_SECS: i64 = 60 * 60; + +pub async fn refresh_grant(state: &AppState, grant: &LinkedRepo) -> Result<(), AppError> { + let Some(ref did_str) = grant.did else { + return Ok(()); + }; + + let did = Did::new(did_str.clone()) + .map_err(|_| AppError::Internal(format!("invalid DID on grant: {did_str}")))?; + + let client = match flow::client_for_grant(state, grant) { + Ok(client) => client, + Err(e) => { + let message = format!("{e}"); + db::mark_needs_reauth(state, &grant.id, &message).await?; + tracing::warn!( + grant_id = %grant.id, + did = %did_str, + error = %message, + "linked repo client construction failed" + ); + return Err(e); + } + }; + + match client.restore(&did).await { + Ok(_session) => { + db::touch_refreshed(state, &grant.id).await?; + Ok(()) + } + Err(e) => { + let message = format!("{e}"); + db::mark_needs_reauth(state, &grant.id, &message).await?; + tracing::warn!( + grant_id = %grant.id, + did = %did_str, + error = %message, + "linked repo session refresh failed" + ); + Err(AppError::Auth(format!( + "linked repo {did_str} needs reauthorization: {message}" + ))) + } + } +} + +fn is_stale(last_refreshed_at: Option<&str>, cutoff: chrono::DateTime) -> bool { + match last_refreshed_at { + Some(ts) => chrono::DateTime::parse_from_rfc3339(ts) + .map(|t| t.with_timezone(&chrono::Utc) < cutoff) + .unwrap_or(true), + None => true, + } +} + +pub async fn run_keepalive(state: AppState) { + tracing::info!("starting linked repo keep-alive task"); + let interval = tokio::time::Duration::from_secs(TICK_SECS); + + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + + loop { + let grants = match db::list(&state).await { + Ok(g) => g, + Err(e) => { + tracing::warn!(error = %e, "linked repo keep-alive could not list grants"); + tokio::time::sleep(interval).await; + continue; + } + }; + + let cutoff = chrono::Utc::now() - chrono::Duration::seconds(STALE_AFTER_SECS); + + for grant in grants { + if grant.status != STATUS_ACTIVE { + continue; + } + + if !is_stale(grant.last_refreshed_at.as_deref(), cutoff) { + continue; + } + + let _ = refresh_grant(&state, &grant).await; + } + + tokio::time::sleep(interval).await; + } +} + +#[cfg(test)] +mod tests { + use super::is_stale; + use chrono::{Duration, Utc}; + + #[test] + fn never_refreshed_is_stale() { + let cutoff = Utc::now() - Duration::seconds(3600); + assert!(is_stale(None, cutoff)); + } + + #[test] + fn unparseable_timestamp_is_stale() { + let cutoff = Utc::now() - Duration::seconds(3600); + assert!(is_stale(Some("not-a-timestamp"), cutoff)); + } + + #[test] + fn timestamp_older_than_cutoff_is_stale() { + let cutoff = Utc::now() - Duration::seconds(3600); + let old = (cutoff - Duration::seconds(60)).to_rfc3339(); + assert!(is_stale(Some(&old), cutoff)); + } + + #[test] + fn timestamp_newer_than_cutoff_is_not_stale() { + let cutoff = Utc::now() - Duration::seconds(3600); + let recent = (cutoff + Duration::seconds(60)).to_rfc3339(); + assert!(!is_stale(Some(&recent), cutoff)); + } +} diff --git a/src/lua/atproto_api.rs b/src/lua/atproto_api.rs index e5d1f6f..edf478d 100644 --- a/src/lua/atproto_api.rs +++ b/src/lua/atproto_api.rs @@ -763,6 +763,25 @@ mod tests { test_db.clone(), crate::db::DatabaseBackend::Sqlite, ), + linked_repos_client: std::sync::Arc::new( + crate::linked_repos::client::build( + "https://plc.directory", + "http://127.0.0.1:0/oauth-client-metadata.json", + "http://127.0.0.1:0", + "http://127.0.0.1:0/auth/callback".into(), + true, + vec![atrium_oauth::Scope::Known( + atrium_oauth::KnownScope::Atproto, + )], + crate::auth::oauth_store::DbStateStore::new( + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ) + .expect("Failed to create test linked-repo OAuth client"), + ), cookie_key: axum_extra::extract::cookie::Key::derive_from( b"test-secret-for-tests-only-not-production", ), diff --git a/src/lua/db_api.rs b/src/lua/db_api.rs index 1e9e6a9..afe0878 100644 --- a/src/lua/db_api.rs +++ b/src/lua/db_api.rs @@ -867,6 +867,25 @@ mod tests { test_db.clone(), crate::db::DatabaseBackend::Sqlite, ), + linked_repos_client: std::sync::Arc::new( + crate::linked_repos::client::build( + "https://plc.directory", + "http://127.0.0.1:0/oauth-client-metadata.json", + "http://127.0.0.1:0", + "http://127.0.0.1:0/auth/callback".into(), + true, + vec![atrium_oauth::Scope::Known( + atrium_oauth::KnownScope::Atproto, + )], + crate::auth::oauth_store::DbStateStore::new( + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ) + .expect("Failed to create test linked-repo OAuth client"), + ), cookie_key: axum_extra::extract::cookie::Key::derive_from( b"test-secret-for-tests-only-not-production", ), diff --git a/src/lua/execute.rs b/src/lua/execute.rs index c22557f..c73d0b2 100644 --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -265,6 +265,31 @@ pub async fn execute_procedure_script( return Err(AppError::Internal(error_message)); } + if let Err(e) = crate::lua::linked_repos_api::register_linked_repos_api(&lua, state_arc.clone()) + { + let error_message = format!("failed to register linked repos API: {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(AppError::Internal(error_message)); + } + if let Some(ref pds_auth) = pds_auth_arc && let Err(e) = atproto_api::register_atproto_blob_api( &lua, @@ -796,6 +821,29 @@ pub async fn execute_query_script( return Err(AppError::Internal(error_message)); } + if let Err(e) = crate::lua::linked_repos_api::register_linked_repos_api(&lua, state_arc.clone()) + { + let error_message = format!("failed to register linked repos API: {e}"); + log_event( + &state.db, + EventLog { + event_type: "script.error".to_string(), + severity: Severity::Error, + actor_did: None, + subject: Some(method.to_string()), + detail: serde_json::json!({ + "error": error_message, + "script_source": script_source, + "method": method, + "duration_ms": start.elapsed().as_millis() as u64, + }), + }, + backend, + ) + .await; + return Err(AppError::Internal(error_message)); + } + // Register the Record API in no-auth mode. Queries don't have a PDS // auth context — the local-only methods (Record.load, :save_local, // :delete_local, Record.delete_local) work; PDS-touching variants diff --git a/src/lua/http_api.rs b/src/lua/http_api.rs index cce9b54..1e7a3c0 100644 --- a/src/lua/http_api.rs +++ b/src/lua/http_api.rs @@ -173,6 +173,25 @@ mod tests { test_db.clone(), crate::db::DatabaseBackend::Sqlite, ), + linked_repos_client: std::sync::Arc::new( + crate::linked_repos::client::build( + "https://plc.directory", + "http://127.0.0.1:0/oauth-client-metadata.json", + "http://127.0.0.1:0", + "http://127.0.0.1:0/auth/callback".into(), + true, + vec![atrium_oauth::Scope::Known( + atrium_oauth::KnownScope::Atproto, + )], + crate::auth::oauth_store::DbStateStore::new( + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ) + .expect("Failed to create test linked-repo OAuth client"), + ), cookie_key: axum_extra::extract::cookie::Key::derive_from( b"test-secret-for-tests-only-not-production", ), diff --git a/src/lua/jobs_api.rs b/src/lua/jobs_api.rs index 281b734..070a1d5 100644 --- a/src/lua/jobs_api.rs +++ b/src/lua/jobs_api.rs @@ -302,6 +302,25 @@ mod tests { test_db.clone(), crate::db::DatabaseBackend::Sqlite, ), + linked_repos_client: std::sync::Arc::new( + crate::linked_repos::client::build( + "https://plc.directory", + "http://127.0.0.1:0/oauth-client-metadata.json", + "http://127.0.0.1:0", + "http://127.0.0.1:0/auth/callback".into(), + true, + vec![atrium_oauth::Scope::Known( + atrium_oauth::KnownScope::Atproto, + )], + crate::auth::oauth_store::DbStateStore::new( + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ) + .expect("Failed to create test linked-repo OAuth client"), + ), cookie_key: axum_extra::extract::cookie::Key::derive_from( b"test-secret-for-tests-only-not-production", ), @@ -541,6 +560,25 @@ mod tests { pool.clone(), crate::db::DatabaseBackend::Sqlite, ), + linked_repos_client: std::sync::Arc::new( + crate::linked_repos::client::build( + "https://plc.directory", + "http://127.0.0.1:0/oauth-client-metadata.json", + "http://127.0.0.1:0", + "http://127.0.0.1:0/auth/callback".into(), + true, + vec![atrium_oauth::Scope::Known( + atrium_oauth::KnownScope::Atproto, + )], + crate::auth::oauth_store::DbStateStore::new( + pool.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + pool.clone(), + crate::db::DatabaseBackend::Sqlite, + ) + .expect("Failed to create test linked-repo OAuth client"), + ), cookie_key: axum_extra::extract::cookie::Key::derive_from( b"test-secret-for-tests-only-not-production", ), diff --git a/src/lua/linked_repos_api.rs b/src/lua/linked_repos_api.rs new file mode 100644 index 0000000..f5e2963 --- /dev/null +++ b/src/lua/linked_repos_api.rs @@ -0,0 +1,219 @@ +use mlua::{Lua, LuaSerdeExt, Result as LuaResult}; +use std::sync::Arc; + +use crate::AppState; +use crate::linked_repos::{db, pds, types}; + +pub(crate) struct LuaLinkedRepo { + state: Arc, + grant_id: String, + did: String, + handle: Option, + status: String, + scopes: String, +} + +impl LuaLinkedRepo { + /// Reload the grant so each call sees current status and scopes rather than + /// whatever was true when the grant was created. + async fn grant(&self) -> mlua::Result { + db::get(&self.state, &self.grant_id) + .await + .map_err(|e| mlua::Error::runtime(format!("{e}")))? + .ok_or_else(|| mlua::Error::runtime(format!("linked repo {} is gone", self.did))) + } +} + +impl mlua::UserData for LuaLinkedRepo { + fn add_fields>(fields: &mut F) { + fields.add_field_method_get("did", |_, this| Ok(this.did.clone())); + fields.add_field_method_get("handle", |_, this| Ok(this.handle.clone())); + fields.add_field_method_get("status", |_, this| Ok(this.status.clone())); + fields.add_field_method_get("scopes", |_, this| Ok(this.scopes.clone())); + } + + fn add_methods>(methods: &mut M) { + methods.add_async_method("create_record", |lua, this, opts: mlua::Table| async move { + let collection: String = opts + .get("collection") + .map_err(|_| mlua::Error::runtime("create_record: collection is required"))?; + let record_val: mlua::Value = opts.get("record")?; + if record_val.is_nil() { + return Err(mlua::Error::runtime("create_record: record is required")); + } + let record: serde_json::Value = lua.from_value(record_val)?; + let rkey: Option = opts.get("rkey").ok(); + + let grant = this.grant().await?; + let (uri, cid) = + pds::create_record(&this.state, &grant, &collection, rkey.as_deref(), record) + .await + .map_err(|e| mlua::Error::runtime(format!("create_record: {e}")))?; + + let result = lua.create_table()?; + result.set("uri", uri)?; + result.set("cid", cid)?; + Ok(mlua::Value::Table(result)) + }); + + methods.add_async_method("put_record", |lua, this, opts: mlua::Table| async move { + let collection: String = opts + .get("collection") + .map_err(|_| mlua::Error::runtime("put_record: collection is required"))?; + let rkey: String = opts + .get("rkey") + .map_err(|_| mlua::Error::runtime("put_record: rkey is required"))?; + let record_val: mlua::Value = opts.get("record")?; + if record_val.is_nil() { + return Err(mlua::Error::runtime("put_record: record is required")); + } + let record: serde_json::Value = lua.from_value(record_val)?; + let swap_cid: Option = opts.get("swap_cid").ok(); + + let grant = this.grant().await?; + let (uri, cid) = pds::put_record( + &this.state, + &grant, + &collection, + &rkey, + record, + swap_cid.as_deref(), + ) + .await + .map_err(|e| mlua::Error::runtime(format!("put_record: {e}")))?; + + let result = lua.create_table()?; + result.set("uri", uri)?; + result.set("cid", cid)?; + Ok(mlua::Value::Table(result)) + }); + + methods.add_async_method( + "delete_record", + |_lua, this, opts: mlua::Table| async move { + let collection: String = opts + .get("collection") + .map_err(|_| mlua::Error::runtime("delete_record: collection is required"))?; + let rkey: String = opts + .get("rkey") + .map_err(|_| mlua::Error::runtime("delete_record: rkey is required"))?; + + let grant = this.grant().await?; + pds::delete_record(&this.state, &grant, &collection, &rkey) + .await + .map_err(|e| mlua::Error::runtime(format!("delete_record: {e}")))?; + Ok(true) + }, + ); + + methods.add_async_method( + "upload_blob", + |lua, this, (bytes, mime): (mlua::LuaString, String)| async move { + let grant = this.grant().await?; + let data = bytes.as_bytes().to_vec(); + let blob = pds::upload_blob(&this.state, &grant, &mime, data) + .await + .map_err(|e| mlua::Error::runtime(format!("upload_blob: {e}")))?; + lua.to_value(&blob) + }, + ); + + methods.add_async_method( + "call", + |lua, this, (nsid, opts): (String, Option)| async move { + let (params, input) = match opts { + Some(t) => { + let params_val: mlua::Value = t.get("params")?; + let input_val: mlua::Value = t.get("input")?; + let params = if params_val.is_nil() { + None + } else { + Some(lua.from_value::(params_val)?) + }; + let input = if input_val.is_nil() { + None + } else { + Some(lua.from_value::(input_val)?) + }; + (params, input) + } + None => (None, None), + }; + + let grant = this.grant().await?; + let out = pds::call(&this.state, &grant, &nsid, params, input) + .await + .map_err(|e| mlua::Error::runtime(format!("call {nsid}: {e}")))?; + lua.to_value(&out) + }, + ); + } +} + +pub fn register_linked_repos_api(lua: &Lua, state: Arc) -> LuaResult<()> { + let table = lua.create_table()?; + + let state_clone = state.clone(); + let list_fn = lua.create_async_function(move |lua, ()| { + let state = state_clone.clone(); + async move { + let grants = db::list(&state) + .await + .map_err(|e| mlua::Error::runtime(format!("linked_repos.list: {e}")))?; + let out = lua.create_table()?; + for (i, grant) in grants.iter().enumerate() { + let entry = lua.create_table()?; + entry.set("id", grant.id.clone())?; + entry.set("did", grant.did.clone())?; + entry.set("handle", grant.handle.clone())?; + entry.set("reason", grant.reason.clone())?; + entry.set("status", grant.status.clone())?; + entry.set("scopes", grant.scopes.clone())?; + out.set(i + 1, entry)?; + } + Ok(mlua::Value::Table(out)) + } + })?; + table.set("list", list_fn)?; + + let state_clone = state.clone(); + let get_fn = lua.create_async_function(move |lua, did: String| { + let state = state_clone.clone(); + async move { + let grant = db::get_by_did(&state, &did) + .await + .map_err(|e| mlua::Error::runtime(format!("linked_repos.get: {e}")))?; + + let Some(grant) = grant else { + return Ok(mlua::Value::Nil); + }; + + if grant.status == types::STATUS_NEEDS_REAUTH { + return Err(mlua::Error::runtime(format!( + "linked repo {did} needs reauthorization: {}", + grant.last_error.as_deref().unwrap_or("unknown error") + ))); + } + + if grant.status == types::STATUS_PENDING { + return Err(mlua::Error::runtime(format!( + "linked repo {did} has not been authorized yet" + ))); + } + + let handle = LuaLinkedRepo { + state: state.clone(), + grant_id: grant.id.clone(), + did: grant.did.clone().unwrap_or(did), + handle: grant.handle.clone(), + status: grant.status.clone(), + scopes: grant.scopes.clone(), + }; + Ok(mlua::Value::UserData(lua.create_userdata(handle)?)) + } + })?; + table.set("get", get_fn)?; + + lua.globals().set("linked_repos", table)?; + Ok(()) +} diff --git a/src/lua/mod.rs b/src/lua/mod.rs index 861c522..fab4340 100644 --- a/src/lua/mod.rs +++ b/src/lua/mod.rs @@ -4,6 +4,7 @@ pub mod db_api; mod execute; pub(crate) mod http_api; pub(crate) mod jobs_api; +pub mod linked_repos_api; pub mod record; pub(crate) mod sandbox; pub mod scripts; diff --git a/src/lua/scripts.rs b/src/lua/scripts.rs index 75da789..ea2b2e3 100644 --- a/src/lua/scripts.rs +++ b/src/lua/scripts.rs @@ -729,8 +729,13 @@ fn extract_bool(v: &Value, key: &str) -> Option { // --------------------------------------------------------------------------- /// Register the default API surface on a fresh sandbox: db / http / xrpc / -/// atproto / Record. `caller_did` flows into xrpc so authenticated calls -/// work; pass `None` for unauthenticated contexts. +/// atproto / linked_repos / jobs / Record. `caller_did` flows into xrpc so +/// authenticated calls work; pass `None` for unauthenticated contexts. +/// +/// `linked_repos` needs no caller: a grant carries its own PDS session, so a +/// record-event script can mirror into a linked repo with no credentials of its +/// own. It is registered here for the same reason the spaces write surface is — +/// every script context is meant to have it. /// /// The Record API is registered in **no-auth mode** here — fine for /// record-event and label scripts which have no caller credentials. @@ -751,6 +756,8 @@ fn register_default_apis( .map_err(|e| format!("atproto api: {e}"))?; crate::lua::spaces_api::register_spaces_write_api(lua, state.clone(), caller_did) .map_err(|e| format!("spaces write api: {e}"))?; + crate::lua::linked_repos_api::register_linked_repos_api(lua, state.clone()) + .map_err(|e| format!("linked repos api: {e}"))?; super::jobs_api::register_jobs_api( lua, state.clone(), @@ -883,6 +890,165 @@ async fn write_dead_letter(state: &AppState, entry: &DeadLetterEntry<'_>) { mod tests { use super::*; + use crate::AppState; + use crate::config::Config; + use crate::lexicon::LexiconRegistry; + use std::sync::Arc; + use tokio::sync::watch; + + fn registration_test_state() -> AppState { + let config = Config { + host: "127.0.0.1".into(), + port: 3000, + database_url: String::new(), + database_backend: crate::db::DatabaseBackend::Sqlite, + public_url: String::new(), + session_secret: "test-secret".into(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + base_path: None, + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + let (tx, _) = watch::channel(vec![]); + let (labeler_tx, _) = watch::channel(()); + sqlx::any::install_default_drivers(); + let test_db = sqlx::AnyPool::connect_lazy("sqlite::memory:").unwrap(); + let backend = crate::db::DatabaseBackend::Sqlite; + let atrium_http = Arc::new(atrium_oauth::DefaultHttpClient::default()); + let did_resolver = atrium_identity::did::CommonDidResolver::new( + atrium_identity::did::CommonDidResolverConfig { + plc_directory_url: "https://plc.directory".into(), + http_client: Arc::clone(&atrium_http), + }, + ); + let handle_resolver = atrium_identity::handle::AtprotoHandleResolver::new( + atrium_identity::handle::AtprotoHandleResolverConfig { + dns_txt_resolver: crate::dns::NativeDnsResolver::new(), + http_client: atrium_http, + }, + ); + let scopes = vec![atrium_oauth::Scope::Known( + atrium_oauth::KnownScope::Atproto, + )]; + let oauth = atrium_oauth::OAuthClient::new(atrium_oauth::OAuthClientConfig { + client_metadata: atrium_oauth::AtprotoLocalhostClientMetadata { + redirect_uris: Some(vec!["http://127.0.0.1:0/auth/callback".into()]), + scopes: Some(scopes.clone()), + }, + keys: None, + state_store: crate::auth::oauth_store::DbStateStore::new(test_db.clone(), backend), + session_store: crate::auth::oauth_store::DbSessionStore::new(test_db.clone(), backend), + resolver: atrium_oauth::OAuthResolverConfig { + did_resolver, + handle_resolver, + authorization_server_metadata: Default::default(), + protected_resource_metadata: Default::default(), + }, + }) + .expect("test OAuth client"); + AppState { + config, + http: reqwest::Client::new(), + db: test_db.clone(), + backfill_db: test_db.clone(), + db_backend: backend, + domain_cache: crate::domain::DomainCache::new(), + lexicons: LexiconRegistry::new(), + collections_tx: tx, + labeler_subscriptions_tx: labeler_tx, + rate_limiter: crate::rate_limit::RateLimiter::new( + crate::rate_limit::RateLimitDefaults { + query_cost: 1, + procedure_cost: 1, + proxy_cost: 1, + }, + ), + oauth: Arc::new(crate::auth::OAuthClientRegistry::new(Arc::new(oauth))), + oauth_state_store: crate::auth::oauth_store::DbStateStore::new( + test_db.clone(), + backend, + ), + linked_repos_client: Arc::new( + crate::linked_repos::client::build( + "https://plc.directory", + "http://127.0.0.1:0/oauth-client-metadata.json", + "http://127.0.0.1:0", + "http://127.0.0.1:0/auth/callback".into(), + true, + scopes, + crate::auth::oauth_store::DbStateStore::new(test_db.clone(), backend), + test_db.clone(), + backend, + ) + .expect("test linked-repo OAuth client"), + ), + cookie_key: axum_extra::extract::cookie::Key::derive_from( + b"test-secret-that-is-at-least-32-bytes-long", + ), + plugin_registry: Arc::new(crate::plugin::PluginRegistry::new()), + wasm_runtime: Arc::new(crate::plugin::WasmRuntime::new().expect("wasm runtime")), + attestation_signer: None, + official_registry: Arc::new(tokio::sync::RwLock::new( + crate::plugin::official_registry::OfficialRegistryState::default(), + )), + official_registry_config: crate::plugin::official_registry::RegistryConfig::production( + ), + proxy_config: Arc::new(arc_swap::ArcSwap::new(Arc::new( + crate::proxy_config::ProxyConfig::default(), + ))), + backfill_events_tx: tokio::sync::broadcast::channel(16).0, + verbose_event_logging: Arc::new(std::sync::atomic::AtomicBool::new(false)), + } + } + + #[tokio::test] + async fn default_apis_include_linked_repos() { + let state = Arc::new(registration_test_state()); + let lua = mlua::Lua::new(); + + register_default_apis(&lua, &state, "record.create:com.example.thing", None) + .expect("default APIs should register"); + + let (kind, get_kind, list_kind): (String, String, String) = lua + .load("return type(linked_repos), type(linked_repos.get), type(linked_repos.list)") + .eval_async() + .await + .expect("linked_repos should be present for record-event and label scripts"); + + assert_eq!(kind, "table"); + assert_eq!(get_kind, "function"); + assert_eq!(list_kind, "function"); + } + + #[tokio::test] + async fn default_apis_still_include_the_existing_globals() { + let state = Arc::new(registration_test_state()); + let lua = mlua::Lua::new(); + + register_default_apis(&lua, &state, "labeler.apply:app.bsky.feed.post", None) + .expect("default APIs should register"); + + let ok: bool = lua + .load( + "return type(db) == 'table' and type(http) == 'table' \ + and type(xrpc) == 'table' and type(atproto) == 'table' \ + and type(atproto.spaces) == 'table' and type(jobs) == 'table'", + ) + .eval_async() + .await + .unwrap(); + assert!(ok, "existing script globals must survive the addition"); + } + #[test] fn parse_record_index_trigger() { let t = ParsedTrigger::parse("record.index:com.example.thing").unwrap(); diff --git a/src/lua/spaces_api.rs b/src/lua/spaces_api.rs index 33a2d49..cf4cd67 100644 --- a/src/lua/spaces_api.rs +++ b/src/lua/spaces_api.rs @@ -635,6 +635,25 @@ mod tests { test_db.clone(), crate::db::DatabaseBackend::Sqlite, ), + linked_repos_client: std::sync::Arc::new( + crate::linked_repos::client::build( + "https://plc.directory", + "http://127.0.0.1:0/oauth-client-metadata.json", + "http://127.0.0.1:0", + "http://127.0.0.1:0/auth/callback".into(), + true, + vec![atrium_oauth::Scope::Known( + atrium_oauth::KnownScope::Atproto, + )], + crate::auth::oauth_store::DbStateStore::new( + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ) + .expect("Failed to create test linked-repo OAuth client"), + ), cookie_key: axum_extra::extract::cookie::Key::derive_from( b"test-secret-for-tests-only-not-production", ), @@ -808,6 +827,22 @@ mod tests { oauth, ))), oauth_state_store: crate::auth::oauth_store::DbStateStore::new(pool.clone(), backend), + linked_repos_client: std::sync::Arc::new( + crate::linked_repos::client::build( + "https://plc.directory", + "http://127.0.0.1:0/oauth-client-metadata.json", + "http://127.0.0.1:0", + "http://127.0.0.1:0/auth/callback".into(), + true, + vec![atrium_oauth::Scope::Known( + atrium_oauth::KnownScope::Atproto, + )], + crate::auth::oauth_store::DbStateStore::new(pool.clone(), backend), + pool.clone(), + backend, + ) + .expect("Failed to create test linked-repo OAuth client"), + ), cookie_key: axum_extra::extract::cookie::Key::derive_from( b"test-secret-for-tests-only-not-production", ), diff --git a/src/lua/xrpc_api.rs b/src/lua/xrpc_api.rs index 5ffe128..9e84305 100644 --- a/src/lua/xrpc_api.rs +++ b/src/lua/xrpc_api.rs @@ -284,6 +284,25 @@ mod tests { test_db.clone(), DatabaseBackend::Sqlite, ), + linked_repos_client: std::sync::Arc::new( + crate::linked_repos::client::build( + "https://plc.directory", + "http://127.0.0.1:0/oauth-client-metadata.json", + "http://127.0.0.1:0", + "http://127.0.0.1:0/auth/callback".into(), + true, + vec![atrium_oauth::Scope::Known( + atrium_oauth::KnownScope::Atproto, + )], + crate::auth::oauth_store::DbStateStore::new( + test_db.clone(), + DatabaseBackend::Sqlite, + ), + test_db.clone(), + DatabaseBackend::Sqlite, + ) + .expect("Failed to create test linked-repo OAuth client"), + ), cookie_key: axum_extra::extract::cookie::Key::derive_from( b"test-secret-for-tests-only-not-production", ), diff --git a/src/main.rs b/src/main.rs index 94ad43e..8beac92 100644 --- a/src/main.rs +++ b/src/main.rs @@ -474,11 +474,13 @@ async fn main() { Scope::Unknown("identity:*".to_string()), ]; + let linked_repos_scopes = oauth_scopes.clone(); + let oauth_client = if is_loopback { info!("Using loopback OAuth client metadata (local development)"); atrium_oauth::OAuthClient::new(OAuthClientConfig { client_metadata: AtprotoLocalhostClientMetadata { - redirect_uris: Some(vec![callback_url]), + redirect_uris: Some(vec![callback_url.clone()]), scopes: Some(oauth_scopes), }, keys: None, @@ -495,7 +497,7 @@ async fn main() { config.effective_public_url().trim_end_matches('/') ), client_uri: Some(config.effective_public_url()), - redirect_uris: vec![callback_url], + redirect_uris: vec![callback_url.clone()], token_endpoint_auth_method: AuthMethod::None, grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], scopes: oauth_scopes, @@ -510,12 +512,24 @@ async fn main() { .expect("Failed to create OAuth client") }; - // Derive the cookie signing key from SESSION_SECRET when it is secure. When - // it is not, log the problem loudly and fall back to an ephemeral random key - // so no attacker can forge cookies with a known/weak key. Cookie-based auth - // is disabled in this state (see the auth extractors and login handlers); - // DPoP, service auth, and API-key auth are unaffected. The server still boots - // so the dashboard can surface the misconfiguration to an operator. + let linked_repos_client = Arc::new( + happyview::linked_repos::client::build( + &config.plc_url, + &format!( + "{}/oauth-client-metadata.json", + config.effective_public_url().trim_end_matches('/') + ), + &config.effective_public_url(), + callback_url.clone(), + is_loopback, + linked_repos_scopes, + oauth_state_store.clone(), + db_pool.clone(), + db_backend, + ) + .expect("Failed to create linked-repo OAuth client"), + ); + let cookie_key = if config.session_secret_secure() { axum_extra::extract::cookie::Key::derive_from(config.session_secret.as_bytes()) } else { @@ -667,6 +681,7 @@ async fn main() { rate_limiter, oauth: oauth_registry, oauth_state_store, + linked_repos_client, cookie_key, plugin_registry, wasm_runtime, @@ -683,6 +698,14 @@ async fn main() { labeler::spawn(state.clone(), labeler_subscriptions_rx); tokio::spawn(labeler::spawn_label_gc(state.db.clone(), state.db_backend)); + { + let gc_db = state.db.clone(); + let gc_backend = state.db_backend; + tokio::spawn(async move { + happyview::auth::state_gc::run_expired_state_gc(gc_db, gc_backend).await; + }); + } + tokio::spawn(happyview::event_log::spawn_retention_cleanup( state.db.clone(), state.config.event_log_retention_days, @@ -724,6 +747,13 @@ async fn main() { }); } + { + let keepalive_state = state.clone(); + tokio::spawn(async move { + happyview::linked_repos::worker::run_keepalive(keepalive_state).await; + }); + } + let app = server::router(state); let addr = config.listen_addr(); diff --git a/src/server.rs b/src/server.rs index dddfa2b..2d5543e 100644 --- a/src/server.rs +++ b/src/server.rs @@ -75,6 +75,14 @@ pub fn router(state: AppState) -> Router { ), )) .nest("/auth", crate::auth::routes::routes()) + .route( + "/auth/linked-repo/start", + get(crate::linked_repos::flow::start_handler), + ) + .route( + "/auth/linked-repo/info", + get(crate::linked_repos::flow::invite_info_handler), + ) .nest("/external-auth", crate::external_auth::routes()) .nest("/oauth", crate::oauth::routes::routes()) // https://atproto.com/specs/oauth#types-of-clients @@ -406,6 +414,22 @@ async fn client_metadata( metadata["policy_uri"] = serde_json::Value::String(uri); } + let base_scopes = crate::linked_repos::scope::client_base_scopes( + oauth_client.client_metadata.scope.as_deref(), + &oauth_client.client_metadata.client_id, + ); + match crate::linked_repos::db::all_scopes(&state).await { + Ok(grant_scopes) => { + let union = crate::linked_repos::scope::union(&grant_scopes, &base_scopes); + metadata["scope"] = serde_json::Value::String(union); + } + Err(e) => { + // Serving a doc without grant scopes would break every linked-repo + // authorization; log loudly rather than failing silently. + tracing::error!(error = %e, "failed to compute linked-repo scope union"); + } + } + Json(metadata) } diff --git a/src/spaces/service.rs b/src/spaces/service.rs index ccd057d..5d51ecb 100644 --- a/src/spaces/service.rs +++ b/src/spaces/service.rs @@ -742,6 +742,22 @@ mod tests { oauth, ))), oauth_state_store: crate::auth::oauth_store::DbStateStore::new(pool.clone(), backend), + linked_repos_client: std::sync::Arc::new( + crate::linked_repos::client::build( + "https://plc.directory", + "http://127.0.0.1:0/oauth-client-metadata.json", + "http://127.0.0.1:0", + "http://127.0.0.1:0/auth/callback".into(), + true, + vec![atrium_oauth::Scope::Known( + atrium_oauth::KnownScope::Atproto, + )], + crate::auth::oauth_store::DbStateStore::new(pool.clone(), backend), + pool.clone(), + backend, + ) + .expect("Failed to create test linked-repo OAuth client"), + ), cookie_key: axum_extra::extract::cookie::Key::derive_from( b"test-secret-for-tests-only-not-production", ), diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..5fca3f8 --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file diff --git a/tests/common/app.rs b/tests/common/app.rs index ba6cbc3..203cdfe 100644 --- a/tests/common/app.rs +++ b/tests/common/app.rs @@ -103,10 +103,14 @@ impl TestApp { http_client: atrium_http, }); let oauth_pool = db::test_pool().await; + let oauth_scopes = vec![ + Scope::Known(KnownScope::Atproto), + Scope::Unknown("identity:*".to_string()), + ]; let oauth = atrium_oauth::OAuthClient::new(OAuthClientConfig { client_metadata: AtprotoLocalhostClientMetadata { redirect_uris: Some(vec!["http://127.0.0.1:0/auth/callback".into()]), - scopes: Some(vec![Scope::Known(KnownScope::Atproto)]), + scopes: Some(oauth_scopes.clone()), }, keys: None, state_store: happyview::auth::oauth_store::DbStateStore::new( @@ -157,6 +161,20 @@ impl TestApp { pool.clone(), backend, ), + linked_repos_client: std::sync::Arc::new( + happyview::linked_repos::client::build( + "https://plc.directory", + "http://127.0.0.1:0/oauth-client-metadata.json", + "http://127.0.0.1:0", + "http://127.0.0.1:0/auth/callback".into(), + true, + oauth_scopes, + happyview::auth::oauth_store::DbStateStore::new(pool.clone(), backend), + pool.clone(), + backend, + ) + .expect("Failed to create test linked-repo OAuth client"), + ), cookie_key: axum_extra::extract::cookie::Key::derive_from( b"test-secret-that-is-at-least-32-bytes-long", ), diff --git a/tests/common/db.rs b/tests/common/db.rs index 5194d58..df601f7 100644 --- a/tests/common/db.rs +++ b/tests/common/db.rs @@ -48,7 +48,7 @@ pub async fn truncate_all(pool: &AnyPool) { match backend { DatabaseBackend::Postgres => { happyview::db::query( - "TRUNCATE happyview_records, happyview_lexicons, happyview_backfill_jobs, happyview_users, happyview_user_permissions, happyview_api_keys, happyview_event_logs, happyview_script_variables, happyview_scripts, happyview_dead_letter_scripts, happyview_dead_letter_hooks, happyview_record_refs, happyview_labeler_subscriptions, happyview_labels, happyview_instance_settings, happyview_domains, happyview_dpop_sessions, happyview_dpop_keys, happyview_api_clients, happyview_delegated_accounts, happyview_account_delegates, happyview_service_identity, happyview_service_entries, happyview_service_entry_xrpcs, happyview_jobs, happyview_spaces, happyview_space_members, happyview_space_records, happyview_space_repo_state, happyview_space_record_oplog, happyview_space_notify_registrations, happyview_space_invites RESTART IDENTITY CASCADE", + "TRUNCATE happyview_records, happyview_lexicons, happyview_backfill_jobs, happyview_users, happyview_user_permissions, happyview_api_keys, happyview_event_logs, happyview_script_variables, happyview_scripts, happyview_dead_letter_scripts, happyview_dead_letter_hooks, happyview_record_refs, happyview_labeler_subscriptions, happyview_labels, happyview_instance_settings, happyview_domains, happyview_dpop_sessions, happyview_dpop_keys, happyview_api_clients, happyview_delegated_accounts, happyview_account_delegates, happyview_service_identity, happyview_service_entries, happyview_service_entry_xrpcs, happyview_jobs, happyview_spaces, happyview_space_members, happyview_space_records, happyview_space_repo_state, happyview_space_record_oplog, happyview_space_notify_registrations, happyview_space_invites, happyview_linked_repo_sessions, happyview_linked_repo_auth_state, happyview_linked_repos, happyview_oauth_sessions RESTART IDENTITY CASCADE", ) .execute(pool) .await @@ -56,6 +56,9 @@ pub async fn truncate_all(pool: &AnyPool) { } DatabaseBackend::Sqlite => { let tables = [ + "happyview_linked_repo_sessions", + "happyview_linked_repo_auth_state", + "happyview_linked_repos", // Spaces tables (children before parents — no cascade on SQLite). "happyview_space_credentials", "happyview_space_dids", @@ -91,6 +94,7 @@ pub async fn truncate_all(pool: &AnyPool) { "happyview_instance_settings", "happyview_domains", "happyview_jobs", + "happyview_oauth_sessions", ]; for table in tables { happyview::db::query(&format!("DELETE FROM {table}")) diff --git a/tests/e2e_linked_repos.rs b/tests/e2e_linked_repos.rs new file mode 100644 index 0000000..0cd0250 --- /dev/null +++ b/tests/e2e_linked_repos.rs @@ -0,0 +1,2484 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use common::app::TestApp; +use happyview::linked_repos::{db, types}; +use http_body_util::BodyExt; +use serial_test::serial; +use tower::ServiceExt; + +#[tokio::test] +#[serial] +async fn create_and_list_grants() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create( + &app.state, + None, + None, + Some("mirror target"), + "atproto repo:com.example.note?action=create", + "did:plc:admin", + ) + .await + .unwrap(); + + assert_eq!(grant.status, types::STATUS_PENDING); + assert!(grant.did.is_none()); + assert_eq!(grant.reason.as_deref(), Some("mirror target")); + + let all = db::list(&app.state).await.unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].id, grant.id); +} + +#[tokio::test] +#[serial] +async fn bind_did_activates_grant() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + + db::bind_did(&app.state, &grant.id, "did:plc:target", Some("target.test")) + .await + .unwrap(); + + let reloaded = db::get(&app.state, &grant.id).await.unwrap().unwrap(); + assert_eq!(reloaded.status, types::STATUS_ACTIVE); + assert_eq!(reloaded.did.as_deref(), Some("did:plc:target")); + assert_eq!(reloaded.handle.as_deref(), Some("target.test")); + assert!(reloaded.authorized_at.is_some()); + + let by_did = db::get_by_did(&app.state, "did:plc:target").await.unwrap(); + assert_eq!(by_did.unwrap().id, grant.id); +} + +#[tokio::test] +#[serial] +async fn mark_needs_reauth_records_error() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + + db::mark_needs_reauth(&app.state, &grant.id, "refresh failed: invalid_grant") + .await + .unwrap(); + + let reloaded = db::get(&app.state, &grant.id).await.unwrap().unwrap(); + assert_eq!(reloaded.status, types::STATUS_NEEDS_REAUTH); + assert_eq!( + reloaded.last_error.as_deref(), + Some("refresh failed: invalid_grant") + ); +} + +#[tokio::test] +#[serial] +async fn delete_removes_grant_and_session() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + + // Seed a session row for the bound DID. + let sql = happyview::db::adapt_sql( + "INSERT INTO happyview_linked_repo_sessions (did, session_data, updated_at) VALUES (?, ?, ?)", + app.state.db_backend, + ); + happyview::db::query(&sql) + .bind("did:plc:target") + .bind("{}") + .bind(happyview::db::now_rfc3339()) + .execute(&app.state.db) + .await + .unwrap(); + + assert!(db::delete(&app.state, &grant.id).await.unwrap()); + + assert!(db::get(&app.state, &grant.id).await.unwrap().is_none()); + + let count_sql = happyview::db::adapt_sql( + "SELECT COUNT(*) FROM happyview_linked_repo_sessions WHERE did = ?", + app.state.db_backend, + ); + let (count,): (i64,) = happyview::db::query_as(&count_sql) + .bind("did:plc:target") + .fetch_one(&app.state.db) + .await + .unwrap(); + assert_eq!(count, 0, "deleting a grant must delete its session"); +} + +#[tokio::test] +#[serial] +async fn all_scopes_returns_every_grants_scopes() { + common::require_db!(); + let app = TestApp::new().await; + + db::create( + &app.state, + None, + None, + None, + "repo:com.example.a", + "did:plc:admin", + ) + .await + .unwrap(); + db::create( + &app.state, + None, + None, + None, + "repo:com.example.b", + "did:plc:admin", + ) + .await + .unwrap(); + + let mut scopes = db::all_scopes(&app.state).await.unwrap(); + scopes.sort(); + assert_eq!(scopes, vec!["repo:com.example.a", "repo:com.example.b"]); +} + +#[tokio::test] +#[serial] +async fn linked_sessions_are_isolated_from_dashboard_sessions() { + common::require_db!(); + let app = TestApp::new().await; + + let sql = happyview::db::adapt_sql( + "INSERT INTO happyview_oauth_sessions (did, session_data, updated_at) VALUES (?, ?, datetime('now'))", + app.state.db_backend, + ); + happyview::db::query(&sql) + .bind("did:plc:shared") + .bind(r#"{"marker":"dashboard"}"#) + .execute(&app.state.db) + .await + .unwrap(); + + let sql = happyview::db::adapt_sql( + "INSERT INTO happyview_linked_repo_sessions (did, session_data, updated_at) VALUES (?, ?, ?)", + app.state.db_backend, + ); + happyview::db::query(&sql) + .bind("did:plc:shared") + .bind(r#"{"marker":"linked"}"#) + .bind(happyview::db::now_rfc3339()) + .execute(&app.state.db) + .await + .unwrap(); + + // Neither table clobbers the other. + let read = |table: &str| { + let sql = happyview::db::adapt_sql( + &format!("SELECT session_data FROM {table} WHERE did = ?"), + app.state.db_backend, + ); + let pool = app.state.db.clone(); + async move { + let (data,): (String,) = happyview::db::query_as(&sql) + .bind("did:plc:shared") + .fetch_one(&pool) + .await + .unwrap(); + data + } + }; + + assert!(read("happyview_oauth_sessions").await.contains("dashboard")); + assert!( + read("happyview_linked_repo_sessions") + .await + .contains("linked") + ); +} + +#[tokio::test] +#[serial] +async fn app_state_exposes_a_linked_repos_client() { + common::require_db!(); + let app = TestApp::new().await; + // Shares the primary client's identity; only the session store differs. + assert_eq!( + app.state.linked_repos_client.client_metadata.client_id, + app.state.oauth.primary_client().client_metadata.client_id, + ); +} + +async fn fetch_client_metadata(app: &TestApp) -> serde_json::Value { + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/oauth-client-metadata.json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() +} + +fn primary_client_scopes(app: &TestApp) -> Vec { + let client = app.state.oauth.get_for_domain("http://127.0.0.1:0"); + let scopes = happyview::linked_repos::scope::client_base_scopes( + client.client_metadata.scope.as_deref(), + &client.client_metadata.client_id, + ); + // A loopback client_id must still yield the scopes it was built with — + // anchor on one so a silently-empty base can't make this vacuous. + assert!( + scopes.split_whitespace().any(|s| s == "atproto"), + "the primary client must hold atproto, got {scopes:?}" + ); + scopes.split_whitespace().map(String::from).collect() +} + +fn scope_tokens(scope: &str) -> Vec { + let mut tokens: Vec = scope.split_whitespace().map(String::from).collect(); + tokens.sort(); + tokens +} + +#[tokio::test] +#[serial] +async fn client_metadata_advertises_base_scopes_with_no_grants() { + common::require_db!(); + let app = TestApp::new().await; + let metadata = fetch_client_metadata(&app).await; + let scope = metadata["scope"].as_str().unwrap(); + + let mut expected = primary_client_scopes(&app); + expected.sort(); + assert!(!expected.is_empty(), "the client must advertise something"); + // With no grants the union is exactly the client's own scopes — no more + // (nothing invented) and no fewer (nothing dropped). + assert_eq!(scope_tokens(scope), expected); +} + +#[tokio::test] +#[serial] +async fn client_metadata_includes_grant_scopes() { + common::require_db!(); + let app = TestApp::new().await; + + db::create( + &app.state, + None, + None, + None, + "repo:com.example.note?action=create blob:image/*", + "did:plc:admin", + ) + .await + .unwrap(); + + let metadata = fetch_client_metadata(&app).await; + let scope = metadata["scope"].as_str().unwrap(); + let advertised = scope_tokens(scope); + assert!( + advertised + .iter() + .any(|s| s == "repo:com.example.note?action=create") + ); + assert!(advertised.iter().any(|s| s == "blob:image/*")); + + for base in primary_client_scopes(&app) { + assert!( + advertised.contains(&base), + "primary client scope {base} must survive the union, got {scope}" + ); + } +} + +#[tokio::test] +#[serial] +async fn deleting_a_grant_drops_its_scopes_from_the_union() { + common::require_db!(); + let app = TestApp::new().await; + + let a = db::create( + &app.state, + None, + None, + None, + "repo:com.example.a", + "did:plc:admin", + ) + .await + .unwrap(); + db::create( + &app.state, + None, + None, + None, + "repo:com.example.shared", + "did:plc:admin", + ) + .await + .unwrap(); + + let metadata = fetch_client_metadata(&app).await; + assert!( + metadata["scope"] + .as_str() + .unwrap() + .contains("repo:com.example.a") + ); + + db::delete(&app.state, &a.id).await.unwrap(); + + let metadata = fetch_client_metadata(&app).await; + assert!( + !metadata["scope"] + .as_str() + .unwrap() + .contains("repo:com.example.a") + ); +} + +#[tokio::test] +#[serial] +async fn shared_scopes_survive_deleting_one_grant() { + common::require_db!(); + let app = TestApp::new().await; + + let a = db::create( + &app.state, + None, + None, + None, + "repo:com.example.shared", + "did:plc:admin", + ) + .await + .unwrap(); + db::create( + &app.state, + None, + None, + None, + "repo:com.example.shared", + "did:plc:admin", + ) + .await + .unwrap(); + + db::delete(&app.state, &a.id).await.unwrap(); + + let metadata = fetch_client_metadata(&app).await; + assert!( + metadata["scope"] + .as_str() + .unwrap() + .contains("repo:com.example.shared"), + "a scope another grant still needs must stay advertised" + ); +} + +fn admin_post( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), + body: &serde_json::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 json_body(resp: axum::response::Response) -> serde_json::Value { + let body = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() +} + +#[tokio::test] +#[serial] +async fn admin_creates_and_lists_a_grant() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_post( + "/admin/linked-repos", + app.admin_cookie(), + &serde_json::json!({ + "reason": "mirror target", + "scopes": "atproto repo:com.example.note?action=create", + }), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let created = json_body(resp).await; + assert_eq!(created["status"], "pending"); + assert!(created["did"].is_null()); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/linked-repos") + .header(app.admin_cookie().0, app.admin_cookie().1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let listed = json_body(resp).await; + assert_eq!(listed["linked_repos"].as_array().unwrap().len(), 1); +} + +#[tokio::test] +#[serial] +async fn admin_create_adds_atproto_to_a_scope_set_without_it() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_post( + "/admin/linked-repos", + app.admin_cookie(), + &serde_json::json!({ "scopes": "repo:com.example.note?action=create" }), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let created = json_body(resp).await; + let scopes = created["scopes"].as_str().unwrap(); + assert!( + scopes.split_whitespace().any(|s| s == "atproto"), + "stored scopes must include atproto, got {scopes}" + ); + + let stored = db::get(&app.state, created["id"].as_str().unwrap()) + .await + .unwrap() + .unwrap(); + assert!(stored.scopes.split_whitespace().any(|s| s == "atproto")); + assert!( + stored + .scopes + .split_whitespace() + .any(|s| s == "repo:com.example.note?action=create"), + "the requested scopes must survive normalization, got {}", + stored.scopes + ); + + let metadata = fetch_client_metadata(&app).await; + let advertised = scope_tokens(metadata["scope"].as_str().unwrap()); + for s in stored.scopes.split_whitespace() { + assert!(advertised.contains(&s.to_string()), "{s} not advertised"); + } +} + +#[tokio::test] +#[serial] +async fn admin_create_does_not_duplicate_atproto() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_post( + "/admin/linked-repos", + app.admin_cookie(), + &serde_json::json!({ "scopes": "atproto repo:com.example.note" }), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let created = json_body(resp).await; + let scopes = created["scopes"].as_str().unwrap(); + assert_eq!( + scopes + .split_whitespace() + .filter(|s| *s == "atproto") + .count(), + 1, + "atproto must appear exactly once, got {scopes}" + ); + assert_eq!(scopes, "atproto repo:com.example.note"); +} + +#[tokio::test] +#[serial] +async fn admin_rejects_invalid_scopes() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_post( + "/admin/linked-repos", + app.admin_cookie(), + &serde_json::json!({ "scopes": "repo:com.example.note?action=frobnicate" }), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +#[serial] +async fn admin_rejects_empty_scopes() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_post( + "/admin/linked-repos", + app.admin_cookie(), + &serde_json::json!({ "scopes": " " }), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +#[serial] +async fn admin_deletes_a_grant() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/admin/linked-repos/{}", grant.id)) + .header(app.admin_cookie().0, app.admin_cookie().1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert!(db::get(&app.state, &grant.id).await.unwrap().is_none()); +} + +#[tokio::test] +#[serial] +async fn admin_list_requires_auth() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/linked-repos") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +#[serial] +async fn complete_binds_an_open_grant() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + + flow::complete(&app.state, &grant.id, "did:plc:whoever") + .await + .unwrap(); + + let reloaded = db::get(&app.state, &grant.id).await.unwrap().unwrap(); + assert_eq!(reloaded.did.as_deref(), Some("did:plc:whoever")); + assert_eq!(reloaded.status, types::STATUS_ACTIVE); +} + +#[tokio::test] +#[serial] +async fn complete_rejects_a_did_mismatch_on_a_pinned_grant() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow; + + let grant = db::create( + &app.state, + Some("did:plc:expected"), + Some("expected.test"), + None, + "atproto", + "did:plc:admin", + ) + .await + .unwrap(); + + let err = flow::complete(&app.state, &grant.id, "did:plc:attacker") + .await + .unwrap_err(); + let msg = format!("{err}"); + assert!( + !msg.contains("did:plc:expected") && !msg.contains("did:plc:attacker"), + "refusal must not disclose either DID, got: {msg}" + ); + assert!(msg.contains("different account"), "got: {msg}"); + + let reloaded = db::get(&app.state, &grant.id).await.unwrap().unwrap(); + assert_eq!(reloaded.did.as_deref(), Some("did:plc:expected")); + assert_eq!( + reloaded.status, + types::STATUS_PENDING, + "a rejected completion must not activate the grant" + ); +} + +#[tokio::test] +#[serial] +async fn complete_rejects_an_already_linked_did() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow; + + let first = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + flow::complete(&app.state, &first.id, "did:plc:target") + .await + .unwrap(); + + let second = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + let err = flow::complete(&app.state, &second.id, "did:plc:target") + .await + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("already linked"), "got: {msg}"); + assert!( + !msg.contains(&first.id), + "refusal must not disclose another grant's id, got: {msg}" + ); + + let reloaded = db::get(&app.state, &second.id).await.unwrap().unwrap(); + assert_eq!(reloaded.status, types::STATUS_PENDING); +} + +#[tokio::test] +#[serial] +async fn a_refused_completion_flags_the_grant_whose_session_it_clobbered() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow; + + let first = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + flow::complete(&app.state, &first.id, "did:plc:target") + .await + .unwrap(); + assert_eq!( + db::get(&app.state, &first.id) + .await + .unwrap() + .unwrap() + .status, + types::STATUS_ACTIVE + ); + + let second = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + flow::complete(&app.state, &second.id, "did:plc:target") + .await + .unwrap_err(); + + let reloaded = db::get(&app.state, &first.id).await.unwrap().unwrap(); + assert_eq!( + reloaded.status, + types::STATUS_NEEDS_REAUTH, + "the grant that lost its session must not still read as active" + ); + assert!( + reloaded.last_error.is_some(), + "the dashboard needs a reason to show" + ); +} + +#[tokio::test] +#[serial] +async fn a_refused_pinned_completion_flags_the_interlopers_own_grant() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow; + + let theirs = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + flow::complete(&app.state, &theirs.id, "did:plc:interloper") + .await + .unwrap(); + + let pinned = db::create( + &app.state, + Some("did:plc:expected"), + None, + None, + "atproto", + "did:plc:admin", + ) + .await + .unwrap(); + flow::complete(&app.state, &pinned.id, "did:plc:interloper") + .await + .unwrap_err(); + + assert_eq!( + db::get(&app.state, &theirs.id) + .await + .unwrap() + .unwrap() + .status, + types::STATUS_NEEDS_REAUTH + ); +} + +#[tokio::test] +#[serial] +async fn invite_token_survives_lookup_and_dies_on_linking() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + let token = flow::mint_invite_with_expiry(&app.state, &grant.id, None) + .await + .unwrap() + .token; + + // Reading it, repeatedly, leaves it usable + for _ in 0..3 { + assert_eq!( + flow::invite_grant_id(&app.state, &token).await.unwrap(), + Some(grant.id.clone()) + ); + assert!(flow::invite_exists(&app.state, &token).await.unwrap()); + } + + // Linking the grant retires it + flow::invalidate_invites_for_grant(&app.state, &grant.id) + .await + .unwrap(); + assert!(!flow::invite_exists(&app.state, &token).await.unwrap()); + assert_eq!( + flow::invite_grant_id(&app.state, &token).await.unwrap(), + None + ); +} + +#[tokio::test] +#[serial] +async fn invite_endpoint_returns_a_url_once() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + + let resp = app + .router + .clone() + .oneshot(admin_post( + &format!("/admin/linked-repos/{}/invite", grant.id), + app.admin_cookie(), + &serde_json::json!({}), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + let url = body["invite_url"].as_str().unwrap(); + assert!(url.contains("/auth/linked-repo/start?token=")); + + // The admin handing this link out needs to know when it stops working. + let expires_at = body["expires_at"] + .as_str() + .expect("invite response must report an expiry"); + assert!( + chrono::DateTime::parse_from_rfc3339(expires_at).is_ok(), + "expires_at must be RFC3339, got: {expires_at}" + ); + + // The raw token must never appear in the list response. + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/linked-repos") + .header(app.admin_cookie().0, app.admin_cookie().1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let listed = serde_json::to_string(&json_body(resp).await).unwrap(); + let token = url.split("token=").nth(1).unwrap(); + assert!( + !listed.contains(token), + "list response leaked the invite token" + ); +} + +#[tokio::test] +#[serial] +async fn a_bare_did_leaves_the_handle_unset() { + common::require_db!(); + let app = TestApp::new().await; + + let resolved = happyview::linked_repos::flow::resolve_identifier(&app.state, "did:plc:pinned") + .await + .unwrap(); + assert_eq!(resolved.did, "did:plc:pinned"); + assert!(resolved.handle.is_none()); + + let resp = app + .router + .clone() + .oneshot(admin_post( + "/admin/linked-repos", + app.admin_cookie(), + &serde_json::json!({ "handle": "did:plc:pinned", "scopes": "atproto" }), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let created = json_body(resp).await; + assert_eq!(created["did"], "did:plc:pinned"); + assert!( + created["handle"].is_null(), + "a DID-pinned grant must not carry its own DID as a handle, got: {}", + created["handle"] + ); +} + +#[tokio::test] +#[serial] +async fn authorize_endpoint_refuses_an_open_grant() { + common::require_db!(); + let app = TestApp::new().await; + + // No handle and no DID: there is nobody to authorize against, so the + // endpoint must say so rather than start a flow for nobody. + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + + let resp = app + .router + .clone() + .oneshot(admin_post( + &format!("/admin/linked-repos/{}/authorize", grant.id), + app.admin_cookie(), + &serde_json::json!({}), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +#[serial] +async fn invite_endpoint_rejects_an_unknown_grant() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_post( + "/admin/linked-repos/does-not-exist/invite", + app.admin_cookie(), + &serde_json::json!({}), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +#[serial] +async fn start_prompts_for_a_handle_without_burning_the_invite() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + let token = flow::mint_invite_with_expiry(&app.state, &grant.id, None) + .await + .unwrap() + .token; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri(format!("/auth/linked-repo/start?token={token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + let location = resp + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap() + .to_string(); + assert!( + location.contains("/link/start?token="), + "expected a redirect to the landing page, got: {location}" + ); + assert!( + location.contains(&urlencoding::encode(&token).to_string()), + "the landing page must receive the token" + ); + + // Loading the prompt must not consume the invite. + assert_eq!( + flow::invite_grant_id(&app.state, &token).await.unwrap(), + Some(grant.id.clone()) + ); +} + +#[tokio::test] +#[serial] +async fn start_rejects_an_unknown_token() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/auth/linked-repo/start?token=nope&handle=someone.test") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let text = String::from_utf8(body.to_vec()).unwrap(); + assert!( + text.contains("this link is invalid, expired, or has already been used"), + "expected the invite check to fail first, got {text}" + ); + assert!( + !text.contains("resolve handle"), + "handle resolution ran before the invite check: {text}" + ); +} + +#[tokio::test] +#[serial] +async fn discard_session_removes_a_linked_session() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow; + + happyview::db::query(&happyview::db::adapt_sql( + "INSERT INTO happyview_linked_repo_sessions (did, session_data, updated_at) VALUES (?, ?, ?)", + app.state.db_backend, + )) + .bind("did:plc:refused") + .bind("{}") + .bind("2026-01-01T00:00:00Z") + .execute(&app.state.db) + .await + .unwrap(); + + flow::discard_session(&app.state, "did:plc:refused") + .await + .unwrap(); + + let remaining: Option<(String,)> = happyview::db::query_as(&happyview::db::adapt_sql( + "SELECT did FROM happyview_linked_repo_sessions WHERE did = ?", + app.state.db_backend, + )) + .bind("did:plc:refused") + .fetch_optional(&app.state.db) + .await + .unwrap(); + assert!(remaining.is_none(), "refused session must not survive"); +} + +async fn insert_auth_state( + app: &TestApp, + state_key: &str, + grant_id: &str, + token_hash: Option<&str>, + expires_at: &str, +) { + let sql = happyview::db::adapt_sql( + "INSERT INTO happyview_linked_repo_auth_state (state, grant_id, token_hash, expires_at) \ + VALUES (?, ?, ?, ?)", + app.state.db_backend, + ); + happyview::db::query(&sql) + .bind(state_key) + .bind(grant_id) + .bind(token_hash) + .bind(expires_at) + .execute(&app.state.db) + .await + .unwrap(); +} + +fn future_rfc3339() -> String { + (chrono::Utc::now() + chrono::Duration::minutes(10)).to_rfc3339() +} + +fn past_rfc3339() -> String { + (chrono::Utc::now() - chrono::Duration::minutes(10)).to_rfc3339() +} + +#[tokio::test] +#[serial] +async fn take_pending_grant_returns_the_grant_and_is_single_use() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow::{self, PendingGrant}; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + insert_auth_state(&app, "state-live", &grant.id, None, &future_rfc3339()).await; + + assert_eq!( + flow::take_pending_grant(&app.state, "state-live") + .await + .unwrap(), + PendingGrant::Grant { + grant_id: grant.id.clone(), + origin: flow::AuthOrigin::Admin, + } + ); + // The DELETE is the claim, so a replay finds nothing at all. + assert_eq!( + flow::take_pending_grant(&app.state, "state-live") + .await + .unwrap(), + PendingGrant::NotLinked + ); +} + +#[tokio::test] +#[serial] +async fn take_pending_grant_reports_not_linked_for_a_dashboard_login() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow::{self, PendingGrant}; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + + insert_auth_state( + &app, + "invite:deadbeef", + &grant.id, + Some("deadbeef"), + &future_rfc3339(), + ) + .await; + + assert_eq!( + flow::take_pending_grant(&app.state, "invite:deadbeef") + .await + .unwrap(), + PendingGrant::NotLinked + ); + assert_eq!( + flow::take_pending_grant(&app.state, "never-seen-this") + .await + .unwrap(), + PendingGrant::NotLinked + ); + + let (count,): (i64,) = happyview::db::query_as(&happyview::db::adapt_sql( + "SELECT COUNT(*) FROM happyview_linked_repo_auth_state WHERE state = ?", + app.state.db_backend, + )) + .bind("invite:deadbeef") + .fetch_one(&app.state.db) + .await + .unwrap(); + assert_eq!(count, 1, "an invite must not be consumed by a callback"); +} + +#[tokio::test] +#[serial] +async fn take_pending_grant_reports_expired_rather_than_not_linked() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow::{self, PendingGrant}; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + insert_auth_state(&app, "state-stale", &grant.id, None, &past_rfc3339()).await; + + assert_eq!( + flow::take_pending_grant(&app.state, "state-stale") + .await + .unwrap(), + PendingGrant::Expired + ); +} + +#[tokio::test] +#[serial] +async fn invite_lookup_rejects_an_expired_token() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + let token = flow::mint_invite_with_expiry(&app.state, &grant.id, None) + .await + .unwrap() + .token; + + happyview::db::query(&happyview::db::adapt_sql( + "UPDATE happyview_linked_repo_auth_state SET expires_at = ? WHERE grant_id = ?", + app.state.db_backend, + )) + .bind(past_rfc3339()) + .bind(&grant.id) + .execute(&app.state.db) + .await + .unwrap(); + + assert!( + !flow::invite_exists(&app.state, &token).await.unwrap(), + "an expired invite must not be usable" + ); +} + +fn callback_request(code: &str, state: &str) -> Request { + Request::builder() + .uri(format!("/auth/callback?code={code}&state={state}")) + .body(Body::empty()) + .unwrap() +} + +#[tokio::test] +#[serial] +async fn an_expired_linked_state_never_reaches_the_dashboard_login_path() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create( + &app.state, + None, + None, + None, + "atproto repo:com.example.note?action=create", + "did:plc:admin", + ) + .await + .unwrap(); + insert_auth_state(&app, "state-expired", &grant.id, None, &past_rfc3339()).await; + + let resp = app + .router + .clone() + .oneshot(callback_request("bogus-code", "state-expired")) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + let location = resp.headers().get("location").unwrap().to_str().unwrap(); + assert!( + location.contains("/link/result?status=expired"), + "got: {location}" + ); + assert!( + resp.headers().get("set-cookie").is_none(), + "a linked-repo callback must never issue a dashboard session cookie" + ); +} + +#[tokio::test] +#[serial] +async fn a_grant_deleted_mid_flight_never_reaches_the_dashboard_login_path() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + insert_auth_state(&app, "state-orphan", &grant.id, None, &future_rfc3339()).await; + assert!(db::delete(&app.state, &grant.id).await.unwrap()); + + let resp = app + .router + .clone() + .oneshot(callback_request("bogus-code", "state-orphan")) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + let location = resp.headers().get("location").unwrap().to_str().unwrap(); + assert!( + location.contains("/link/result?status=gone"), + "got: {location}" + ); + assert!( + resp.headers().get("set-cookie").is_none(), + "a linked-repo callback must never issue a dashboard session cookie" + ); +} + +#[tokio::test] +#[serial] +async fn the_linked_repo_callback_branch_issues_no_cookie() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + insert_auth_state(&app, "state-live", &grant.id, None, &future_rfc3339()).await; + + // A bogus code fails the token exchange, which is as far as a test can get + // without a PDS — but far enough to prove nothing sets a session cookie. + let resp = app + .router + .clone() + .oneshot(callback_request("bogus-code", "state-live")) + .await + .unwrap(); + + let location = resp + .headers() + .get("location") + .map(|v| v.to_str().unwrap().to_string()) + .unwrap_or_default(); + assert!( + location.contains("/link/result?status=failed"), + "expected the failure result page, got status {} location {location:?}", + resp.status() + ); + assert!( + resp.headers().get("set-cookie").is_none(), + "the linked-repo branch must not touch the dashboard cookie jar" + ); +} + +#[tokio::test] +#[serial] +async fn client_for_grant_is_deterministic_and_scope_specific() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow; + + let grant = db::create( + &app.state, + None, + None, + None, + "atproto repo:com.example.note?action=create", + "did:plc:admin", + ) + .await + .unwrap(); + + let at_authorize = flow::client_for_grant(&app.state, &grant).unwrap(); + let reloaded = db::get(&app.state, &grant.id).await.unwrap().unwrap(); + let at_callback = flow::client_for_grant(&app.state, &reloaded).unwrap(); + assert_eq!( + at_authorize.client_metadata.client_id, at_callback.client_metadata.client_id, + "a grant must derive the same client on both sides of the flow" + ); + + let other = db::create( + &app.state, + None, + None, + None, + "atproto repo:com.example.other?action=create", + "did:plc:admin", + ) + .await + .unwrap(); + let other_client = flow::client_for_grant(&app.state, &other).unwrap(); + assert_ne!( + at_authorize.client_metadata.client_id, other_client.client_metadata.client_id, + "different scopes must derive different loopback client ids" + ); +} + +#[tokio::test] +#[serial] +async fn start_rejects_a_url_shaped_handle_without_burning_the_invite() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow; + + // atrium treats any input starting with https:// as a PDS/entryway URL and + // fetches it, so an unvalidated handle here is an SSRF primitive handed to + // whoever holds the invite. + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + let token = flow::mint_invite_with_expiry(&app.state, &grant.id, None) + .await + .unwrap() + .token; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri(format!( + "/auth/linked-repo/start?token={token}&handle=https%3A%2F%2Fexample.com%2F" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + // Rejected before anything was spent. + assert_eq!( + flow::invite_grant_id(&app.state, &token).await.unwrap(), + Some(grant.id.clone()) + ); +} + +#[tokio::test] +#[serial] +async fn a_failed_authorization_leaves_the_invite_usable() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + let token = flow::mint_invite_with_expiry(&app.state, &grant.id, None) + .await + .unwrap() + .token; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri(format!( + "/auth/linked-repo/start?token={token}&handle=did:plc:zzzzzzzzzzzzzzzzzzzzzzzz" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert!( + resp.status().is_client_error() || resp.status().is_server_error(), + "expected the authorization to fail, got {}", + resp.status() + ); + + assert_eq!( + flow::invite_grant_id(&app.state, &token).await.unwrap(), + Some(grant.id.clone()), + "a failed authorization must not burn the invite" + ); +} + +#[tokio::test] +#[serial] +async fn admin_rejects_a_malformed_did() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_post( + "/admin/linked-repos", + app.admin_cookie(), + &serde_json::json!({ "handle": "did:nope", "scopes": "atproto" }), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +#[serial] +async fn refresh_marks_needs_reauth_when_no_session_exists() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::worker; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:ghost", None) + .await + .unwrap(); + let grant = db::get(&app.state, &grant.id).await.unwrap().unwrap(); + + // No session row exists for did:plc:ghost, so restore must fail. + assert!(worker::refresh_grant(&app.state, &grant).await.is_err()); + + let reloaded = db::get(&app.state, &grant.id).await.unwrap().unwrap(); + assert_eq!(reloaded.status, types::STATUS_NEEDS_REAUTH); + assert!(reloaded.last_error.is_some()); +} + +#[tokio::test] +#[serial] +async fn refresh_skips_grants_without_a_did() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::worker; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + + // A pending, unbound grant is not an error — there is nothing to refresh. + assert!(worker::refresh_grant(&app.state, &grant).await.is_ok()); + + let reloaded = db::get(&app.state, &grant.id).await.unwrap().unwrap(); + assert_eq!(reloaded.status, types::STATUS_PENDING); +} + +#[tokio::test] +#[serial] +async fn create_record_refuses_a_collection_outside_scope() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::pds; + + let grant = db::create( + &app.state, + None, + None, + None, + "repo:com.example.allowed?action=create", + "did:plc:admin", + ) + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + let grant = db::get(&app.state, &grant.id).await.unwrap().unwrap(); + + let err = pds::create_record( + &app.state, + &grant, + "com.example.forbidden", + None, + serde_json::json!({ "text": "nope" }), + ) + .await + .unwrap_err(); + + let msg = format!("{err}"); + assert!(msg.contains("com.example.forbidden"), "got: {msg}"); + assert!( + msg.contains("repo:com.example.forbidden?action=create"), + "got: {msg}" + ); +} + +#[tokio::test] +#[serial] +async fn delete_record_refuses_when_only_create_is_granted() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::pds; + + let grant = db::create( + &app.state, + None, + None, + None, + "repo:com.example.note?action=create", + "did:plc:admin", + ) + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + let grant = db::get(&app.state, &grant.id).await.unwrap().unwrap(); + + let err = pds::delete_record(&app.state, &grant, "com.example.note", "abc") + .await + .unwrap_err(); + assert!(format!("{err}").contains("action=delete"), "got: {err}"); +} + +#[tokio::test] +#[serial] +async fn upload_blob_refuses_an_unscoped_mime() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::pds; + + let grant = db::create( + &app.state, + None, + None, + None, + "blob:image/*", + "did:plc:admin", + ) + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + let grant = db::get(&app.state, &grant.id).await.unwrap().unwrap(); + + let err = pds::upload_blob(&app.state, &grant, "video/mp4", vec![0u8; 4]) + .await + .unwrap_err(); + assert!(format!("{err}").contains("video/mp4"), "got: {err}"); +} + +#[tokio::test] +#[serial] +async fn put_record_refuses_a_grant_scoped_to_update_only() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::pds; + + let grant = db::create( + &app.state, + None, + None, + None, + "repo:com.example.note?action=update", + "did:plc:admin", + ) + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + let grant = db::get(&app.state, &grant.id).await.unwrap().unwrap(); + + let err = pds::put_record( + &app.state, + &grant, + "com.example.note", + "abc", + serde_json::json!({ "text": "nope" }), + None, + ) + .await + .unwrap_err(); + + let msg = format!("{err}"); + assert!( + msg.contains("action=create") && msg.contains("action=update"), + "expected the refusal to name both actions, got: {msg}" + ); + assert!( + msg.contains("putRecord can create"), + "expected the refusal to explain why both are needed, got: {msg}" + ); + assert!( + msg.contains("swap_cid"), + "expected the refusal to mention swap_cid as the way out, got: {msg}" + ); +} + +#[tokio::test] +#[serial] +async fn put_record_passes_scope_check_with_both_create_and_update() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::pds; + + let grant = db::create( + &app.state, + None, + None, + None, + "repo:com.example.note?action=create,update", + "did:plc:admin", + ) + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + let grant = db::get(&app.state, &grant.id).await.unwrap().unwrap(); + + let err = pds::put_record( + &app.state, + &grant, + "com.example.note", + "abc", + serde_json::json!({ "text": "ok" }), + None, + ) + .await + .unwrap_err(); + + let msg = format!("{err}"); + assert!( + !msg.contains("lacks the scope"), + "scope check should have passed, got: {msg}" + ); + assert!( + msg.contains("needs reauthorization"), + "expected a session/auth failure, got: {msg}" + ); + + let reloaded = db::get(&app.state, &grant.id).await.unwrap().unwrap(); + assert_eq!( + reloaded.status, + types::STATUS_NEEDS_REAUTH, + "reaching session restoration proves the scope pre-check passed" + ); +} + +#[tokio::test] +#[serial] +async fn put_record_with_swap_cid_passes_scope_check_on_update_only_grant() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::pds; + + let grant = db::create( + &app.state, + None, + None, + None, + "repo:com.example.note?action=update", + "did:plc:admin", + ) + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + let grant = db::get(&app.state, &grant.id).await.unwrap().unwrap(); + + let err = pds::put_record( + &app.state, + &grant, + "com.example.note", + "abc", + serde_json::json!({ "text": "ok" }), + Some("bafyreiabc123"), + ) + .await + .unwrap_err(); + + let msg = format!("{err}"); + assert!( + !msg.contains("lacks the scope"), + "scope check should have passed, got: {msg}" + ); + assert!( + msg.contains("needs reauthorization"), + "expected a session/auth failure, got: {msg}" + ); + + let reloaded = db::get(&app.state, &grant.id).await.unwrap().unwrap(); + assert_eq!( + reloaded.status, + types::STATUS_NEEDS_REAUTH, + "reaching session restoration proves the scope pre-check passed" + ); +} + +#[tokio::test] +#[serial] +async fn put_record_with_swap_cid_still_refuses_a_create_only_grant() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::pds; + + let grant = db::create( + &app.state, + None, + None, + None, + "repo:com.example.note?action=create", + "did:plc:admin", + ) + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + let grant = db::get(&app.state, &grant.id).await.unwrap().unwrap(); + + let err = pds::put_record( + &app.state, + &grant, + "com.example.note", + "abc", + serde_json::json!({ "text": "nope" }), + Some("bafyreiabc123"), + ) + .await + .unwrap_err(); + + let msg = format!("{err}"); + assert!( + msg.contains("action=update"), + "expected the refusal to name the update scope, got: {msg}" + ); + assert!( + msg.contains("swap_cid"), + "expected the refusal to explain the swap_cid path, got: {msg}" + ); +} + +#[tokio::test] +#[serial] +async fn pds_call_refuses_a_grant_in_needs_reauth() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::pds; + + let grant = db::create( + &app.state, + None, + None, + None, + "repo:com.example.note?action=create", + "did:plc:admin", + ) + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + db::mark_needs_reauth(&app.state, &grant.id, "refresh failed: invalid_grant") + .await + .unwrap(); + let grant = db::get(&app.state, &grant.id).await.unwrap().unwrap(); + + let err = pds::create_record( + &app.state, + &grant, + "com.example.note", + None, + serde_json::json!({ "text": "nope" }), + ) + .await + .unwrap_err(); + + let msg = format!("{err}"); + assert!(!msg.contains("lacks the scope"), "got: {msg}"); + assert!(msg.contains("needs reauthorization"), "got: {msg}"); +} + +#[tokio::test] +#[serial] +async fn pds_call_refuses_an_unbound_grant() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::pds; + + let grant = db::create( + &app.state, + None, + None, + None, + "repo:com.example.note?action=create", + "did:plc:admin", + ) + .await + .unwrap(); + + let err = pds::create_record( + &app.state, + &grant, + "com.example.note", + None, + serde_json::json!({ "text": "nope" }), + ) + .await + .unwrap_err(); + + let msg = format!("{err}"); + assert!(!msg.contains("lacks the scope"), "got: {msg}"); + assert!(msg.contains("has not been authorized yet"), "got: {msg}"); +} + +async fn mint_invite_via_api(app: &TestApp, grant_id: &str) -> (String, String) { + let resp = app + .router + .clone() + .oneshot(admin_post( + &format!("/admin/linked-repos/{grant_id}/invite"), + app.admin_cookie(), + &serde_json::json!({}), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + let url = body["invite_url"].as_str().unwrap().to_string(); + let token = url.split("token=").nth(1).unwrap().to_string(); + (token, url) +} + +async fn get_invites(app: &TestApp, grant_id: &str) -> axum::response::Response { + app.router + .clone() + .oneshot( + Request::builder() + .uri(format!("/admin/linked-repos/{grant_id}/invites")) + .header(app.admin_cookie().0, app.admin_cookie().1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() +} + +async fn revoke_invite_via_api( + app: &TestApp, + grant_id: &str, + invite_id: &str, +) -> axum::response::Response { + app.router + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!( + "/admin/linked-repos/{grant_id}/invites/{invite_id}" + )) + .header(app.admin_cookie().0, app.admin_cookie().1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() +} + +async fn seed_grant(app: &TestApp) -> types::LinkedRepo { + db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap() +} + +#[tokio::test] +#[serial] +async fn invite_list_returns_the_hash_not_the_raw_token() { + common::require_db!(); + let app = TestApp::new().await; + let grant = seed_grant(&app).await; + + let (token, _) = mint_invite_via_api(&app, &grant.id).await; + + let body = json_body(get_invites(&app, &grant.id).await).await; + let invites = body["invites"].as_array().unwrap(); + assert_eq!(invites.len(), 1); + + let invite_id = invites[0]["invite_id"].as_str().unwrap(); + assert_ne!( + invite_id, token, + "the raw token must never be recoverable from the list endpoint" + ); + assert_eq!(invite_id.len(), 64, "expected a hex sha256"); + assert!(invites[0]["expires_at"].is_string()); + + let raw = serde_json::to_string(&body).unwrap(); + assert!(!raw.contains(&token), "list response leaked the raw token"); +} + +#[tokio::test] +#[serial] +async fn invite_list_excludes_expired_and_oauth_state_rows() { + common::require_db!(); + let app = TestApp::new().await; + let grant = seed_grant(&app).await; + + // A live invite. + mint_invite_via_api(&app, &grant.id).await; + + let insert = happyview::db::adapt_sql( + "INSERT INTO happyview_linked_repo_auth_state (state, grant_id, token_hash, expires_at) \ + VALUES (?, ?, ?, ?)", + app.state.db_backend, + ); + + // An expired invite. + happyview::db::query(&insert) + .bind("invite:deadbeef") + .bind(&grant.id) + .bind("deadbeef") + .bind("2020-01-01T00:00:00+00:00") + .execute(&app.state.db) + .await + .unwrap(); + + let insert_state = happyview::db::adapt_sql( + "INSERT INTO happyview_linked_repo_auth_state (state, grant_id, expires_at) \ + VALUES (?, ?, ?)", + app.state.db_backend, + ); + happyview::db::query(&insert_state) + .bind("oauth-state-key") + .bind(&grant.id) + .bind("2999-01-01T00:00:00+00:00") + .execute(&app.state.db) + .await + .unwrap(); + + let body = json_body(get_invites(&app, &grant.id).await).await; + let invites = body["invites"].as_array().unwrap(); + assert_eq!( + invites.len(), + 1, + "only the live invite should be listed, got: {invites:?}" + ); + assert_ne!(invites[0]["invite_id"].as_str().unwrap(), "deadbeef"); +} + +#[tokio::test] +#[serial] +async fn invite_list_404s_for_an_unknown_grant() { + common::require_db!(); + let app = TestApp::new().await; + let resp = get_invites(&app, "00000000-0000-0000-0000-000000000000").await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +#[serial] +async fn revoking_an_invite_makes_it_unusable() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow; + + let grant = seed_grant(&app).await; + let (token, _) = mint_invite_via_api(&app, &grant.id).await; + + let body = json_body(get_invites(&app, &grant.id).await).await; + let invite_id = body["invites"][0]["invite_id"] + .as_str() + .unwrap() + .to_string(); + + let resp = revoke_invite_via_api(&app, &grant.id, &invite_id).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(json_body(resp).await["revoked"], true); + + // the link no longer works + assert!( + !flow::invite_exists(&app.state, &token).await.unwrap(), + "a revoked invite must not be consumable" + ); + + let body = json_body(get_invites(&app, &grant.id).await).await; + assert!(body["invites"].as_array().unwrap().is_empty()); +} + +#[tokio::test] +#[serial] +async fn revoking_through_another_grant_is_refused_and_leaves_the_invite_usable() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow; + + let owner = seed_grant(&app).await; + let other = seed_grant(&app).await; + let (token, _) = mint_invite_via_api(&app, &owner.id).await; + + let body = json_body(get_invites(&app, &owner.id).await).await; + let invite_id = body["invites"][0]["invite_id"] + .as_str() + .unwrap() + .to_string(); + + let resp = revoke_invite_via_api(&app, &other.id, &invite_id).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + assert!( + flow::invite_exists(&app.state, &token).await.unwrap(), + "revoking via the wrong grant must not affect the owner's invite" + ); +} + +#[tokio::test] +#[serial] +async fn revoking_an_unknown_invite_404s() { + common::require_db!(); + let app = TestApp::new().await; + let grant = seed_grant(&app).await; + + let resp = revoke_invite_via_api(&app, &grant.id, "not-a-real-hash").await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +#[serial] +async fn invite_honours_expires_in_and_rejects_out_of_range() { + common::require_db!(); + let app = TestApp::new().await; + let grant = seed_grant(&app).await; + + let resp = app + .router + .clone() + .oneshot(admin_post( + &format!("/admin/linked-repos/{}/invite", grant.id), + app.admin_cookie(), + &serde_json::json!({ "expires_in": 120 }), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let expires_at = json_body(resp).await["expires_at"] + .as_str() + .unwrap() + .to_string(); + + // 120s out should be within the 7-day TTL + let parsed = chrono::DateTime::parse_from_rfc3339(&expires_at).unwrap(); + let delta = parsed.with_timezone(&chrono::Utc) - chrono::Utc::now(); + assert!( + delta.num_seconds() > 0 && delta.num_seconds() <= 130, + "expected ~120s TTL, got {}s", + delta.num_seconds() + ); + + for bad in [30_i64, 60 * 60 * 24 * 60] { + let resp = app + .router + .clone() + .oneshot(admin_post( + &format!("/admin/linked-repos/{}/invite", grant.id), + app.admin_cookie(), + &serde_json::json!({ "expires_in": bad }), + )) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "expires_in={bad} should be rejected" + ); + } +} + +#[tokio::test] +#[serial] +async fn invite_endpoint_accepts_a_json_content_type_with_an_empty_body() { + common::require_db!(); + let app = TestApp::new().await; + let grant = seed_grant(&app).await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/admin/linked-repos/{}/invite", grant.id)) + .header(app.admin_cookie().0, app.admin_cookie().1) + .header("content-type", "application/json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + assert!(json_body(resp).await["invite_url"].is_string()); +} + +#[tokio::test] +#[serial] +async fn invite_endpoint_still_accepts_no_body() { + common::require_db!(); + let app = TestApp::new().await; + let grant = seed_grant(&app).await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/admin/linked-repos/{}/invite", grant.id)) + .header(app.admin_cookie().0, app.admin_cookie().1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + assert!(json_body(resp).await["invite_url"].is_string()); +} + +#[tokio::test] +#[serial] +async fn reaching_the_pds_redirect_does_not_burn_the_invite() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow; + + let grant = seed_grant(&app).await; + let (token, _) = mint_invite_via_api(&app, &grant.id).await; + + let _ = app + .router + .clone() + .oneshot( + Request::builder() + .uri(format!( + "/auth/linked-repo/start?token={token}&handle=nonexistent.invalid" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + flow::invite_exists(&app.state, &token).await.unwrap(), + "the invite must survive a trip to the PDS that was never completed" + ); +} + +#[tokio::test] +#[serial] +async fn linking_a_grant_clears_its_outstanding_invites() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow; + + let grant = seed_grant(&app).await; + let (first, _) = mint_invite_via_api(&app, &grant.id).await; + let (second, _) = mint_invite_via_api(&app, &grant.id).await; + + // An unrelated grant's invite must be left alone. + let other = seed_grant(&app).await; + let (other_token, _) = mint_invite_via_api(&app, &other.id).await; + + let cleared = flow::invalidate_invites_for_grant(&app.state, &grant.id) + .await + .unwrap(); + assert_eq!(cleared, 2, "both of this grant's invites should be cleared"); + + assert!(!flow::invite_exists(&app.state, &first).await.unwrap()); + assert!(!flow::invite_exists(&app.state, &second).await.unwrap()); + assert!( + flow::invite_exists(&app.state, &other_token).await.unwrap(), + "another grant's invite must be untouched" + ); +} + +#[tokio::test] +#[serial] +async fn invalidating_invites_leaves_in_flight_oauth_state_alone() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow; + + let grant = seed_grant(&app).await; + mint_invite_via_api(&app, &grant.id).await; + // An in-flight OAuth state row for the same grant shares this table. + insert_auth_state(&app, "state-inflight", &grant.id, None, &future_rfc3339()).await; + + flow::invalidate_invites_for_grant(&app.state, &grant.id) + .await + .unwrap(); + + // The authorization currently being completed must not be destroyed by + // clearing invites. + let sql = happyview::db::adapt_sql( + "SELECT COUNT(*) FROM happyview_linked_repo_auth_state WHERE state = ?", + app.state.db_backend, + ); + let (count,): (i64,) = happyview::db::query_as(&sql) + .bind("state-inflight") + .fetch_one(&app.state.db) + .await + .unwrap(); + assert_eq!(count, 1, "in-flight OAuth state must survive"); +} + +/// Removing a repo takes its invite links with it — a link for a grant that no +/// longer exists must not stay usable. +#[tokio::test] +#[serial] +async fn deleting_a_grant_deletes_its_invites() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::linked_repos::flow; + + let grant = seed_grant(&app).await; + let (token, _) = mint_invite_via_api(&app, &grant.id).await; + assert!(flow::invite_exists(&app.state, &token).await.unwrap()); + + // Another grant's invite must survive. + let other = seed_grant(&app).await; + let (other_token, _) = mint_invite_via_api(&app, &other.id).await; + + assert!(db::delete(&app.state, &grant.id).await.unwrap()); + + assert!( + !flow::invite_exists(&app.state, &token).await.unwrap(), + "an invite for a deleted grant must stop working" + ); + assert!( + flow::invite_exists(&app.state, &other_token).await.unwrap(), + "another grant's invite must be untouched" + ); +} + +/// ...but it deliberately leaves in-flight OAuth state alone. +/// +/// If an admin removes a repo while someone is partway through authorizing it, +/// that person's callback still has to be *recognised* as a linked-repo +/// callback so they get the "request withdrawn" page. Delete the state row and +/// the callback falls through to the dashboard-login exchange instead — which +/// is how a linked account's elevated-scope tokens could end up in the +/// dashboard session table. +#[tokio::test] +#[serial] +async fn deleting_a_grant_preserves_in_flight_oauth_state() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = seed_grant(&app).await; + mint_invite_via_api(&app, &grant.id).await; + insert_auth_state(&app, "state-midflight", &grant.id, None, &future_rfc3339()).await; + + assert!(db::delete(&app.state, &grant.id).await.unwrap()); + + let sql = happyview::db::adapt_sql( + "SELECT COUNT(*) FROM happyview_linked_repo_auth_state WHERE state = ?", + app.state.db_backend, + ); + let (count,): (i64,) = happyview::db::query_as(&sql) + .bind("state-midflight") + .fetch_one(&app.state.db) + .await + .unwrap(); + assert_eq!( + count, 1, + "in-flight OAuth state must outlive the grant so the callback can \ + report it was withdrawn rather than falling through to dashboard login" + ); + + // And the callback does exactly that. + let resp = app + .router + .clone() + .oneshot(callback_request("bogus-code", "state-midflight")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + let location = resp.headers().get("location").unwrap().to_str().unwrap(); + assert!( + location.contains("/link/result?status=gone"), + "got: {location}" + ); + assert!( + resp.headers().get("set-cookie").is_none(), + "must never issue a dashboard session cookie" + ); +} + +// --------------------------------------------------------------------------- +// Expired OAuth-state cleanup +// --------------------------------------------------------------------------- + +/// Abandoned flows leave rows in both state tables. Nothing stale is ever +/// honoured — every read filters on `expires_at` — but without a sweep the rows +/// accumulate with no ceiling. +#[tokio::test] +#[serial] +async fn expired_state_gc_purges_both_tables_and_spares_live_rows() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::auth::state_gc::purge_expired_state; + + let grant = seed_grant(&app).await; + + // A live invite and a live in-flight authorization. + let (live_token, _) = mint_invite_via_api(&app, &grant.id).await; + insert_auth_state(&app, "state-live", &grant.id, None, &future_rfc3339()).await; + + // Their expired counterparts. + insert_auth_state(&app, "state-dead", &grant.id, None, &past_rfc3339()).await; + let insert_invite = happyview::db::adapt_sql( + "INSERT INTO happyview_linked_repo_auth_state (state, grant_id, token_hash, expires_at) \ + VALUES (?, ?, ?, ?)", + app.state.db_backend, + ); + happyview::db::query(&insert_invite) + .bind("invite:expired") + .bind(&grant.id) + .bind("expiredhash") + .bind(past_rfc3339()) + .execute(&app.state.db) + .await + .unwrap(); + + // And the sibling table the same sweep covers. + let insert_redirect = happyview::db::adapt_sql( + "INSERT INTO happyview_auth_login_redirects (state, redirect_uri, created_at, expires_at) \ + VALUES (?, ?, ?, ?)", + app.state.db_backend, + ); + for (key, expires) in [ + ("redirect-dead", past_rfc3339()), + ("redirect-live", future_rfc3339()), + ] { + happyview::db::query(&insert_redirect) + .bind(key) + .bind("/dashboard") + .bind(happyview::db::now_rfc3339()) + .bind(expires) + .execute(&app.state.db) + .await + .unwrap(); + } + + let removed = purge_expired_state(&app.state.db, app.state.db_backend).await; + assert_eq!( + removed, 3, + "expected the expired invite, in-flight state, and redirect to be swept" + ); + + let count = |table: &'static str, key: &'static str| { + let sql = happyview::db::adapt_sql( + &format!("SELECT COUNT(*) FROM {table} WHERE state = ?"), + app.state.db_backend, + ); + let pool = app.state.db.clone(); + async move { + let (n,): (i64,) = happyview::db::query_as(&sql) + .bind(key) + .fetch_one(&pool) + .await + .unwrap(); + n + } + }; + + assert_eq!( + count("happyview_linked_repo_auth_state", "state-dead").await, + 0 + ); + assert_eq!( + count("happyview_linked_repo_auth_state", "invite:expired").await, + 0 + ); + assert_eq!( + count("happyview_auth_login_redirects", "redirect-dead").await, + 0 + ); + + // Live rows are untouched, and the live invite still works. + assert_eq!( + count("happyview_linked_repo_auth_state", "state-live").await, + 1 + ); + assert_eq!( + count("happyview_auth_login_redirects", "redirect-live").await, + 1 + ); + assert!( + happyview::linked_repos::flow::invite_exists(&app.state, &live_token) + .await + .unwrap(), + "a live invite must survive the sweep" + ); +} + +/// Sweeping an already-clean database is a no-op, so the hourly tick is free. +#[tokio::test] +#[serial] +async fn expired_state_gc_is_a_no_op_when_nothing_has_expired() { + common::require_db!(); + let app = TestApp::new().await; + use happyview::auth::state_gc::purge_expired_state; + + let grant = seed_grant(&app).await; + mint_invite_via_api(&app, &grant.id).await; + + assert_eq!( + purge_expired_state(&app.state.db, app.state.db_backend).await, + 0 + ); +} diff --git a/tests/lua_atproto_api.rs b/tests/lua_atproto_api.rs index 30b82c7..4643521 100644 --- a/tests/lua_atproto_api.rs +++ b/tests/lua_atproto_api.rs @@ -84,6 +84,20 @@ async fn test_state_with_pool(pool: sqlx::AnyPool, backend: DatabaseBackend) -> std::sync::Arc::new(oauth), )), oauth_state_store: happyview::auth::oauth_store::DbStateStore::new(pool.clone(), backend), + linked_repos_client: std::sync::Arc::new( + happyview::linked_repos::client::build( + "https://plc.directory", + "http://127.0.0.1:0/oauth-client-metadata.json", + "http://127.0.0.1:0", + "http://127.0.0.1:0/auth/callback".into(), + true, + vec![Scope::Known(KnownScope::Atproto)], + happyview::auth::oauth_store::DbStateStore::new(pool.clone(), backend), + pool.clone(), + backend, + ) + .expect("Failed to create test linked-repo OAuth client"), + ), cookie_key: axum_extra::extract::cookie::Key::derive_from( b"test-secret-that-is-at-least-32-bytes-long", ), diff --git a/tests/lua_db_api.rs b/tests/lua_db_api.rs index 91a51c5..ce6bded 100644 --- a/tests/lua_db_api.rs +++ b/tests/lua_db_api.rs @@ -87,6 +87,20 @@ async fn test_state_with_pool(pool: sqlx::AnyPool, backend: DatabaseBackend) -> std::sync::Arc::new(oauth), )), oauth_state_store: happyview::auth::oauth_store::DbStateStore::new(pool.clone(), backend), + linked_repos_client: std::sync::Arc::new( + happyview::linked_repos::client::build( + "https://plc.directory", + "http://127.0.0.1:0/oauth-client-metadata.json", + "http://127.0.0.1:0", + "http://127.0.0.1:0/auth/callback".into(), + true, + vec![Scope::Known(KnownScope::Atproto)], + happyview::auth::oauth_store::DbStateStore::new(pool.clone(), backend), + pool.clone(), + backend, + ) + .expect("Failed to create test linked-repo OAuth client"), + ), cookie_key: axum_extra::extract::cookie::Key::derive_from( b"test-secret-that-is-at-least-32-bytes-long", ), diff --git a/tests/lua_linked_repos_api.rs b/tests/lua_linked_repos_api.rs new file mode 100644 index 0000000..4c74a96 --- /dev/null +++ b/tests/lua_linked_repos_api.rs @@ -0,0 +1,605 @@ +mod common; + +use common::app::TestApp; +use happyview::linked_repos::db; +use serial_test::serial; +use std::sync::Arc; + +async fn lua_with_api(app: &TestApp) -> mlua::Lua { + let lua = mlua::Lua::new(); + happyview::lua::linked_repos_api::register_linked_repos_api(&lua, Arc::new(app.state.clone())) + .unwrap(); + lua +} + +#[tokio::test] +#[serial] +async fn global_is_registered() { + common::require_db!(); + let app = TestApp::new().await; + let lua = lua_with_api(&app).await; + + let ok: bool = lua + .load( + r#"return type(linked_repos) == "table" + and type(linked_repos.get) == "function" + and type(linked_repos.list) == "function""#, + ) + .eval_async() + .await + .unwrap(); + assert!(ok); +} + +#[tokio::test] +#[serial] +async fn list_returns_grants() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create( + &app.state, + None, + None, + Some("mirror"), + "atproto", + "did:plc:admin", + ) + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", Some("target.test")) + .await + .unwrap(); + + let lua = lua_with_api(&app).await; + let (count, did, reason): (i64, String, String) = lua + .load( + r#"local all = linked_repos.list() + return #all, all[1].did, all[1].reason"#, + ) + .eval_async() + .await + .unwrap(); + + assert_eq!(count, 1); + assert_eq!(did, "did:plc:target"); + assert_eq!(reason, "mirror"); +} + +#[tokio::test] +#[serial] +async fn get_returns_nil_for_unknown_did() { + common::require_db!(); + let app = TestApp::new().await; + let lua = lua_with_api(&app).await; + + let is_nil: bool = lua + .load(r#"return linked_repos.get("did:plc:nobody") == nil"#) + .eval_async() + .await + .unwrap(); + assert!(is_nil); +} + +#[tokio::test] +#[serial] +async fn get_exposes_handle_fields() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create( + &app.state, + None, + None, + None, + "repo:com.example.note?action=create", + "did:plc:admin", + ) + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", Some("target.test")) + .await + .unwrap(); + + let lua = lua_with_api(&app).await; + let (did, handle, status): (String, String, String) = lua + .load( + r#"local repo = linked_repos.get("did:plc:target") + return repo.did, repo.handle, repo.status"#, + ) + .eval_async() + .await + .unwrap(); + + assert_eq!(did, "did:plc:target"); + assert_eq!(handle, "target.test"); + assert_eq!(status, "active"); +} + +#[tokio::test] +#[serial] +async fn create_record_raises_on_missing_scope() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create( + &app.state, + None, + None, + None, + "repo:com.example.allowed?action=create", + "did:plc:admin", + ) + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + + let lua = lua_with_api(&app).await; + let err = lua + .load( + r#"local repo = linked_repos.get("did:plc:target") + repo:create_record{ collection = "com.example.forbidden", record = { a = 1 } }"#, + ) + .exec_async() + .await + .unwrap_err() + .to_string(); + + assert!(err.contains("com.example.forbidden"), "got: {err}"); +} + +#[tokio::test] +#[serial] +async fn get_raises_when_grant_needs_reauth() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + db::mark_needs_reauth(&app.state, &grant.id, "revoked") + .await + .unwrap(); + + let lua = lua_with_api(&app).await; + let err = lua + .load(r#"local repo = linked_repos.get("did:plc:target")"#) + .exec_async() + .await + .unwrap_err() + .to_string(); + + assert!(err.contains("needs reauthorization"), "got: {err}"); +} + +#[tokio::test] +#[serial] +async fn create_record_requires_a_collection() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create(&app.state, None, None, None, "repo:*", "did:plc:admin") + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + + let lua = lua_with_api(&app).await; + let err = lua + .load( + r#"local repo = linked_repos.get("did:plc:target") + repo:create_record{ record = { a = 1 } }"#, + ) + .exec_async() + .await + .unwrap_err() + .to_string(); + + assert!(err.contains("collection is required"), "got: {err}"); +} + +#[tokio::test] +#[serial] +async fn put_record_swap_cid_is_passed_through_to_the_scope_check() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create( + &app.state, + None, + None, + None, + "repo:com.example.note?action=update", + "did:plc:admin", + ) + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + + let lua = lua_with_api(&app).await; + + // Without swap_cid: refused, naming both actions. + let err_no_swap = lua + .load( + r#"local repo = linked_repos.get("did:plc:target") + repo:put_record{ collection = "com.example.note", rkey = "abc", record = { a = 1 } }"#, + ) + .exec_async() + .await + .unwrap_err() + .to_string(); + assert!( + err_no_swap.contains("action=create") && err_no_swap.contains("action=update"), + "got: {err_no_swap}" + ); + + // With swap_cid: scope check passes; failure comes from session + // restoration instead (no OAuth session exists for did:plc:target). + let err_with_swap = lua + .load( + r#"local repo = linked_repos.get("did:plc:target") + repo:put_record{ + collection = "com.example.note", + rkey = "abc", + record = { a = 1 }, + swap_cid = "bafyreiabc123", + }"#, + ) + .exec_async() + .await + .unwrap_err() + .to_string(); + assert!( + !err_with_swap.contains("lacks the scope"), + "scope check should have passed, got: {err_with_swap}" + ); + assert!( + err_with_swap.contains("needs reauthorization"), + "expected a session/auth failure, got: {err_with_swap}" + ); +} + +#[tokio::test] +#[serial] +async fn delete_record_raises_on_missing_scope() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create( + &app.state, + None, + None, + None, + "repo:com.example.allowed?action=create", + "did:plc:admin", + ) + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + + let lua = lua_with_api(&app).await; + let err = lua + .load( + r#"local repo = linked_repos.get("did:plc:target") + repo:delete_record{ collection = "com.example.allowed", rkey = "abc" }"#, + ) + .exec_async() + .await + .unwrap_err() + .to_string(); + + assert!(err.contains("delete"), "got: {err}"); + assert!(err.contains("com.example.allowed"), "got: {err}"); +} + +#[tokio::test] +#[serial] +async fn delete_record_requires_a_collection() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create(&app.state, None, None, None, "repo:*", "did:plc:admin") + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + + let lua = lua_with_api(&app).await; + let err = lua + .load( + r#"local repo = linked_repos.get("did:plc:target") + repo:delete_record{ rkey = "abc" }"#, + ) + .exec_async() + .await + .unwrap_err() + .to_string(); + + assert!(err.contains("collection is required"), "got: {err}"); +} + +#[tokio::test] +#[serial] +async fn delete_record_requires_a_rkey() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create(&app.state, None, None, None, "repo:*", "did:plc:admin") + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + + let lua = lua_with_api(&app).await; + let err = lua + .load( + r#"local repo = linked_repos.get("did:plc:target") + repo:delete_record{ collection = "com.example.note" }"#, + ) + .exec_async() + .await + .unwrap_err() + .to_string(); + + assert!(err.contains("rkey is required"), "got: {err}"); +} + +#[tokio::test] +#[serial] +async fn upload_blob_passes_non_utf8_bytes_through_intact() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create( + &app.state, + None, + None, + None, + "blob:image/png", + "did:plc:admin", + ) + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + + let lua = lua_with_api(&app).await; + let err = lua + .load( + r#"local repo = linked_repos.get("did:plc:target") + local bytes = string.char(0xFF, 0xFE, 0x00, 0x01) + repo:upload_blob(bytes, "image/png")"#, + ) + .exec_async() + .await + .unwrap_err() + .to_string(); + + assert!( + !err.contains("lacks a blob scope"), + "scope check should have passed, got: {err}" + ); + assert!( + !err.to_lowercase().contains("utf-8") && !err.to_lowercase().contains("utf8"), + "bytes should not have been rejected as invalid UTF-8, got: {err}" + ); + assert!( + err.contains("needs reauthorization"), + "expected a session/auth failure, got: {err}" + ); +} + +#[tokio::test] +#[serial] +async fn upload_blob_raises_on_missing_blob_scope() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create( + &app.state, + None, + None, + None, + "blob:video/mp4", + "did:plc:admin", + ) + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + + let lua = lua_with_api(&app).await; + let err = lua + .load( + r#"local repo = linked_repos.get("did:plc:target") + local bytes = string.char(0xFF, 0xFE, 0x00, 0x01) + repo:upload_blob(bytes, "image/png")"#, + ) + .exec_async() + .await + .unwrap_err() + .to_string(); + + assert!(err.contains("lacks a blob scope"), "got: {err}"); + assert!(err.contains("image/png"), "got: {err}"); +} + +#[tokio::test] +#[serial] +async fn call_converts_params_table_without_raising() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + + let lua = lua_with_api(&app).await; + let err = lua + .load( + r#"local repo = linked_repos.get("did:plc:target") + repo:call("com.example.doThing", { params = { foo = "bar", n = 1 } })"#, + ) + .exec_async() + .await + .unwrap_err() + .to_string(); + + assert!( + err.contains("needs reauthorization"), + "expected a session/auth failure, got: {err}" + ); +} + +#[tokio::test] +#[serial] +async fn call_converts_input_table_without_raising() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create(&app.state, None, None, None, "atproto", "did:plc:admin") + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + + let lua = lua_with_api(&app).await; + let err = lua + .load( + r#"local repo = linked_repos.get("did:plc:target") + repo:call("com.example.doThing", { input = { foo = "bar", nested = { a = 1 } } })"#, + ) + .exec_async() + .await + .unwrap_err() + .to_string(); + + assert!( + err.contains("needs reauthorization"), + "expected a session/auth failure, got: {err}" + ); +} + +#[tokio::test] +#[serial] +async fn stale_handle_is_rejected_after_grant_needs_reauth() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create(&app.state, None, None, None, "repo:*", "did:plc:admin") + .await + .unwrap(); + db::bind_did(&app.state, &grant.id, "did:plc:target", None) + .await + .unwrap(); + + let lua = lua_with_api(&app).await; + let get_and_flip: mlua::Table = lua + .load( + r#"local repo = linked_repos.get("did:plc:target") + return { repo = repo }"#, + ) + .eval_async() + .await + .unwrap(); + let repo: mlua::Value = get_and_flip.get("repo").unwrap(); + lua.globals().set("stale_repo", repo).unwrap(); + + db::mark_needs_reauth(&app.state, &grant.id, "revoked") + .await + .unwrap(); + + let err = lua + .load(r#"stale_repo:create_record{ collection = "com.example.note", record = { a = 1 } }"#) + .exec_async() + .await + .unwrap_err() + .to_string(); + + assert!(err.contains("needs reauthorization"), "got: {err}"); +} + +#[tokio::test] +#[serial] +async fn global_is_registered_for_record_event_scripts() { + common::require_db!(); + let app = TestApp::new().await; + + let grant = db::create( + &app.state, + None, + None, + Some("mirror"), + "atproto", + "did:plc:abcdefghijklmnopqrstuvwx", + ) + .await + .unwrap(); + db::bind_did( + &app.state, + &grant.id, + "did:plc:234567abcdefghijklmnopqr", + Some("target.test"), + ) + .await + .unwrap(); + + let script = happyview::lua::ResolvedScript { + id: "record.create:com.example.note".to_string(), + language: happyview::lua::ScriptLanguage::Lua, + body: r#" + function handle() + local all = linked_repos.list() + return { + kind = type(linked_repos), + get = type(linked_repos.get), + list = type(linked_repos.list), + count = #all, + did = all[1] and all[1].did or nil, + } + end + "# + .to_string(), + }; + + let record = serde_json::json!({ "title": "hello" }); + let out = happyview::lua::run_record_event_once( + &app.state, + &script, + happyview::lua::RecordEventPayload { + nsid: "com.example.note", + action: "create", + uri: "at://did:plc:author/com.example.note/abc", + did: "did:plc:author", + rkey: "abc", + record: Some(&record), + }, + ) + .await + .unwrap() + .expect("script returned a table"); + + assert_eq!(out["kind"], "table"); + assert_eq!(out["get"], "function"); + assert_eq!(out["list"], "function"); + assert_eq!(out["count"], 1); + assert_eq!(out["did"], "did:plc:234567abcdefghijklmnopqr"); +} diff --git a/tests/lua_record_api.rs b/tests/lua_record_api.rs index fd54e63..1b9763b 100644 --- a/tests/lua_record_api.rs +++ b/tests/lua_record_api.rs @@ -92,6 +92,20 @@ async fn test_state_with_pool(pool: sqlx::AnyPool, backend: DatabaseBackend) -> std::sync::Arc::new(oauth), )), oauth_state_store: happyview::auth::oauth_store::DbStateStore::new(pool.clone(), backend), + linked_repos_client: std::sync::Arc::new( + happyview::linked_repos::client::build( + "https://plc.directory", + "http://127.0.0.1:0/oauth-client-metadata.json", + "http://127.0.0.1:0", + "http://127.0.0.1:0/auth/callback".into(), + true, + vec![Scope::Known(KnownScope::Atproto)], + happyview::auth::oauth_store::DbStateStore::new(pool.clone(), backend), + pool.clone(), + backend, + ) + .expect("Failed to create test linked-repo OAuth client"), + ), cookie_key: axum_extra::extract::cookie::Key::derive_from( b"test-secret-that-is-at-least-32-bytes-long", ), diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 28f5124..14a804f 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -35,6 +35,8 @@ export default defineConfig({ "proxy-config.spec.ts", "spaces.spec.ts", "jobs.spec.ts", + "linked-repos.spec.ts", + "link-invite-pages.spec.ts", ], dependencies: ["setup"], use: { browserName: "chromium" }, @@ -45,6 +47,15 @@ export default defineConfig({ dependencies: ["post-setup"], use: { browserName: "chromium", ignoreHTTPSErrors: true }, }, + { + // Drives a real OAuth authorization against the local PDS, which Caddy + // serves over HTTPS with a local CA — hence ignoreHTTPSErrors, same as + // the attach-account project. + name: "linked-repos-oauth", + testMatch: "linked-repos-oauth.spec.ts", + dependencies: ["post-setup"], + use: { browserName: "chromium", ignoreHTTPSErrors: true }, + }, { name: "didplc-setup", testMatch: "setup-didplc.spec.ts", diff --git a/web/src/app/dashboard/settings/linked-repos/page.tsx b/web/src/app/dashboard/settings/linked-repos/page.tsx new file mode 100644 index 0000000..2ce4b7c --- /dev/null +++ b/web/src/app/dashboard/settings/linked-repos/page.tsx @@ -0,0 +1,280 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +import { useCurrentUser } from "@/hooks/use-current-user"; +import { toastError } from "@/lib/format"; +import { deleteLinkedRepo, getLinkedRepoInvites, getLinkedRepos } from "@/lib/api"; +import type { LinkedRepo } from "@/types/linked-repos"; +import { + LinkedRepoDetail, + relativeTime, + statusBadge, +} from "@/components/linked-repos/linked-repo-detail"; +import { LinkRepoDialog } from "@/components/linked-repos/link-repo-dialog"; +import { SiteHeader } from "@/components/site-header"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +export default function LinkedReposPage() { + const { hasPermission } = useCurrentUser(); + const [repos, setRepos] = useState([]); + const [loading, setLoading] = useState(true); + const [dialogOpen, setDialogOpen] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [pendingDelete, setPendingDelete] = useState(null); + const [inviteCounts, setInviteCounts] = useState>({}); + + const load = useCallback(() => { + getLinkedRepos() + .then((resp) => { + setRepos(resp.linked_repos); + setLoading(false); + }) + .catch((e) => { + toastError("Failed to load linked repos", e); + setLoading(false); + }); + }, []); + + useEffect(() => { + load(); + }, [load]); + + const canCreate = hasPermission("linked-repos:create"); + const canDelete = hasPermission("linked-repos:delete"); + const canViewInvites = hasPermission("linked-repos:read"); + const columnCount = canViewInvites ? 6 : 5; + + // Counts only. The table says how many links are live; managing them belongs + // to the detail sheet, so nothing in this column is interactive. + useEffect(() => { + if (!canViewInvites || repos.length === 0) return; + let cancelled = false; + Promise.all( + repos.map((repo) => + getLinkedRepoInvites(repo.id) + .then((resp) => [repo.id, resp.invites.length] as const) + .catch(() => [repo.id, 0] as const), + ), + ).then((entries) => { + if (!cancelled) setInviteCounts(Object.fromEntries(entries)); + }); + return () => { + cancelled = true; + }; + }, [repos, canViewInvites]); + + const selected = repos.find((r) => r.id === selectedId) ?? null; + + async function confirmDelete() { + if (!pendingDelete) return; + try { + await deleteLinkedRepo(pendingDelete.id); + toast.success("Linked repo removed"); + setPendingDelete(null); + setSelectedId(null); + load(); + } catch (e) { + toastError("Failed to remove linked repo", e); + } + } + + function inviteSummary(repo: LinkedRepo): string { + const n = inviteCounts[repo.id] ?? 0; + if (n === 0) return "—"; + return n === 1 ? "1 link outstanding" : `${n} links outstanding`; + } + + return ( + <> + +
+
+

Linked Repos

+ {canCreate && ( + + )} +
+ +
+ + + + Repo + Reason + Scopes + Status + {canViewInvites && Invite links} + Last refreshed + + + + {loading && repos.length === 0 && ( + + + + + + )} + {!loading && repos.length === 0 && ( + + + No linked repos yet. Linking a repo lets this AppView write + to it from scripts, long after whoever authorized it has + gone. + + + )} + {repos.map((repo) => ( + setSelectedId(repo.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setSelectedId(repo.id); + } + }} + > + + {repo.handle ?? repo.did ?? "—"} + + + {repo.reason ?? "—"} + + +
+ {repo.scopes + .split(/\s+/) + .filter(Boolean) + .map((scope) => ( + + {scope} + + ))} +
+
+ {statusBadge(repo)} + {canViewInvites && ( + + {inviteSummary(repo)} + + )} + + {repo.last_refreshed_at + ? relativeTime(repo.last_refreshed_at) + : "—"} + +
+ ))} +
+
+
+
+ + { + if (!open) setSelectedId(null); + }} + > + + {selected && ( + <> + + + {selected.handle ?? selected.did ?? "Unauthorized grant"} + + + {selected.handle + ? "Linked repo" + : selected.did + ? "Linked repo, pinned by DID" + : "Open grant — the repo is decided by whoever completes an invite link"} + + + setPendingDelete(selected)} + onChanged={load} + /> + + )} + + + + + + !open && setPendingDelete(null)} + > + + + Remove this linked repo? + + This deletes the stored session as well. Any script writing to{" "} + {pendingDelete?.handle ?? pendingDelete?.did ?? "this repo"} will + start failing. Scopes are immutable, so restoring access means + linking it again from scratch. + + + + Cancel + + Remove + + + + + + ); +} diff --git a/web/src/app/link/result/page.tsx b/web/src/app/link/result/page.tsx new file mode 100644 index 0000000..66f7ed0 --- /dev/null +++ b/web/src/app/link/result/page.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { Suspense } from "react"; +import { useSearchParams } from "next/navigation"; +import { AlertTriangle, Ban, CheckCircle2, Clock, XCircle } from "lucide-react"; + +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; + +type ResultStatus = + | "success" + | "expired" + | "mismatch" + | "already_linked" + | "failed" + | "gone"; + +interface ResultCopy { + icon: React.ComponentType<{ className?: string }>; + iconClassName: string; + title: string; + description: (handle: string | null) => React.ReactNode; +} + +const RESULT_COPY: Record = { + success: { + icon: CheckCircle2, + iconClassName: "text-green-600 dark:text-green-500", + title: "Repo linked", + description: (handle) => ( + <> + {handle ? ( + <> + {handle} is now linked. + + ) : ( + "Your repo is now linked." + )}{" "} + You can review or revoke this access at any time from your own + account or PDS settings. + + ), + }, + expired: { + icon: Clock, + iconClassName: "text-yellow-600 dark:text-yellow-500", + title: "This link expired", + description: () => + "The authorization took too long to complete, or it was already finished elsewhere. Ask whoever sent you the link for a fresh one.", + }, + mismatch: { + icon: AlertTriangle, + iconClassName: "text-yellow-600 dark:text-yellow-500", + title: "Wrong account authorized", + description: () => + "You approved this from a different account than the one this link was issued for. Make sure you're signed into the correct account, then open the link again to retry.", + }, + already_linked: { + icon: CheckCircle2, + iconClassName: "text-muted-foreground", + title: "Already linked", + description: () => + "This repo is already linked. There's nothing more you need to do.", + }, + failed: { + icon: XCircle, + iconClassName: "text-destructive", + title: "Something went wrong", + description: () => + "We couldn't complete the connection. Try opening the link again, and if it keeps failing, contact whoever sent it to you.", + }, + gone: { + icon: Ban, + iconClassName: "text-muted-foreground", + title: "Request withdrawn", + description: () => + "Whoever sent this link canceled the request before you finished. No action is needed on your end — reach out to them if you still need to grant access.", + }, +}; + +function isResultStatus(value: string | null): value is ResultStatus { + return value !== null && value in RESULT_COPY; +} + +function LinkResultInner() { + const searchParams = useSearchParams(); + const statusParam = searchParams.get("status"); + const handle = searchParams.get("handle"); + + const status: ResultStatus = isResultStatus(statusParam) ? statusParam : "failed"; + const copy = RESULT_COPY[status]; + const Icon = copy.icon; + + return ( + + + + + + {/* `EmptyTitle` renders a plain div. On a standalone page a stranger + lands on cold, this is the page's title and should be exposed as a + heading to assistive tech. */} + + {copy.title} + + {copy.description(handle)} + + {status === "mismatch" && ( + +

+ No action was taken on the account you meant to link — it's safe + to try again. +

+
+ )} +
+ ); +} + +export default function LinkResultPage() { + return ( +
+
+ + + +
+
+ ); +} diff --git a/web/src/app/link/start/page.tsx b/web/src/app/link/start/page.tsx new file mode 100644 index 0000000..01e58ec --- /dev/null +++ b/web/src/app/link/start/page.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import Image from "next/image"; +import { useSearchParams } from "next/navigation"; +import { AlertTriangle, Loader2 } from "lucide-react"; + +import { getLinkedRepoInvite, type LinkedRepoInviteInfo } from "@/lib/api"; +import { describeScope } from "@/lib/linked-repo-scopes"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; + +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || ""; + +function LinkStartInner() { + const searchParams = useSearchParams(); + const token = searchParams.get("token") ?? ""; + + const [info, setInfo] = useState(null); + // Distinguishes "the server told us this token is dead" from "we couldn't + // even reach the server" — the former has no useful retry, the latter + // might just be a network blip. + const [fetchFailed, setFetchFailed] = useState(false); + const [handle, setHandle] = useState(""); + + const [retryCount, setRetryCount] = useState(0); + + useEffect(() => { + if (!token) return; + let cancelled = false; + // Deliberately no synchronous setState reset here — React's compiler flags + // that as a cascading render. The retry button clears `fetchFailed` itself + // before bumping `retryCount`, which is the only way this effect re-runs. + getLinkedRepoInvite(token) + .then((data) => { + if (!cancelled) setInfo(data); + }) + .catch(() => { + if (!cancelled) setFetchFailed(true); + }); + return () => { + cancelled = true; + }; + }, [token, retryCount]); + + function handleContinue() { + if (!info) return; + const params = new URLSearchParams({ token }); + const chosenHandle = info.pinned_identifier ?? handle.trim(); + if (chosenHandle) params.set("handle", chosenHandle); + window.location.assign( + `${BASE_PATH}/auth/linked-repo/start?${params.toString()}`, + ); + } + + // No token at all — this link can never resolve to anything, so treat it + // the same as a token the server has already rejected. + if (!token || (info && !info.valid)) { + return ( + + + + + + + This link is no longer usable + + + It may have expired, already been used, or been revoked. Ask whoever + sent it to you for a new one — retrying this one won't help. + + + + ); + } + + if (fetchFailed) { + return ( + + + + + + + Couldn't load this link + + + Something went wrong reaching the server. Check your connection and + try again. + + + + + ); + } + + if (!info) { + return ( + + +
+ +
+ + +
+
+
+ + + + +
+ ); + } + + const descriptions = info.scopes.map(describeScope); + const capabilities = descriptions.filter((d) => !d.quiet); + const hasBasicAccess = descriptions.some((d) => d.quiet); + const canContinue = + Boolean(info.pinned_identifier) || handle.trim().length > 0; + + return ( + + +
+ {info.logo_url && ( + + )} +
+ {info.app_name} + + is asking for access to your ATProto repo + +
+
+
+ + {info.reason && ( +
+

Reason

+ {/* Stacked rather than inline: this is the admin's own prose and can + run to several lines, so it needs room to breathe. `whitespace- + pre-line` keeps any line breaks they typed. */} +

+ {info.reason} +

+
+ )} + +
+

+ What this will let {info.app_name} do +

+ {capabilities.length === 0 ? ( +

+ No specific record or file access was requested. +

+ ) : ( +
    + {capabilities.map((cap) => ( +
  • + {cap.text} + + {cap.raw} + +
  • + ))} +
+ )} + {hasBasicAccess && ( +

+ Basic account access is also included — that only identifies which + account is linked, it isn't a separate permission. +

+ )} +
+ +
+ {info.pinned_identifier ? ( + <> + +

+ {info.pinned_identifier} +

+

+ This link is only valid for this account. +

+ + ) : ( + <> + + setHandle(e.target.value)} + placeholder="you.bsky.social" + autoComplete="off" + /> + + )} +
+ + {info.expires_at && ( +

+ This link expires {new Date(info.expires_at).toLocaleString()}. +

+ )} + +

+ Continuing takes you to your own PDS to approve this. {info.app_name}{" "} + never sees your password. +

+
+ + + +
+ ); +} + +function LinkStartFallback() { + return ( + + + + Loading… + + + ); +} + +export default function LinkStartPage() { + return ( +
+
+ }> + + +
+
+ ); +} diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx index 1151d8a..63c66b3 100644 --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -23,6 +23,7 @@ import { IconFlask, IconFingerprint, IconPlayerPlay, + IconLink, } from "@tabler/icons-react"; import Image from "next/image"; import Link from "next/link"; @@ -135,6 +136,12 @@ const systemItems: NavItem[] = [ icon: IconArrowsShuffle, requiredPermissions: ["settings:manage"], }, + { + title: "Linked Repos", + url: "/dashboard/settings/linked-repos", + icon: IconLink, + requiredPermissions: ["linked-repos:read"], + }, { title: "ENV Variables", url: "/dashboard/settings/env-variables", diff --git a/web/src/components/linked-repos/link-repo-dialog.tsx b/web/src/components/linked-repos/link-repo-dialog.tsx new file mode 100644 index 0000000..bd54146 --- /dev/null +++ b/web/src/components/linked-repos/link-repo-dialog.tsx @@ -0,0 +1,295 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { Check, Copy, Loader2 } from "lucide-react"; + +import { + authorizeLinkedRepo, + createLinkedRepo, + inviteLinkedRepo, +} from "@/lib/api"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + ScopeBuilder, + grantsNoAccess, + validateScopeString, +} from "@/components/linked-repos/scope-builder"; + +interface LinkRepoDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreated: () => void; +} + +type SubmitAction = "authorize" | "invite"; + +interface InviteResult { + invite_url: string; + expires_at: string; +} + +// Replaces the Task 10 placeholder with the real create flow: handle/reason +// fields, the ScopeBuilder, and two submit paths (immediate OAuth vs. a +// single-use invite link). +export function LinkRepoDialog({ + open, + onOpenChange, + onCreated, +}: LinkRepoDialogProps) { + const [formKey, setFormKey] = useState(0); + const [handle, setHandle] = useState(""); + const [reason, setReason] = useState(""); + const [scopes, setScopes] = useState(""); + const [repoId, setRepoId] = useState(null); + const [inviteResult, setInviteResult] = useState(null); + const [submitting, setSubmitting] = useState(null); + const [error, setError] = useState(null); + const [lastAction, setLastAction] = useState(null); + const [copied, setCopied] = useState(false); + + const handleTrimmed = handle.trim(); + const scopeError = validateScopeString(scopes); + // The builder always emits `atproto`, so a non-empty scope string no longer + // means the operator picked anything — keep a grant that can do nothing from + // being created by merely opening this dialog. Once the grant exists its + // scopes are immutable, so this only gates creation, never a retry. + const noAccess = repoId === null && grantsNoAccess(scopes); + const canSubmit = submitting === null && scopeError === null && !noAccess; + + function handleOpenChange(nextOpen: boolean) { + onOpenChange(nextOpen); + if (!nextOpen) { + // Force a fresh ScopeBuilder (and form state) next time the dialog + // opens, rather than trying to thread a reset through its props. + setFormKey((k) => k + 1); + setHandle(""); + setReason(""); + setScopes(""); + setRepoId(null); + setInviteResult(null); + setSubmitting(null); + setError(null); + setLastAction(null); + setCopied(false); + } + } + + // Creation only ever happens once per dialog session — repoId is cached so + // a failed authorize/invite call (e.g. the auth server hasn't picked up + // freshly-created client metadata yet) can be retried without creating a + // second grant. + async function submit(action: SubmitAction) { + setSubmitting(action); + setError(null); + setLastAction(action); + try { + let id = repoId; + if (id === null) { + const created = await createLinkedRepo({ + handle: handleTrimmed || undefined, + reason: reason.trim() || undefined, + scopes, + }); + id = created.id; + setRepoId(id); + onCreated(); + } + if (action === "authorize") { + const { authorize_url } = await authorizeLinkedRepo(id); + window.location.assign(authorize_url); + return; + } + const result = await inviteLinkedRepo(id); + setInviteResult(result); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSubmitting(null); + } + } + + async function copyInvite() { + if (!inviteResult) return; + await navigator.clipboard.writeText(inviteResult.invite_url); + setCopied(true); + toast.success("Invite link copied. It works once and then expires."); + setTimeout(() => setCopied(false), 2000); + } + + return ( + + + + + {inviteResult ? "Invite link created" : "Link a repo"} + + + {inviteResult + ? "Share this with whoever should authorize it. It works once and then expires." + : "Grant this AppView durable OAuth access to a repo, scoped to exactly what it needs."} + + + +
+ + {inviteResult ? ( +
+ +
+ + +
+

+ Expires {new Date(inviteResult.expires_at).toLocaleString()}. +

+
+ ) : ( +
+ {repoId !== null ? ( +
+

+ This grant was already created + {handleTrimmed ? ` for ${handleTrimmed}` : ""}. Scopes are + immutable once created, so retry the action below rather + than editing them here. +

+

+ {scopes} +

+
+ ) : ( + <> +
+ + setHandle(e.target.value)} + placeholder="alice.bsky.social or did:plc:..." + /> +

+ Leave this blank to create an open link whose repo is + decided by whoever completes it. Fill it in to pin the + grant to this account, so only they can complete it. +

+
+ +
+ +