1//! HTTP surface of the control plane.23use std::collections::{HashMap, HashSet};4use std::convert::Infallible;5use std::fmt::Write as _;6use std::hash::Hash;7use std::str::FromStr;8use std::sync::atomic::{AtomicU64, Ordering};9use std::sync::{Arc, Mutex};10use std::time::{Duration, SystemTime, UNIX_EPOCH};1112use axum::extract::{Path, Query, State};13use axum::http::{header, HeaderMap, StatusCode};14use axum::response::sse::{Event, KeepAlive, Sse};15use axum::response::{Html, IntoResponse, Response};16use axum::routing::{delete, get, post, put};17use axum::{Json, Router};18use devsite_proto::access_plan::ServiceGrantPlanClaims;19use devsite_proto::capability::KeyBytes;20use devsite_proto::{AccountId, ResourceId};21use futures_util::stream::{self, StreamExt};22use serde::{Deserialize, Serialize};23use tokio::sync::broadcast;24use tokio_stream::wrappers::BroadcastStream;2526use crate::auth::{self, ExternalIdentity};27use crate::db::{Db, MachineAuthentication, ResourceKind, ShareStatus};28use crate::issuer::Issuer;29use crate::policy::{can_view, Visibility};30use crate::theme;3132pub struct AppState {33 pub db: Mutex<Db>,34 pub rate_limits: Mutex<RateLimits>,35 pub issuer: Issuer,36 pub identity_namespace: String,37 pub public_origin: String,38 pub relay_config: devsite_iroh::RelayConfig,39 pub profile_changes: broadcast::Sender<ProfileChange>,40 pub profile_revision: AtomicU64,41}424344pub struct ProfileChange {45 revision: u64,46 accounts: Arc<HashSet<AccountId>>,47}4849impl AppState {50 fn notify_profiles(&self, accounts: impl IntoIterator<Item = AccountId>) {51 let accounts = accounts.into_iter().collect::<HashSet<_>>();52 if accounts.is_empty() {53 return;54 }55 let revision = self.profile_revision.fetch_add(1, Ordering::Relaxed) + 1;56 let _ = self.profile_changes.send(ProfileChange {57 revision,58 accounts: Arc::new(accounts),59 });60 }61}626364pub enum RateClass {65 Session,66 Mutation,67 Credential,68 Capability,69}7071impl RateClass {72 fn limit(self) -> u32 {73 match self {74 Self::Session => 12,75 Self::Mutation => 120,76 Self::Credential => 20,77 Self::Capability => 300,78 }79 }80}818283pub struct RateLimits {84 windows: HashMap<(AccountId, RateClass), RateWindow>,85}8687struct RateWindow {88 started_at: u64,89 requests: u32,90}9192impl RateLimits {93 fn check(&mut self, account: AccountId, class: RateClass, now: u64) -> bool {94 const WINDOW_SECS: u64 = 60;95 // One small entry per active account/class. Expired entries from users96 // who never return are discarded opportunistically.97 if self.windows.len() > 4096 {98 self.windows99 .retain(|_, window| now.saturating_sub(window.started_at) < WINDOW_SECS);100 }101 let window = self.windows.entry((account, class)).or_insert(RateWindow {102 started_at: now,103 requests: 0,104 });105 if now.saturating_sub(window.started_at) >= WINDOW_SECS {106 window.started_at = now;107 window.requests = 0;108 }109 if window.requests >= class.limit() {110 return false;111 }112 window.requests += 1;113 true114 }115}116117pub type Shared = Arc<AppState>;118119pub fn now_secs() -> u64 {120 SystemTime::now()121 .duration_since(UNIX_EPOCH)122 .map(|d| d.as_secs())123 .unwrap_or(0)124}125126// -- errors -------------------------------------------------------------------127128129pub struct ApiError(StatusCode, String);130131impl ApiError {132 fn bad_request(message: impl Into<String>) -> Self {133 Self(StatusCode::BAD_REQUEST, message.into())134 }135 fn unauthorized() -> Self {136 Self(StatusCode::UNAUTHORIZED, "sign in first".into())137 }138 fn forbidden(message: impl Into<String>) -> Self {139 Self(StatusCode::FORBIDDEN, message.into())140 }141 fn too_many_requests() -> Self {142 Self(143 StatusCode::TOO_MANY_REQUESTS,144 "too many requests — try again shortly".into(),145 )146 }147 /// Used wherever revealing existence would leak information.148 fn not_found() -> Self {149 Self(StatusCode::NOT_FOUND, "not found".into())150 }151 fn internal(err: anyhow::Error) -> Self {152 tracing::error!("internal error: {err:#}");153 Self(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())154 }155}156157impl IntoResponse for ApiError {158 fn into_response(self) -> Response {159 (self.0, Json(serde_json::json!({ "error": self.1 }))).into_response()160 }161}162163type ApiResult<T> = Result<T, ApiError>;164165// -- session extraction --------------------------------------------------------166167/// Resolve the caller's account from the session cookie or bearer token.168///169/// Returns `None` for anonymous callers rather than erroring, because public profiles are170/// readable without signing in.171fn current_account(state: &AppState, headers: &HeaderMap) -> ApiResult<Option<AccountId>> {172 let now = now_secs();173 let db = state.db.lock().unwrap();174 if let Some(token) = bearer_token(headers) {175 let hash = auth::hash_token(&token);176 // Browser sessions remain accepted as bearer tokens for local operator177 // workflows, but installed CLIs use independently revocable credentials.178 let account = db179 .machine_authentication(&hash, now)180 .map_err(ApiError::internal)?181 .map(|machine| machine.account)182 .or(db.session_account(&hash, now).map_err(ApiError::internal)?);183 return Ok(account.map(|a| a.id));184 }185 let Some(token) = cookie_token(headers) else {186 return Ok(None);187 };188 let account = db189 .session_account(&auth::hash_token(&token), now)190 .map_err(ApiError::internal)?;191 Ok(account.map(|a| a.id))192}193194fn require_account(state: &AppState, headers: &HeaderMap) -> ApiResult<AccountId> {195 current_account(state, headers)?.ok_or_else(ApiError::unauthorized)196}197198/// Dashboard-only operations require the browser's HttpOnly session cookie. A199/// stolen machine credential can manage the profile, but cannot mint siblings200/// or revoke the owner's remaining ways back in.201fn require_browser_account(state: &AppState, headers: &HeaderMap) -> ApiResult<AccountId> {202 let token = cookie_token(headers).ok_or_else(ApiError::unauthorized)?;203 let db = state.db.lock().unwrap();204 db.session_account(&auth::hash_token(&token), now_secs())205 .map_err(ApiError::internal)?206 .map(|account| account.id)207 .ok_or_else(ApiError::unauthorized)208}209210fn require_machine(state: &AppState, headers: &HeaderMap) -> ApiResult<MachineAuthentication> {211 let token = bearer_token(headers).ok_or_else(ApiError::unauthorized)?;212 state213 .db214 .lock()215 .unwrap()216 .machine_authentication(&auth::hash_token(&token), now_secs())217 .map_err(ApiError::internal)?218 .ok_or_else(ApiError::unauthorized)219}220221fn check_rate(state: &AppState, account: AccountId, class: RateClass) -> ApiResult<()> {222 if state223 .rate_limits224 .lock()225 .unwrap()226 .check(account, class, now_secs())227 {228 Ok(())229 } else {230 Err(ApiError::too_many_requests())231 }232}233234fn bearer_token(headers: &HeaderMap) -> Option<String> {235 headers236 .get(header::AUTHORIZATION)?237 .to_str()238 .ok()?239 .strip_prefix("Bearer ")240 .map(str::to_string)241}242243fn cookie_token(headers: &HeaderMap) -> Option<String> {244 headers245 .get(header::COOKIE)?246 .to_str()247 .ok()?248 .split(';')249 .filter_map(|part| part.trim().split_once('='))250 .find(|(name, _)| *name == "devsite_session")251 .map(|(_, value)| value.to_string())252}253254// -- payloads ------------------------------------------------------------------255256257pub struct ClaimHandleRequest {258 pub handle: String,259}260261262pub struct CreateResourceRequest {263 pub name: String,264 pub kind: String,265 pub visibility: String,266 267 pub url: Option<String>,268 /// Handles to share with. Only meaningful when `visibility` is `shared`.269 270 pub share_with: Vec<String>,271 /// The folder to file it under. Absent means loose on the profile — and272 /// absent on a re-add means "take it out of the folder it was in", because a273 /// command names the whole state of the thing it names.274 275 pub folder: Option<String>,276}277278279pub struct CreateResourceResponse {280 pub resource_id: String,281 pub plan: ResourcePlan,282}283284285pub struct ResourcePlanResponse {286 pub plan: ResourcePlan,287}288289290pub struct ResourcePlan {291 pub operation: &'static str,292 pub target: ResourcePlanTarget,293 pub changes: Vec<ResourcePlanChange>,294 pub recipient_changes: Vec<RecipientPlanChange>,295 pub effects: Vec<ResourcePlanEffect>,296}297298299pub struct ResourcePlanTarget {300 pub resource_id: Option<String>,301 pub kind: &'static str,302 pub name: String,303}304305306pub struct ResourcePlanChange {307 pub field: &'static str,308 pub from: serde_json::Value,309 pub to: serde_json::Value,310}311312313pub struct RecipientPlanChange {314 pub handle: String,315 pub from: Option<&'static str>,316 pub to: Option<&'static str>,317 pub reason: &'static str,318}319320321pub struct ResourcePlanEffect {322 pub code: &'static str,323 pub handles: Vec<String>,324}325326327pub struct RegisterDaemonRequest {328 pub proof: String,329}330331332pub struct EnrollMachineRequest {333 pub endpoint_id: String,334 pub proof: String,335}336337338pub struct SetSharesRequest {339 340 pub share_with: Vec<String>,341}342343344pub struct ProfileSettingsRequest {345 pub private_only: bool,346}347348349pub struct CreateMachineCredentialRequest {350 pub name: String,351 352 pub scopes: Vec<String>,353}354355356pub struct ServiceGrantRequestEnvelope {357 pub schema_version: u32,358 pub request_id: String,359 pub service: String,360 pub requester_endpoint_id: String,361 pub expires_at: u64,362 pub proof: String,363}364365366pub struct ServiceGrantIssueRequest {367 pub request: ServiceGrantRequestEnvelope,368 pub resource_id: String,369 pub expires_at: u64,370 pub broker_proof: String,371 372 pub approved_plan: Option<String>,373}374375376pub struct ServiceSearchQuery {377 pub keyword: String,378}379380381pub struct ProfileEntry {382 pub resource_id: String,383 pub name: String,384 pub kind: &'static str,385 pub visibility: &'static str,386 pub url: Option<String>,387 pub folder: Option<String>,388 /// Present only on "shared with me" entries.389 390 pub owner_handle: Option<String>,391}392393394pub struct ProfileResponse {395 pub handle: String,396 pub entries: Vec<ProfileEntry>,397 pub shared_with_me: Vec<ProfileEntry>,398 /// The owner's presentation: validated `--pico-*` theme assignments and399 /// `--devsite-*` layout settings, in the order they were written. The page400 /// turns only the Pico declarations into a scoped rule.401 pub theme: Vec<theme::Declaration>,402}403404405pub struct ThemeRequest {406 /// Declarations only — `--pico-border-radius: 0.5rem;`,407 /// `--devsite-folders: closed;`, and so on. Anything outside the whitelist408 /// is a 400 with a reason, never a silent drop.409 pub css: String,410}411412413pub struct ThemeResponse {414 /// The theme as stored, after normalisation.415 pub css: String,416 pub theme: Vec<theme::Declaration>,417}418419420pub struct ConnectionTicketResponse {421 pub ticket: String,422 pub expires_at: u64,423}424425426pub struct RedeemTicketRequest {427 pub ticket: String,428 pub client_endpoint_id: String,429}430431432pub struct RedeemTicketResponse {433 pub session_token: String,434 435 pub relay_access: devsite_iroh::RelayAccess,436 pub resource_id: String,437 pub name: String,438 pub expires_at: u64,439}440441442pub struct CapabilityResponse {443 /// base64url of the postcard-encoded signed capability.444 pub capability: String,445 /// Who to present it to. Not where they are — the client resolves that446 /// through iroh's address lookup, from the daemon's own published record.447 pub daemon_endpoint_id: String,448}449450// -- routes --------------------------------------------------------------------451452pub fn router(state: Shared) -> Router {453 Router::new()454 .route("/api/config", get(config))455 .route("/api/pubkey", get(pubkey))456 .route("/api/auth/session", delete(sign_out))457 .route("/api/me", get(me))458 .route("/api/profile", post(claim_handle))459 .route("/api/resources", post(create_resource).get(list_resources))460 .route("/api/resources/plan", post(plan_resource))461 .route("/api/resources/{id}", delete(delete_resource))462 .route("/api/resources/{id}/shares", put(set_resource_shares))463 .route("/api/share-invitations", get(list_share_invitations))464 .route(465 "/api/share-invitations/{id}/accept",466 post(accept_share_invitation),467 )468 .route(469 "/api/share-invitations/{id}",470 delete(decline_share_invitation),471 )472 .route(473 "/ui/share-invitations/{id}/accept",474 post(accept_share_invitation_ui),475 )476 .route(477 "/ui/share-invitations/{id}",478 delete(decline_share_invitation_ui),479 )480 .route("/api/daemon", get(daemon_info).put(register_daemon))481 .route("/api/daemon/authorizations", get(daemon_authorizations))482 .route("/api/machine/enroll", put(enroll_machine))483 .route(484 "/api/profile/settings",485 get(profile_settings).put(set_profile_settings),486 )487 .route(488 "/api/machine-credentials",489 get(list_machine_credentials).post(create_machine_credential),490 )491 .route(492 "/api/machine-credentials/{id}",493 delete(revoke_machine_credential),494 )495 .route("/api/theme", get(read_theme).put(write_theme))496 .route("/api/theme/properties", get(theme_properties))497 .route("/api/profile/{handle}", get(profile))498 .route("/ui/profile/{handle}/stream", get(profile_stream))499 .route("/api/services/{id}/ticket", post(create_connection_ticket))500 .route("/api/tickets/redeem", post(redeem_connection_ticket))501 .route(502 "/api/tunnel/session",503 get(tunnel_session_info).delete(delete_tunnel_session),504 )505 .route("/api/tunnel/capability", post(tunnel_capability))506 .route("/api/access/services", get(accessible_services))507 .route("/api/access/grants/plan", post(plan_service_grant))508 .route("/api/access/grants", post(issue_service_grant))509 .with_state(state)510}511512/// Public configuration the browser needs to start a sign-in.513async fn config(State(state): State<Shared>) -> impl IntoResponse {514 Json(serde_json::json!({515 // `issuer` stays as a compatibility alias for older clients that displayed it.516 "issuer": state.identity_namespace,517 "auth": {518 "namespace": state.identity_namespace,519 "start_url": "/auth/start",520 },521 "public_origin": state.public_origin,522 "redirect_uri": format!("{}/auth/callback", state.public_origin),523 "api_version": 3,524 "minimum_cli_version": "0.3.0",525 "server_version": env!("CARGO_PKG_VERSION"),526 "daemon_protocol": String::from_utf8_lossy(devsite_proto::ALPN),527 }))528}529530/// The capability-signing public key, pinned by daemons at login.531async fn pubkey(State(state): State<Shared>) -> impl IntoResponse {532 Json(serde_json::json!({ "public_key": state.issuer.public_key_hex() }))533}534535pub(crate) struct EstablishedBrowserSession {536 pub token: String,537 pub handle: Option<String>,538}539540/// Application port shared by every login adapter: a verified external identity enters;541/// an opaque, provider-independent browser session comes out.542pub(crate) fn establish_browser_session(543 state: &AppState,544 identity: ExternalIdentity,545) -> ApiResult<EstablishedBrowserSession> {546 let now = now_secs();547 let token = auth::generate_session_token();548 let (account_id, handle) = {549 let db = state.db.lock().unwrap();550 let account = db551 .upsert_account(&identity.namespace, &identity.subject, now)552 .map_err(ApiError::internal)?;553 (account.id, account.handle)554 };555 check_rate(state, account_id, RateClass::Session)?;556 {557 let db = state.db.lock().unwrap();558 db.create_session(559 &auth::hash_token(&token),560 account_id,561 now + auth::SESSION_LIFETIME_SECS,562 )563 .map_err(ApiError::internal)?;564 db.purge_expired_sessions(now).ok();565 }566567 Ok(EstablishedBrowserSession { token, handle })568}569570pub(crate) fn browser_session_cookie(state: &AppState, token: &str) -> String {571 // HttpOnly so page scripts cannot read it; SameSite=Lax so it survives an identity572 // provider redirect without being sent on cross-site POSTs.573 format!(574 "devsite_session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}{}",575 auth::SESSION_LIFETIME_SECS,576 if state.public_origin.starts_with("https://") {577 "; Secure"578 } else {579 ""580 }581 )582}583584async fn sign_out(State(state): State<Shared>, headers: HeaderMap) -> ApiResult<Response> {585 if let Some(token) = cookie_token(&headers) {586 state587 .db588 .lock()589 .unwrap()590 .delete_session(&auth::hash_token(&token))591 .map_err(ApiError::internal)?;592 }593594 let cookie = format!(595 "devsite_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{}",596 if state.public_origin.starts_with("https://") {597 "; Secure"598 } else {599 ""600 }601 );602 Ok((StatusCode::NO_CONTENT, [(header::SET_COOKIE, cookie)]).into_response())603}604605async fn me(State(state): State<Shared>, headers: HeaderMap) -> ApiResult<Response> {606 let Some(id) = current_account(&state, &headers)? else {607 return Ok(StatusCode::NO_CONTENT.into_response());608 };609 let db = state.db.lock().unwrap();610 let account = db611 .account_by_id(id)612 .map_err(ApiError::internal)?613 .ok_or_else(ApiError::unauthorized)?;614 Ok(Json(serde_json::json!({615 "account_id": account.id.to_string(),616 "handle": account.handle,617 }))618 .into_response())619}620621async fn claim_handle(622 State(state): State<Shared>,623 headers: HeaderMap,624 Json(body): Json<ClaimHandleRequest>,625) -> ApiResult<impl IntoResponse> {626 let id = require_account(&state, &headers)?;627 check_rate(&state, id, RateClass::Mutation)?;628 let handle =629 auth::validate_handle(&body.handle).map_err(|e| ApiError::bad_request(e.to_string()))?;630631 let db = state.db.lock().unwrap();632 if let Some(existing) = db.account_by_handle(&handle).map_err(ApiError::internal)? {633 if existing.id != id {634 return Err(ApiError::bad_request("that handle is taken"));635 }636 }637 db.set_handle(id, &handle).map_err(ApiError::internal)?;638 drop(db);639 state.notify_profiles([id]);640 Ok(Json(serde_json::json!({ "handle": handle })))641}642643async fn create_resource(644 State(state): State<Shared>,645 headers: HeaderMap,646 Json(body): Json<CreateResourceRequest>,647) -> ApiResult<impl IntoResponse> {648 let owner = require_account(&state, &headers)?;649 check_rate(&state, owner, RateClass::Mutation)?;650 let mut db = state.db.lock().unwrap();651 let mut prepared = prepare_resource(&db, owner, &body)?;652653 let resource = db654 .create_resource(655 owner,656 &prepared.name,657 prepared.kind,658 prepared.visibility,659 prepared.url.as_deref(),660 prepared.folder.as_deref(),661 now_secs(),662 )663 .map_err(ApiError::internal)?;664665 // The share list this request names is the whole share list afterwards.666 // Re-running `service host --share @carol` means Carol is the invited recipient,667 // not Carol plus whoever was named the last time it ran.668 db.set_shares(resource, &prepared.viewers)669 .map_err(ApiError::internal)?;670 let viewers = db.shared_with(resource).map_err(ApiError::internal)?;671 prepared.plan.target.resource_id = Some(resource.to_string());672 drop(db);673 state.notify_profiles(std::iter::once(owner).chain(viewers));674675 Ok(Json(CreateResourceResponse {676 resource_id: resource.to_string(),677 plan: prepared.plan,678 }))679}680681async fn plan_resource(682 State(state): State<Shared>,683 headers: HeaderMap,684 Json(body): Json<CreateResourceRequest>,685) -> ApiResult<impl IntoResponse> {686 let owner = require_account(&state, &headers)?;687 // Planning resolves account handles and therefore uses the same bounded688 // rate class as applying a mutation.689 check_rate(&state, owner, RateClass::Mutation)?;690 let db = state.db.lock().unwrap();691 let prepared = prepare_resource(&db, owner, &body)?;692 Ok(Json(ResourcePlanResponse {693 plan: prepared.plan,694 }))695}696697struct PreparedResource {698 name: String,699 kind: ResourceKind,700 visibility: Visibility,701 url: Option<String>,702 folder: Option<String>,703 viewers: Vec<AccountId>,704 plan: ResourcePlan,705}706707fn prepare_resource(708 db: &Db,709 owner: AccountId,710 body: &CreateResourceRequest,711) -> ApiResult<PreparedResource> {712 let name = validate_resource_name(&body.name)?.to_string();713 let kind = match body.kind.as_str() {714 "link" => ResourceKind::Link,715 "service" => ResourceKind::Service,716 other => return Err(ApiError::bad_request(format!("unknown kind `{other}`"))),717 };718 let visibility = Visibility::parse(&body.visibility)719 .ok_or_else(|| ApiError::bad_request("visibility must be public, private or shared"))?;720 validate_resource_visibility(kind, visibility)?;721 if visibility != Visibility::Shared && !body.share_with.is_empty() {722 return Err(ApiError::bad_request(723 "share_with is only valid when visibility is shared",724 ));725 }726727 // A link needs a URL; a service must not carry one. The local TCP target stays728 // in the owner's daemon config and is never uploaded.729 let url = match kind {730 ResourceKind::Link if body.url.is_none() => {731 return Err(ApiError::bad_request("a link needs a url"))732 }733 ResourceKind::Link => Some(validate_public_url(body.url.as_deref().unwrap())?),734 ResourceKind::Service if body.url.is_some() => {735 return Err(ApiError::bad_request(736 "a service's local target stays on your machine and must not be sent here",737 ))738 }739 ResourceKind::Service => None,740 };741 let folder = validate_folder(body.folder.as_deref())?;742743 let existing = db744 .resources_owned_by(owner)745 .map_err(ApiError::internal)?746 .into_iter()747 .find(|resource| resource.name == name && resource.kind == kind);748 if !db749 .resource_named(owner, &name, kind)750 .map_err(ApiError::internal)?751 && db.resource_count(owner).map_err(ApiError::internal)? >= MAX_RESOURCES752 {753 return Err(ApiError::bad_request(format!(754 "a profile may contain at most {MAX_RESOURCES} resources"755 )));756 }757758 // Resolve every share target before either planning or creating anything.759 let viewers = resolve_share_accounts(db, owner, &body.share_with)?;760 let desired_handles = viewers761 .iter()762 .map(|viewer| {763 db.account_by_id(*viewer)764 .map_err(ApiError::internal)?765 .and_then(|account| account.handle)766 .ok_or_else(|| ApiError::bad_request("a share recipient no longer has a handle"))767 })768 .collect::<ApiResult<Vec<_>>>()?;769 let plan = build_resource_plan(770 db,771 existing.as_ref(),772 &name,773 kind,774 visibility,775 url.as_deref(),776 folder.as_deref(),777 &desired_handles,778 )?;779780 Ok(PreparedResource {781 name,782 kind,783 visibility,784 url,785 folder,786 viewers,787 plan,788 })789}790791792fn build_resource_plan(793 db: &Db,794 existing: Option<&crate::db::Resource>,795 name: &str,796 kind: ResourceKind,797 visibility: Visibility,798 url: Option<&str>,799 folder: Option<&str>,800 desired_handles: &[String],801) -> ApiResult<ResourcePlan> {802 let mut changes = Vec::new();803 let current_visibility = existing.map(|resource| resource.visibility.as_str());804 let current_url = existing.and_then(|resource| resource.url.as_deref());805 let current_folder = existing.and_then(|resource| resource.folder.as_deref());806 for (field, from, to) in [807 (808 "visibility",809 serde_json::to_value(current_visibility).unwrap(),810 serde_json::to_value(Some(visibility.as_str())).unwrap(),811 ),812 (813 "url",814 serde_json::to_value(current_url).unwrap(),815 serde_json::to_value(url).unwrap(),816 ),817 (818 "folder",819 serde_json::to_value(current_folder).unwrap(),820 serde_json::to_value(folder).unwrap(),821 ),822 ] {823 if from != to {824 changes.push(ResourcePlanChange { field, from, to });825 }826 }827828 let existing_recipients = match existing {829 Some(resource) => db830 .share_recipients(resource.id)831 .map_err(ApiError::internal)?,832 None => Vec::new(),833 };834 let desired = desired_handles835 .iter()836 .map(|handle| handle.to_ascii_lowercase())837 .collect::<HashSet<_>>();838 let existing_names = existing_recipients839 .iter()840 .map(|recipient| recipient.handle.to_ascii_lowercase())841 .collect::<HashSet<_>>();842 let destination_changed =843 kind == ResourceKind::Link && existing.is_some() && current_url != url;844 let mut recipient_changes = Vec::new();845 let mut reapproval = Vec::new();846 let mut access_revoked = Vec::new();847 let mut invitations_withdrawn = Vec::new();848 let mut invited = Vec::new();849850 for recipient in &existing_recipients {851 let wanted = desired.contains(&recipient.handle.to_ascii_lowercase());852 if !wanted {853 recipient_changes.push(RecipientPlanChange {854 handle: recipient.handle.clone(),855 from: Some(recipient.status.as_str()),856 to: None,857 reason: "recipient_removed",858 });859 match recipient.status {860 ShareStatus::Accepted => access_revoked.push(recipient.handle.clone()),861 ShareStatus::Pending => invitations_withdrawn.push(recipient.handle.clone()),862 ShareStatus::Declined => {}863 }864 } else if destination_changed && recipient.status == ShareStatus::Accepted {865 recipient_changes.push(RecipientPlanChange {866 handle: recipient.handle.clone(),867 from: Some("accepted"),868 to: Some("pending"),869 reason: "destination_changed",870 });871 reapproval.push(recipient.handle.clone());872 }873 }874 for handle in desired_handles {875 if !existing_names.contains(&handle.to_ascii_lowercase()) {876 recipient_changes.push(RecipientPlanChange {877 handle: handle.clone(),878 from: None,879 to: Some("pending"),880 reason: "recipient_added",881 });882 invited.push(handle.clone());883 }884 }885886 let mut effects = Vec::new();887 for (code, handles) in [888 ("recipient_reapproval_required", reapproval),889 ("accepted_access_revoked", access_revoked),890 ("pending_invitations_withdrawn", invitations_withdrawn),891 ("recipients_invited", invited),892 ] {893 if !handles.is_empty() {894 effects.push(ResourcePlanEffect { code, handles });895 }896 }897898 let operation = if existing.is_none() {899 "create"900 } else if changes.is_empty() && recipient_changes.is_empty() {901 "noop"902 } else {903 "update"904 };905 Ok(ResourcePlan {906 operation,907 target: ResourcePlanTarget {908 resource_id: existing.map(|resource| resource.id.to_string()),909 kind: kind.as_str(),910 name: name.to_string(),911 },912 changes,913 recipient_changes,914 effects,915 })916}917918fn validate_resource_visibility(kind: ResourceKind, visibility: Visibility) -> ApiResult<()> {919 match (kind, visibility) {920 (ResourceKind::Link, Visibility::Public | Visibility::Private | Visibility::Shared)921 | (ResourceKind::Service, Visibility::Private | Visibility::Shared) => {}922 (ResourceKind::Service, Visibility::Public) => {923 return Err(ApiError::bad_request(924 "TCP services must be private or shared with specific users",925 ))926 }927 }928 Ok(())929}930931const MAX_RESOURCES: usize = 100;932const MAX_SHARES: usize = 25;933const MAX_RESOURCE_NAME: usize = 80;934const MAX_PUBLIC_URL: usize = 2048;935936fn validate_resource_name(name: &str) -> ApiResult<&str> {937 if name.trim() != name || name.is_empty() {938 return Err(ApiError::bad_request(939 "a resource name must not be blank or have leading or trailing whitespace",940 ));941 }942 if name.chars().count() > MAX_RESOURCE_NAME {943 return Err(ApiError::bad_request(format!(944 "a resource name may be at most {MAX_RESOURCE_NAME} characters"945 )));946 }947 if name.chars().any(char::is_control) {948 return Err(ApiError::bad_request(949 "a resource name may not contain control characters",950 ));951 }952 Ok(name)953}954955fn validate_public_url(value: &str) -> ApiResult<String> {956 if value.len() > MAX_PUBLIC_URL {957 return Err(ApiError::bad_request(format!(958 "a link URL may be at most {MAX_PUBLIC_URL} bytes"959 )));960 }961 let parsed = url::Url::parse(value)962 .map_err(|_| ApiError::bad_request("a link URL must be a valid absolute URL"))?;963 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {964 return Err(ApiError::bad_request(965 "a link URL must use http or https and name a host",966 ));967 }968 if !parsed.username().is_empty() || parsed.password().is_some() {969 return Err(ApiError::bad_request(970 "a link URL may not contain embedded credentials",971 ));972 }973 Ok(parsed.to_string())974}975976fn resolve_share_accounts(977 db: &Db,978 owner: AccountId,979 handles: &[String],980) -> ApiResult<Vec<AccountId>> {981 if handles.len() > MAX_SHARES {982 return Err(ApiError::bad_request(format!(983 "a resource may be shared with at most {MAX_SHARES} people"984 )));985 }986 let mut seen = HashSet::new();987 let mut viewers = Vec::new();988 for handle in handles {989 let handle =990 auth::validate_handle(handle).map_err(|e| ApiError::bad_request(e.to_string()))?;991 let viewer = db992 .account_by_handle(&handle)993 .map_err(ApiError::internal)?994 .ok_or_else(|| ApiError::bad_request(format!("no such user @{handle}")))?;995 if viewer.id == owner {996 return Err(ApiError::bad_request(997 "you do not need to share a resource with yourself",998 ));999 }1000 if seen.insert(viewer.id) {1001 viewers.push(viewer.id);1002 }1003 }1004 Ok(viewers)1005}10061007/// Check a folder name, treating blank as none.1008///1009/// A folder has no existence of its own — it is this string, repeated across the1010/// resources that share it — so "" and absent have to mean the same thing, or a1011/// profile would grow a nameless fold that nothing could remove.1012fn validate_folder(folder: Option<&str>) -> ApiResult<Option<String>> {1013 let Some(folder) = folder.map(str::trim).filter(|f| !f.is_empty()) else {1014 return Ok(None);1015 };1016 if folder.chars().count() > theme::MAX_FOLDER_NAME {1017 return Err(ApiError::bad_request(format!(1018 "a folder name may be at most {} characters",1019 theme::MAX_FOLDER_NAME1020 )));1021 }1022 // Control characters would survive HTML-escaping and come out as invisible1023 // damage to the summary line.1024 if folder.chars().any(char::is_control) {1025 return Err(ApiError::bad_request(1026 "a folder name may not contain control characters",1027 ));1028 }1029 Ok(Some(folder.to_string()))1030}10311032/// Remove a resource. Only its owner can, and only their own.1033///1034/// A resource that is not yours is 404 rather than 403, as everywhere else here:1035/// a 403 would confirm the id exists.1036async fn delete_resource(1037 State(state): State<Shared>,1038 headers: HeaderMap,1039 Path(id): Path<String>,1040) -> ApiResult<impl IntoResponse> {1041 let owner = require_account(&state, &headers)?;1042 check_rate(&state, owner, RateClass::Mutation)?;1043 let resource = ResourceId::from_str(&id).map_err(|_| ApiError::not_found())?;10441045 let db = state.db.lock().unwrap();1046 let viewers = db.shared_with(resource).map_err(ApiError::internal)?;1047 if !db1048 .delete_resource(owner, resource)1049 .map_err(ApiError::internal)?1050 {1051 return Err(ApiError::not_found());1052 }1053 drop(db);1054 state.notify_profiles(std::iter::once(owner).chain(viewers));1055 Ok(StatusCode::NO_CONTENT)1056}10571058async fn list_resources(1059 State(state): State<Shared>,1060 headers: HeaderMap,1061) -> ApiResult<impl IntoResponse> {1062 let owner = require_account(&state, &headers)?;1063 let db = state.db.lock().unwrap();1064 let resources = db.resources_owned_by(owner).map_err(ApiError::internal)?;1065 let resources = resources1066 .iter()1067 .map(|resource| {1068 let shares = db1069 .share_recipients(resource.id)1070 .map_err(ApiError::internal)?;1071 let shared_with = shares1072 .iter()1073 .filter(|share| share.status == ShareStatus::Accepted)1074 .map(|share| share.handle.clone())1075 .collect::<Vec<_>>();1076 Ok(serde_json::json!({1077 "resource_id": resource.id.to_string(),1078 "name": resource.name,1079 "kind": resource.kind.as_str(),1080 "visibility": resource.visibility.as_str(),1081 "url": resource.url,1082 "folder": resource.folder,1083 "shared_with": shared_with,1084 "shares": shares.iter().map(|share| serde_json::json!({1085 "handle": share.handle,1086 "status": share.status.as_str(),1087 })).collect::<Vec<_>>(),1088 }))1089 })1090 .collect::<ApiResult<Vec<_>>>()?;1091 Ok(Json(serde_json::json!({1092 "resources": resources1093 })))1094}10951096async fn set_resource_shares(1097 State(state): State<Shared>,1098 headers: HeaderMap,1099 Path(id): Path<String>,1100 Json(body): Json<SetSharesRequest>,1101) -> ApiResult<impl IntoResponse> {1102 let owner = require_account(&state, &headers)?;1103 check_rate(&state, owner, RateClass::Mutation)?;1104 let resource_id = ResourceId::from_str(&id).map_err(|_| ApiError::not_found())?;11051106 let mut db = state.db.lock().unwrap();1107 let resource = db1108 .resource(resource_id)1109 .map_err(ApiError::internal)?1110 .filter(|resource| resource.owner_id == owner)1111 .ok_or_else(ApiError::not_found)?;1112 if resource.visibility != Visibility::Shared && !body.share_with.is_empty() {1113 return Err(ApiError::bad_request(1114 "only a shared resource can name viewers",1115 ));1116 }1117 let old_viewers = db.shared_with(resource_id).map_err(ApiError::internal)?;1118 let viewers = resolve_share_accounts(&db, owner, &body.share_with)?;1119 db.set_shares(resource_id, &viewers)1120 .map_err(ApiError::internal)?;1121 let new_viewers = db.shared_with(resource_id).map_err(ApiError::internal)?;1122 drop(db);1123 state.notify_profiles(std::iter::once(owner).chain(old_viewers).chain(new_viewers));1124 Ok(StatusCode::NO_CONTENT)1125}11261127async fn list_share_invitations(1128 State(state): State<Shared>,1129 headers: HeaderMap,1130) -> ApiResult<impl IntoResponse> {1131 let viewer = require_browser_account(&state, &headers)?;1132 let db = state.db.lock().unwrap();1133 let invitations = db.incoming_shares(viewer).map_err(ApiError::internal)?;1134 Ok(Json(serde_json::json!({1135 "shares": invitations.iter().map(|invitation| serde_json::json!({1136 "resource_id": invitation.resource.id.to_string(),1137 "name": invitation.resource.name,1138 "kind": invitation.resource.kind.as_str(),1139 "url": invitation.resource.url,1140 "owner_handle": invitation.owner_handle,1141 "status": invitation.status.as_str(),1142 })).collect::<Vec<_>>()1143 })))1144}11451146async fn accept_share_invitation(1147 State(state): State<Shared>,1148 headers: HeaderMap,1149 Path(id): Path<String>,1150) -> ApiResult<impl IntoResponse> {1151 update_share_invitation(&state, &headers, &id, true)?;1152 Ok(StatusCode::NO_CONTENT)1153}11541155async fn decline_share_invitation(1156 State(state): State<Shared>,1157 headers: HeaderMap,1158 Path(id): Path<String>,1159) -> ApiResult<impl IntoResponse> {1160 update_share_invitation(&state, &headers, &id, false)?;1161 Ok(StatusCode::NO_CONTENT)1162}11631164async fn accept_share_invitation_ui(1165 State(state): State<Shared>,1166 headers: HeaderMap,1167 Path(id): Path<String>,1168) -> ApiResult<Html<String>> {1169 let viewer = update_share_invitation(&state, &headers, &id, true)?;1170 incoming_share_controls(&state, viewer)1171}11721173async fn decline_share_invitation_ui(1174 State(state): State<Shared>,1175 headers: HeaderMap,1176 Path(id): Path<String>,1177) -> ApiResult<Html<String>> {1178 let viewer = update_share_invitation(&state, &headers, &id, false)?;1179 incoming_share_controls(&state, viewer)1180}11811182fn update_share_invitation(1183 state: &AppState,1184 headers: &HeaderMap,1185 id: &str,1186 accept: bool,1187) -> ApiResult<AccountId> {1188 let viewer = require_browser_account(state, headers)?;1189 check_rate(state, viewer, RateClass::Mutation)?;1190 let resource = ResourceId::from_str(id).map_err(|_| ApiError::not_found())?;1191 let mut db = state.db.lock().unwrap();1192 let changed = if accept {1193 db.accept_share(viewer, resource)1194 } else {1195 db.decline_share(viewer, resource)1196 }1197 .map_err(ApiError::internal)?;1198 if !changed {1199 return Err(ApiError::not_found());1200 }1201 let owner = db1202 .resource(resource)1203 .map_err(ApiError::internal)?1204 .ok_or_else(ApiError::not_found)?1205 .owner_id;1206 drop(db);1207 state.notify_profiles([viewer, owner]);1208 Ok(viewer)1209}12101211fn incoming_share_controls(state: &AppState, viewer: AccountId) -> ApiResult<Html<String>> {1212 let db = state.db.lock().unwrap();1213 let invitations = db.incoming_shares(viewer).map_err(ApiError::internal)?;1214 if invitations.is_empty() {1215 return Ok(Html(1216 "<li><small>No incoming shares.</small></li>".to_string(),1217 ));1218 }12191220 let mut html = String::new();1221 for invitation in invitations {1222 let id = invitation.resource.id;1223 let destination = invitation1224 .resource1225 .url1226 .as_deref()1227 .map_or_else(String::new, |url| {1228 format!(" - <span data-selectable>{}</span>", escape_html(url))1229 });1230 html.push_str(&format!(1231 "<li><div><strong>{}</strong><small>from @{} - {}{}</small></div>\1232 <div class=\"dashboard-actions\">",1233 escape_html(&invitation.resource.name),1234 escape_html(&invitation.owner_handle),1235 invitation.resource.kind.as_str(),1236 destination,1237 ));1238 if invitation.status == ShareStatus::Pending {1239 html.push_str(&format!(1240 "<button type=\"button\" fx-action=\"/ui/share-invitations/{id}/accept\" \1241 fx-method=\"POST\" fx-target=\"#incoming-shares\" \1242 fx-swap=\"innerHTML\">Accept</button>"1243 ));1244 }1245 let label = if invitation.status == ShareStatus::Pending {1246 "Decline"1247 } else {1248 "Remove"1249 };1250 html.push_str(&format!(1251 "<button class=\"secondary outline\" type=\"button\" \1252 fx-action=\"/ui/share-invitations/{id}\" fx-method=\"DELETE\" \1253 fx-target=\"#incoming-shares\" fx-swap=\"innerHTML\">{label}</button>\1254 </div></li>"1255 ));1256 }1257 Ok(Html(html))1258}12591260fn escape_html(value: &str) -> String {1261 let mut escaped = String::with_capacity(value.len());1262 for character in value.chars() {1263 match character {1264 '&' => escaped.push_str("&"),1265 '<' => escaped.push_str("<"),1266 '>' => escaped.push_str(">"),1267 '"' => escaped.push_str("""),1268 '\'' => escaped.push_str("'"),1269 other => escaped.push(other),1270 }1271 }1272 escaped1273}12741275/// Record which endpoint id this account's daemon answers on.1276///1277/// Called once when a daemon starts, not on a timer. The id is stable across1278/// restarts, and where that endpoint can be reached is published by the daemon1279/// itself and resolved by the browser — the control plane is not in the1280/// addressing path and does not want to be.1281async fn register_daemon(1282 State(state): State<Shared>,1283 headers: HeaderMap,1284 Json(body): Json<RegisterDaemonRequest>,1285) -> ApiResult<impl IntoResponse> {1286 let machine = require_machine(&state, &headers)?;1287 check_rate(&state, machine.account.id, RateClass::Mutation)?;1288 verify_endpoint_proof(&machine.endpoint_id, &body.proof)?;12891290 let db = state.db.lock().unwrap();1291 db.register_daemon(machine.account.id, &machine.endpoint_id)1292 .map_err(ApiError::internal)?;1293 drop(db);12941295 let endpoint_key = parse_endpoint_id(&machine.endpoint_id)1296 .ok_or_else(|| ApiError::internal(anyhow::anyhow!("stored endpoint id is invalid")))?;1297 let relay_access = state1298 .relay_config1299 .access(&endpoint_key)1300 .map_err(ApiError::internal)?;1301 Ok(Json(relay_access))1302}13031304/// Report endpoint identities without implying daemon liveness. Registration is1305/// durable and records only which endpoint the account currently addresses.1306async fn daemon_info(1307 State(state): State<Shared>,1308 headers: HeaderMap,1309) -> ApiResult<impl IntoResponse> {1310 let machine = require_machine(&state, &headers)?;1311 let registered_endpoint_id = state1312 .db1313 .lock()1314 .unwrap()1315 .daemon_endpoint(machine.account.id)1316 .map_err(ApiError::internal)?;1317 Ok(Json(serde_json::json!({1318 "credential_endpoint_id": machine.endpoint_id,1319 "registration_matches_credential": registered_endpoint_id.as_deref()1320 == Some(machine.endpoint_id.as_str()),1321 "registered_endpoint_id": registered_endpoint_id,1322 })))1323}13241325async fn daemon_authorizations(1326 State(state): State<Shared>,1327 headers: HeaderMap,1328) -> ApiResult<impl IntoResponse> {1329 let machine = require_machine(&state, &headers)?;1330 let pairs = state1331 .db1332 .lock()1333 .unwrap()1334 .service_authorizations(machine.account.id)1335 .map_err(ApiError::internal)?;1336 Ok(Json(serde_json::json!({1337 "authorizations": pairs.into_iter().map(|(viewer, resource)| serde_json::json!({1338 "viewer_id": viewer.to_string(),1339 "resource_id": resource.to_string(),1340 })).collect::<Vec<_>>()1341 })))1342}13431344/// Consume a one-use browser ticket, bind it to the daemon's Ed25519 identity,1345/// and rotate it into the persistent machine credential stored by the CLI.1346async fn enroll_machine(1347 State(state): State<Shared>,1348 headers: HeaderMap,1349 Json(body): Json<EnrollMachineRequest>,1350) -> ApiResult<impl IntoResponse> {1351 let ticket = bearer_token(&headers).ok_or_else(ApiError::unauthorized)?;1352 if !valid_prefixed_token(&ticket, "dmt_") {1353 return Err(ApiError::unauthorized());1354 }1355 verify_endpoint_proof(&body.endpoint_id, &body.proof)?;13561357 let now = now_secs();1358 let ticket_hash = auth::hash_token(&ticket);1359 let token = auth::generate_machine_token();1360 let token_hash = auth::hash_token(&token);1361 let (credential_id, account) = {1362 state1363 .db1364 .lock()1365 .unwrap()1366 .machine_enrollment(&ticket_hash)1367 .map_err(ApiError::internal)?1368 .ok_or_else(ApiError::unauthorized)?1369 };1370 check_rate(&state, account, RateClass::Credential)?;1371 if !state1372 .db1373 .lock()1374 .unwrap()1375 .enroll_machine(1376 &credential_id,1377 account,1378 &ticket_hash,1379 &token_hash,1380 &body.endpoint_id,1381 now,1382 )1383 .map_err(ApiError::internal)?1384 {1385 return Err(ApiError::unauthorized());1386 }13871388 Ok(Json(serde_json::json!({1389 "machine_credential": token,1390 "endpoint_id": body.endpoint_id,1391 })))1392}13931394fn verify_endpoint_proof(endpoint_id: &str, proof: &str) -> ApiResult<()> {1395 let endpoint = parse_endpoint_id(endpoint_id)1396 .ok_or_else(|| ApiError::bad_request("endpoint_id is not a 32 byte Ed25519 key"))?;1397 verify_endpoint_signature(1398 &endpoint,1399 proof,1400 &devsite_proto::machine_endpoint_proof_message(&endpoint),1401 )1402}14031404fn verify_endpoint_signature(endpoint: &KeyBytes, proof: &str, message: &[u8]) -> ApiResult<()> {1405 let signature = data_encoding::BASE64URL_NOPAD1406 .decode(proof.as_bytes())1407 .map_err(|_| ApiError::bad_request("endpoint proof is not base64url"))?;1408 let signature: [u8; 64] = signature1409 .try_into()1410 .map_err(|_| ApiError::bad_request("endpoint proof is not a 64 byte signature"))?;1411 let key = ed25519_dalek::VerifyingKey::from_bytes(endpoint)1412 .map_err(|_| ApiError::bad_request("endpoint_id is not a valid Ed25519 key"))?;1413 use ed25519_dalek::Verifier;1414 key.verify(message, &ed25519_dalek::Signature::from_bytes(&signature))1415 .map_err(|_| ApiError::bad_request("endpoint proof did not verify"))1416}14171418// -- dashboard ---------------------------------------------------------------14191420async fn profile_settings(1421 State(state): State<Shared>,1422 headers: HeaderMap,1423) -> ApiResult<impl IntoResponse> {1424 let account = require_browser_account(&state, &headers)?;1425 let db = state.db.lock().unwrap();1426 let private_only = db1427 .profile_private_only(account)1428 .map_err(ApiError::internal)?;1429 Ok(Json(serde_json::json!({ "private_only": private_only })))1430}14311432async fn set_profile_settings(1433 State(state): State<Shared>,1434 headers: HeaderMap,1435 Json(body): Json<ProfileSettingsRequest>,1436) -> ApiResult<impl IntoResponse> {1437 let account = require_browser_account(&state, &headers)?;1438 check_rate(&state, account, RateClass::Mutation)?;1439 let db = state.db.lock().unwrap();1440 db.set_profile_private_only(account, body.private_only)1441 .map_err(ApiError::internal)?;1442 drop(db);1443 state.notify_profiles([account]);1444 Ok(Json(1445 serde_json::json!({ "private_only": body.private_only }),1446 ))1447}14481449const MAX_MACHINE_CREDENTIALS: usize = 10;1450const MAX_MACHINE_NAME: usize = 60;1451const SERVICE_GRANT_SCOPE: &str = "service_grants:issue";14521453fn validate_machine_name(name: &str) -> ApiResult<&str> {1454 if name.trim() != name || name.is_empty() {1455 return Err(ApiError::bad_request("a machine name may not be blank"));1456 }1457 if name.chars().count() > MAX_MACHINE_NAME {1458 return Err(ApiError::bad_request(format!(1459 "a machine name may be at most {MAX_MACHINE_NAME} characters"1460 )));1461 }1462 if name.chars().any(char::is_control) {1463 return Err(ApiError::bad_request(1464 "a machine name may not contain control characters",1465 ));1466 }1467 Ok(name)1468}14691470async fn list_machine_credentials(1471 State(state): State<Shared>,1472 headers: HeaderMap,1473) -> ApiResult<impl IntoResponse> {1474 let account = require_browser_account(&state, &headers)?;1475 let db = state.db.lock().unwrap();1476 let credentials = db1477 .machine_credentials(account)1478 .map_err(ApiError::internal)?;1479 Ok(Json(serde_json::json!({1480 "credentials": credentials.iter().map(|credential| serde_json::json!({1481 "id": credential.id,1482 "name": credential.name,1483 "created_at": credential.created_at,1484 "last_used_at": credential.last_used_at,1485 "endpoint_id": credential.endpoint_id,1486 "enrolled_at": credential.enrolled_at,1487 "scopes": credential.scopes,1488 })).collect::<Vec<_>>()1489 })))1490}14911492async fn create_machine_credential(1493 State(state): State<Shared>,1494 headers: HeaderMap,1495 Json(body): Json<CreateMachineCredentialRequest>,1496) -> ApiResult<impl IntoResponse> {1497 let account = require_browser_account(&state, &headers)?;1498 check_rate(&state, account, RateClass::Credential)?;1499 let name = validate_machine_name(&body.name)?;1500 let mut scopes = body.scopes;1501 scopes.sort();1502 scopes.dedup();1503 if scopes.iter().any(|scope| scope != SERVICE_GRANT_SCOPE) {1504 return Err(ApiError::bad_request(format!(1505 "the only supported machine scope is `{SERVICE_GRANT_SCOPE}`"1506 )));1507 }1508 let ticket = auth::generate_machine_ticket();1509 let id = auth::generate_machine_credential_id();1510 let now = now_secs();15111512 let db = state.db.lock().unwrap();1513 if db1514 .active_machine_credential_count(account)1515 .map_err(ApiError::internal)?1516 >= MAX_MACHINE_CREDENTIALS1517 {1518 return Err(ApiError::bad_request(format!(1519 "an account may have at most {MAX_MACHINE_CREDENTIALS} active machine credentials"1520 )));1521 }1522 db.create_machine_credential(&id, account, name, &auth::hash_token(&ticket), &scopes, now)1523 .map_err(ApiError::internal)?;1524 Ok((1525 StatusCode::CREATED,1526 Json(serde_json::json!({1527 "credential": {1528 "id": id,1529 "name": name,1530 "created_at": now,1531 "last_used_at": null,1532 "scopes": scopes,1533 },1534 "ticket": ticket,1535 })),1536 ))1537}15381539async fn revoke_machine_credential(1540 State(state): State<Shared>,1541 headers: HeaderMap,1542 Path(id): Path<String>,1543) -> ApiResult<impl IntoResponse> {1544 let account = require_browser_account(&state, &headers)?;1545 check_rate(&state, account, RateClass::Credential)?;1546 let db = state.db.lock().unwrap();1547 if !db1548 .revoke_machine_credential(account, &id, now_secs())1549 .map_err(ApiError::internal)?1550 {1551 return Err(ApiError::not_found());1552 }1553 Ok(StatusCode::NO_CONTENT)1554}15551556// -- themes --------------------------------------------------------------------15571558/// The presentation properties a profile may set, and what each one accepts.1559///1560/// Served by the same binary that enforces the list, so the CLI, the website and1561/// the docs cannot drift from what is actually accepted.1562async fn theme_properties() -> impl IntoResponse {1563 Json(serde_json::json!({1564 "properties": theme::properties()1565 .map(|(name, accepts)| serde_json::json!({ "name": name, "accepts": accepts }))1566 .collect::<Vec<_>>()1567 }))1568}15691570async fn read_theme(1571 State(state): State<Shared>,1572 headers: HeaderMap,1573) -> ApiResult<impl IntoResponse> {1574 let account = require_account(&state, &headers)?;1575 let css = {1576 let db = state.db.lock().unwrap();1577 db.custom_css(account).map_err(ApiError::internal)?1578 };1579 let css = css.unwrap_or_default();1580 Ok(Json(ThemeResponse {1581 theme: theme::parse(&css).unwrap_or_default(),1582 css,1583 }))1584}15851586async fn write_theme(1587 State(state): State<Shared>,1588 headers: HeaderMap,1589 Json(body): Json<ThemeRequest>,1590) -> ApiResult<impl IntoResponse> {1591 let account = require_account(&state, &headers)?;1592 check_rate(&state, account, RateClass::Mutation)?;1593 const MAX_THEME_BYTES: usize = 16 * 1024;1594 if body.css.len() > MAX_THEME_BYTES {1595 return Err(ApiError::bad_request(format!(1596 "a theme may be at most {MAX_THEME_BYTES} bytes"1597 )));1598 }15991600 // Validated here, once, before anything is stored. Every later read — the1601 // profile page, the CLI, this endpoint — can then treat the column as1602 // already-checked rather than re-deciding what is safe.1603 let declarations = theme::parse(&body.css).map_err(ApiError::bad_request)?;1604 let css = theme::to_css(&declarations);16051606 let db = state.db.lock().unwrap();1607 db.set_custom_css(account, (!css.is_empty()).then_some(css.as_str()))1608 .map_err(ApiError::internal)?;1609 drop(db);1610 state.notify_profiles([account]);16111612 Ok(Json(ThemeResponse {1613 css,1614 theme: declarations,1615 }))1616}16171618async fn profile(1619 State(state): State<Shared>,1620 headers: HeaderMap,1621 Path(handle): Path<String>,1622) -> ApiResult<impl IntoResponse> {1623 Ok(Json(profile_snapshot(&state, &headers, &handle)?.response))1624}16251626struct ProfileSnapshot {1627 owner_id: AccountId,1628 response: ProfileResponse,1629}16301631fn profile_snapshot(1632 state: &AppState,1633 headers: &HeaderMap,1634 handle: &str,1635) -> ApiResult<ProfileSnapshot> {1636 let viewer = current_account(state, headers)?;1637 let handle = handle.trim_start_matches('@').to_ascii_lowercase();16381639 let db = state.db.lock().unwrap();1640 let owner = db1641 .account_by_handle(&handle)1642 .map_err(ApiError::internal)?1643 .ok_or_else(ApiError::not_found)?;1644 if db1645 .profile_private_only(owner.id)1646 .map_err(ApiError::internal)?1647 && viewer != Some(owner.id)1648 {1649 return Err(ApiError::not_found());1650 }16511652 let mut entries = Vec::new();1653 for resource in db1654 .resources_owned_by(owner.id)1655 .map_err(ApiError::internal)?1656 {1657 let shared_with = db.shared_with(resource.id).map_err(ApiError::internal)?;1658 // The single choke point. A resource that fails here is not merely hidden from the1659 // rendering — it never enters the response at all.1660 if !can_view(viewer, owner.id, resource.visibility, &shared_with) {1661 continue;1662 }1663 entries.push(to_entry(&resource, None));1664 }16651666 // "Shared with me" belongs on your own profile, not on everyone else's. Listing it on1667 // @alice's page would also show Bob the same Agent entry twice.1668 let mut shared_with_me = Vec::new();1669 if let Some(viewer_id) = viewer.filter(|v| *v == owner.id) {1670 for resource in db1671 .resources_shared_with(viewer_id)1672 .map_err(ApiError::internal)?1673 {1674 let shared = db.shared_with(resource.id).map_err(ApiError::internal)?;1675 if !can_view(viewer, resource.owner_id, resource.visibility, &shared) {1676 continue;1677 }1678 let owner_account = db1679 .account_by_id(resource.owner_id)1680 .map_err(ApiError::internal)?;1681 shared_with_me.push(to_entry(&resource, owner_account.and_then(|a| a.handle)));1682 }1683 }16841685 // Stored themes are canonical, so re-parsing them here is cheap and always1686 // succeeds. It is not merely a formality: if a property is ever retired from1687 // the whitelist, a profile styled with it degrades to the default rather1688 // than serving a rule the current build no longer stands behind.1689 let theme = match db.custom_css(owner.id).map_err(ApiError::internal)? {1690 Some(css) => theme::parse(&css).unwrap_or_else(|err| {1691 tracing::warn!(handle = %handle, "stored theme no longer validates: {err}");1692 Vec::new()1693 }),1694 None => Vec::new(),1695 };16961697 // No `is_owner`: it existed to decide whether to draw the theme editor, and1698 // the website no longer writes anything. What the owner sees that others do1699 // not is already expressed by the entries themselves — private resources,1700 // and the "shared with me" list, which is populated for the owner alone.1701 Ok(ProfileSnapshot {1702 owner_id: owner.id,1703 response: ProfileResponse {1704 handle,1705 entries,1706 shared_with_me,1707 theme,1708 },1709 })1710}17111712async fn profile_stream(1713 State(state): State<Shared>,1714 headers: HeaderMap,1715 Path(handle): Path<String>,1716) -> ApiResult<Response> {1717 let receiver = state.profile_changes.subscribe();1718 let revision = state.profile_revision.load(Ordering::Relaxed);1719 let initial = profile_snapshot(&state, &headers, &handle)?;1720 let owner = initial.owner_id;1721 let filter_state = Arc::clone(&state);1722 let render_state = Arc::clone(&state);1723 let update_headers = headers.clone();1724 let update_handle = handle.clone();17251726 let initial = stream::iter(profile_events(&initial.response, revision));1727 let updates = BroadcastStream::new(receiver)1728 .map(move |change| match change {1729 Ok(change) if change.accounts.contains(&owner) => Some(change.revision),1730 Ok(_) => None,1731 Err(_) => Some(filter_state.profile_revision.load(Ordering::Relaxed)),1732 })1733 .filter_map(|revision| async move { revision })1734 .map(move |revision| {1735 match profile_snapshot(&render_state, &update_headers, &update_handle) {1736 Ok(profile) => profile_events(&profile.response, revision),1737 Err(_) => vec![Ok(Event::default()1738 .event("unavailable")1739 .data("not found")1740 .id(revision.to_string()))],1741 }1742 })1743 .flat_map(stream::iter);17441745 let events = initial.chain(updates);1746 let mut response = Sse::new(events)1747 .keep_alive(1748 KeepAlive::new()1749 .interval(Duration::from_secs(15))1750 .text("keep-alive"),1751 )1752 .into_response();1753 // A streaming response must reach the browser as each event arrives.1754 response.headers_mut().insert(1755 header::CONTENT_ENCODING,1756 header::HeaderValue::from_static("identity"),1757 );1758 response.headers_mut().insert(1759 header::HeaderName::from_static("x-accel-buffering"),1760 header::HeaderValue::from_static("no"),1761 );1762 Ok(response)1763}17641765fn profile_events(profile: &ProfileResponse, revision: u64) -> Vec<Result<Event, Infallible>> {1766 let id = revision.to_string();1767 vec![1768 Ok(Event::default()1769 .event(r##"{"target":"#profile-theme","swap":"textContent"}"##)1770 .data(render_profile_theme(profile))1771 .id(id.clone())),1772 Ok(Event::default()1773 .data(render_profile_main(profile))1774 .id(id)1775 .retry(Duration::from_secs(3))),1776 ]1777}17781779fn render_profile_theme(profile: &ProfileResponse) -> String {1780 let declarations = profile1781 .theme1782 .iter()1783 .filter(|declaration| declaration.property.starts_with("--pico-"))1784 .map(|declaration| format!(" {}: {};", declaration.property, declaration.value))1785 .collect::<Vec<_>>();1786 if declarations.is_empty() {1787 // SSEXi does not apply a message with empty data. One space clears the style.1788 return " ".to_string();1789 }1790 format!(1791 ":root[data-profile=\"{}\"] {{\n{}\n}}\n",1792 profile.handle,1793 declarations.join("\n")1794 )1795}17961797struct ProfileLayout {1798 folders_open: bool,1799 open_folders: HashSet<String>,1800 folder_order: Vec<String>,1801}18021803fn profile_layout(declarations: &[theme::Declaration]) -> ProfileLayout {1804 let value = |property| {1805 declarations1806 .iter()1807 .find(|declaration| declaration.property == property)1808 .map(|declaration| declaration.value.as_str())1809 };1810 let names = |property| {1811 value(property)1812 .and_then(|list| serde_json::from_str::<Vec<String>>(&format!("[{list}]")).ok())1813 .unwrap_or_default()1814 };1815 ProfileLayout {1816 folders_open: value(theme::FOLDERS) != Some("closed"),1817 open_folders: names(theme::OPEN_FOLDERS).into_iter().collect(),1818 folder_order: names(theme::FOLDER_ORDER),1819 }1820}18211822fn render_profile_main(profile: &ProfileResponse) -> String {1823 let scheme = profile1824 .theme1825 .iter()1826 .find(|declaration| declaration.property == theme::SCHEME)1827 .map(|declaration| declaration.value.as_str())1828 .unwrap_or("auto");1829 let entries = profile1830 .entries1831 .iter()1832 .chain(profile.shared_with_me.iter())1833 .collect::<Vec<_>>();1834 let layout = profile_layout(&profile.theme);1835 let mut loose = Vec::new();1836 let mut folders: HashMap<String, Vec<&ProfileEntry>> = HashMap::new();1837 let mut first_folder_order = Vec::new();1838 for entry in &entries {1839 if let Some(folder) = &entry.folder {1840 if !folders.contains_key(folder) {1841 first_folder_order.push(folder.clone());1842 }1843 folders.entry(folder.clone()).or_default().push(entry);1844 } else {1845 loose.push(*entry);1846 }1847 }1848 let mut folder_order = Vec::new();1849 let mut placed = HashSet::new();1850 for folder in layout.folder_order.iter().chain(first_folder_order.iter()) {1851 if folders.contains_key(folder) && placed.insert(folder.clone()) {1852 folder_order.push(folder.clone());1853 }1854 }18551856 let total = entries.len();1857 let count = if total == 1 {1858 "1 site".to_string()1859 } else {1860 format!("{total} sites")1861 };1862 let mut html = format!(1863 "<main class=\"container\" id=\"main\" data-profile-handle=\"{}\" \1864 data-profile-scheme=\"{}\"><article id=\"profile\"><hgroup><h1>@{}</h1>\1865 <p>{count}</p></hgroup>",1866 escape_html(&profile.handle),1867 escape_html(scheme),1868 escape_html(&profile.handle),1869 );1870 if total == 0 {1871 html.push_str("<p><small>Nothing published yet.</small></p>");1872 } else {1873 if !loose.is_empty() {1874 render_profile_entries(&mut html, &loose);1875 }1876 for folder in folder_order {1877 let items = folders.get(&folder).expect("a placed folder must exist");1878 let open = layout.folders_open || layout.open_folders.contains(&folder);1879 let _ = write!(1880 html,1881 "<details class=\"folder\" data-folder=\"{}\"{}><summary>{} \1882 <small>{}</small></summary>",1883 escape_html(&folder),1884 if open { " open" } else { "" },1885 escape_html(&folder),1886 items.len(),1887 );1888 render_profile_entries(&mut html, items);1889 html.push_str("</details>");1890 }1891 }1892 html.push_str("</article></main>");1893 html1894}18951896fn render_profile_entries(html: &mut String, entries: &[&ProfileEntry]) {1897 html.push_str("<ul class=\"entries\">");1898 for entry in entries {1899 let owner = entry.owner_handle.as_deref();1900 let owner_label = owner1901 .map(|handle| format!(" <small>from @{}</small>", escape_html(handle)))1902 .unwrap_or_default();1903 let _ = write!(1904 html,1905 "<li class=\"entry\" id=\"site-{}\" data-kind=\"{}\" \1906 data-visibility=\"{}\">",1907 entry.resource_id, entry.kind, entry.visibility,1908 );1909 if entry.kind == "link" {1910 let url = entry.url.as_deref().unwrap_or_default();1911 let note = if entry.visibility == "public" {1912 String::new()1913 } else {1914 format!("{} - ", escape_html(entry.visibility))1915 };1916 let _ = write!(1917 html,1918 "<a href=\"{}\" target=\"_blank\" rel=\"ugc nofollow noopener noreferrer\">\1919 {}{owner_label}</a><small class=\"state\">{note}<span class=\"host\">\1920 <span>{} </span></span>↗</small>",1921 escape_html(url),1922 escape_html(&entry.name),1923 escape_html(&link_host(url)),1924 );1925 } else {1926 let owner_attribute = owner1927 .map(|handle| format!(" data-service-owner=\"{}\"", escape_html(handle)))1928 .unwrap_or_default();1929 let note = if entry.visibility == "public" {1930 String::new()1931 } else {1932 format!(1933 "<small class=\"state\">{}</small>",1934 escape_html(entry.visibility)1935 )1936 };1937 let _ = write!(1938 html,1939 "<button class=\"outline\" type=\"button\" data-service-id=\"{}\" \1940 data-service-name=\"{}\"{owner_attribute}>{}{owner_label}</button>{note}",1941 entry.resource_id,1942 escape_html(&entry.name),1943 escape_html(&entry.name),1944 );1945 }1946 html.push_str("</li>");1947 }1948 html.push_str("</ul>");1949}19501951fn link_host(value: &str) -> String {1952 let Ok(url) = url::Url::parse(value) else {1953 return String::new();1954 };1955 let mut host = url.host_str().unwrap_or_default().to_string();1956 if let Some(port) = url.port() {1957 let _ = write!(host, ":{port}");1958 }1959 host1960}19611962fn to_entry(resource: &crate::db::Resource, owner_handle: Option<String>) -> ProfileEntry {1963 ProfileEntry {1964 resource_id: resource.id.to_string(),1965 name: resource.name.clone(),1966 kind: resource.kind.as_str(),1967 visibility: resource.visibility.as_str(),1968 url: resource.url.clone(),1969 folder: resource.folder.clone(),1970 owner_handle,1971 }1972}19731974const CONNECTION_TICKET_LIFETIME_SECS: u64 = 2 * 60;1975const TUNNEL_SESSION_LIFETIME_SECS: u64 = 8 * 60 * 60;1976const SERVICE_GRANT_REQUEST_LIFETIME_SECS: u64 = 10 * 60;1977const SERVICE_GRANT_MAX_LIFETIME_SECS: u64 = 15 * 60;1978const MAX_ACTIVE_BROKER_GRANTS: usize = 32;1979const CONNECTION_TICKET_PREFIX: &str = "dst_";1980const TUNNEL_SESSION_PREFIX: &str = "dss_";19811982async fn accessible_services(1983 State(state): State<Shared>,1984 headers: HeaderMap,1985 Query(query): Query<ServiceSearchQuery>,1986) -> ApiResult<impl IntoResponse> {1987 let machine = require_machine(&state, &headers)?;1988 require_service_grant_scope(&machine)?;1989 let keyword = query.keyword.trim();1990 if keyword.is_empty() || keyword.chars().count() > MAX_RESOURCE_NAME {1991 return Err(ApiError::bad_request("keyword must be 1 to 80 characters"));1992 }1993 let needle = keyword.to_lowercase();1994 let db = state.db.lock().unwrap();1995 let services = db1996 .accessible_services(machine.account.id)1997 .map_err(ApiError::internal)?1998 .into_iter()1999 .filter(|(resource, owner)| {2000 resource.name.to_lowercase().contains(&needle)2001 || owner2002 .as_deref()2003 .is_some_and(|handle| handle.to_lowercase().contains(&needle))2004 })2005 .map(|(resource, owner_handle)| {2006 serde_json::json!({2007 "resource_id": resource.id.to_string(),2008 "name": resource.name,2009 "owner_handle": owner_handle,2010 "visibility": resource.visibility.as_str(),2011 "exact_name_match": resource.name.eq_ignore_ascii_case(keyword),2012 })2013 })2014 .collect::<Vec<_>>();2015 Ok(Json(serde_json::json!({2016 "keyword": keyword,2017 "services": services,2018 })))2019}20202021async fn plan_service_grant(2022 State(state): State<Shared>,2023 headers: HeaderMap,2024 Json(body): Json<ServiceGrantIssueRequest>,2025) -> ApiResult<impl IntoResponse> {2026 let machine = require_machine(&state, &headers)?;2027 require_service_grant_scope(&machine)?;2028 let now = now_secs();2029 let db = state.db.lock().unwrap();2030 let (resource, owner_handle, claims) = validate_service_grant(&db, &machine, &body, now)?;2031 let approved_plan = state2032 .issuer2033 .issue_access_plan(&claims)2034 .map_err(ApiError::internal)?;2035 Ok(Json(service_grant_plan(2036 &machine,2037 &body,2038 &resource,2039 owner_handle.as_deref(),2040 &approved_plan,2041 )))2042}20432044async fn issue_service_grant(2045 State(state): State<Shared>,2046 headers: HeaderMap,2047 Json(body): Json<ServiceGrantIssueRequest>,2048) -> ApiResult<impl IntoResponse> {2049 let machine = require_machine(&state, &headers)?;2050 require_service_grant_scope(&machine)?;2051 check_rate(&state, machine.account.id, RateClass::Capability)?;2052 let now = now_secs();2053 let token = prefixed_token(TUNNEL_SESSION_PREFIX);2054 let (resource, owner_handle) = {2055 let db = state.db.lock().unwrap();2056 let (resource, owner_handle, claims) = validate_service_grant(&db, &machine, &body, now)?;2057 let approved_plan = body2058 .approved_plan2059 .as_deref()2060 .ok_or_else(|| ApiError::bad_request("an approved plan token is required"))?;2061 let approved_claims = state2062 .issuer2063 .verify_access_plan(approved_plan)2064 .map_err(|_| ApiError::bad_request("approved plan token did not verify"))?;2065 if approved_claims != claims {2066 return Err(ApiError::bad_request(2067 "approved plan does not match this grant request",2068 ));2069 }2070 if db2071 .active_broker_grant_count(&machine.credential_id, now)2072 .map_err(ApiError::internal)?2073 >= MAX_ACTIVE_BROKER_GRANTS2074 {2075 return Err(ApiError::too_many_requests());2076 }2077 if !db2078 .create_broker_tunnel_session(2079 &auth::hash_token(&token),2080 machine.account.id,2081 resource.id,2082 &body.request.requester_endpoint_id,2083 &machine.credential_id,2084 &body.request.request_id,2085 now,2086 body.expires_at,2087 )2088 .map_err(ApiError::internal)?2089 {2090 return Err(ApiError::bad_request(2091 "this service grant request was already used",2092 ));2093 }2094 (resource, owner_handle)2095 };20962097 Ok((2098 StatusCode::CREATED,2099 Json(serde_json::json!({2100 "grant": token,2101 "request_id": body.request.request_id,2102 "resource_id": resource.id.to_string(),2103 "name": resource.name,2104 "owner_id": resource.owner_id.to_string(),2105 "owner_handle": owner_handle,2106 "requester_endpoint_id": body.request.requester_endpoint_id,2107 "expires_at": body.expires_at,2108 })),2109 ))2110}21112112fn require_service_grant_scope(machine: &MachineAuthentication) -> ApiResult<()> {2113 if machine2114 .scopes2115 .iter()2116 .any(|scope| scope == SERVICE_GRANT_SCOPE)2117 {2118 Ok(())2119 } else {2120 Err(ApiError::forbidden(format!(2121 "this machine credential lacks `{SERVICE_GRANT_SCOPE}`"2122 )))2123 }2124}21252126fn validate_service_grant(2127 db: &Db,2128 machine: &MachineAuthentication,2129 body: &ServiceGrantIssueRequest,2130 now: u64,2131) -> ApiResult<(crate::db::Resource, Option<String>, ServiceGrantPlanClaims)> {2132 let request = &body.request;2133 if request.schema_version != 1 {2134 return Err(ApiError::bad_request(2135 "unsupported service grant request version",2136 ));2137 }2138 if !valid_grant_request_id(&request.request_id) {2139 return Err(ApiError::bad_request("invalid service grant request id"));2140 }2141 if request.service.trim() != request.service2142 || request.service.is_empty()2143 || request.service.chars().count() > MAX_RESOURCE_NAME2144 {2145 return Err(ApiError::bad_request("invalid requested service keyword"));2146 }2147 if request.expires_at <= now2148 || request.expires_at > now.saturating_add(SERVICE_GRANT_REQUEST_LIFETIME_SECS)2149 {2150 return Err(ApiError::bad_request(2151 "service grant request is expired or too long-lived",2152 ));2153 }2154 if body.expires_at <= now2155 || body.expires_at > now.saturating_add(SERVICE_GRANT_MAX_LIFETIME_SECS)2156 {2157 return Err(ApiError::bad_request(2158 "service grant lifetime must be at most 15 minutes",2159 ));2160 }21612162 let requester = parse_endpoint_id(&request.requester_endpoint_id)2163 .ok_or_else(|| ApiError::bad_request("requester endpoint id is invalid"))?;2164 let request_message = devsite_proto::service_grant_request_message(2165 &request.request_id,2166 &request.service,2167 &requester,2168 request.expires_at,2169 );2170 verify_endpoint_signature(&requester, &request.proof, &request_message)?;21712172 let resource_id = ResourceId::from_str(&body.resource_id).map_err(|_| ApiError::not_found())?;2173 let needle = request.service.to_lowercase();2174 let resource = db2175 .accessible_services(machine.account.id)2176 .map_err(ApiError::internal)?2177 .into_iter()2178 .find(|(resource, owner)| {2179 resource.id == resource_id2180 && (resource.name.to_lowercase().contains(&needle)2181 || owner2182 .as_deref()2183 .is_some_and(|handle| handle.to_lowercase().contains(&needle)))2184 })2185 .ok_or_else(ApiError::not_found)?;21862187 let broker = parse_endpoint_id(&machine.endpoint_id)2188 .ok_or_else(|| ApiError::internal(anyhow::anyhow!("broker endpoint id is invalid")))?;2189 let issue_message = devsite_proto::service_grant_issue_message(2190 &request.request_id,2191 &body.resource_id,2192 &requester,2193 body.expires_at,2194 );2195 verify_endpoint_signature(&broker, &body.broker_proof, &issue_message)?;2196 let claims = ServiceGrantPlanClaims {2197 schema_version: request.schema_version,2198 issuer_credential_id: machine.credential_id.clone(),2199 request_id: request.request_id.clone(),2200 service: request.service.clone(),2201 resource_id: body.resource_id.clone(),2202 requester_endpoint_id: request.requester_endpoint_id.clone(),2203 request_expires_at: request.expires_at,2204 grant_expires_at: body.expires_at,2205 };2206 Ok((resource.0, resource.1, claims))2207}22082209fn service_grant_plan(2210 machine: &MachineAuthentication,2211 body: &ServiceGrantIssueRequest,2212 resource: &crate::db::Resource,2213 owner_handle: Option<&str>,2214 approved_plan: &str,2215) -> serde_json::Value {2216 serde_json::json!({2217 "action": "issue_service_grant",2218 "request_id": body.request.request_id,2219 "requested_service": body.request.service,2220 "resource": {2221 "resource_id": resource.id.to_string(),2222 "name": resource.name,2223 "owner_id": resource.owner_id.to_string(),2224 "owner_handle": owner_handle,2225 },2226 "requester_endpoint_id": body.request.requester_endpoint_id,2227 "issuer_credential_id": machine.credential_id,2228 "expires_at": body.expires_at,2229 "approved_plan": approved_plan,2230 "effects": [2231 "create_endpoint_bound_tunnel_session",2232 "invalidate_session_when_broker_credential_is_revoked",2233 ],2234 })2235}22362237fn valid_grant_request_id(value: &str) -> bool {2238 value.starts_with("agr_")2239 && (12..=100).contains(&value.len())2240 && value2241 .bytes()2242 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))2243}22442245/// Mint a short-lived, single-use bootstrap ticket from an authenticated browser.2246async fn create_connection_ticket(2247 State(state): State<Shared>,2248 headers: HeaderMap,2249 Path(id): Path<String>,2250) -> ApiResult<impl IntoResponse> {2251 let viewer = require_browser_account(&state, &headers)?;2252 check_rate(&state, viewer, RateClass::Capability)?;2253 let resource_id = ResourceId::from_str(&id).map_err(|_| ApiError::not_found())?;2254 let now = now_secs();2255 let ticket = prefixed_token(CONNECTION_TICKET_PREFIX);2256 let expires_at = now + CONNECTION_TICKET_LIFETIME_SECS;22572258 let db = state.db.lock().unwrap();2259 let resource = db2260 .resource(resource_id)2261 .map_err(ApiError::internal)?2262 .ok_or_else(ApiError::not_found)?;2263 let shared_with = db.shared_with(resource_id).map_err(ApiError::internal)?;2264 if resource.kind != ResourceKind::Service2265 || !can_view(2266 Some(viewer),2267 resource.owner_id,2268 resource.visibility,2269 &shared_with,2270 )2271 {2272 return Err(ApiError::not_found());2273 }2274 db.create_connection_ticket(2275 &auth::hash_token(&ticket),2276 viewer,2277 resource_id,2278 now,2279 expires_at,2280 )2281 .map_err(ApiError::internal)?;22822283 Ok((2284 StatusCode::CREATED,2285 Json(ConnectionTicketResponse { ticket, expires_at }),2286 ))2287}22882289/// Consume a browser ticket and replace it with a session bound to the CLI's2290/// ephemeral Iroh endpoint key. The session should remain in process memory only.2291async fn redeem_connection_ticket(2292 State(state): State<Shared>,2293 Json(body): Json<RedeemTicketRequest>,2294) -> ApiResult<impl IntoResponse> {2295 if !valid_prefixed_token(&body.ticket, CONNECTION_TICKET_PREFIX) {2296 return Err(invalid_ticket());2297 }2298 let client_key = parse_endpoint_id(&body.client_endpoint_id)2299 .ok_or_else(|| ApiError::bad_request("client_endpoint_id is not a 32 byte hex key"))?;2300 let client_endpoint_id = data_encoding::HEXLOWER.encode(&client_key);2301 let now = now_secs();2302 let session_token = prefixed_token(TUNNEL_SESSION_PREFIX);2303 let expires_at = now + TUNNEL_SESSION_LIFETIME_SECS;23042305 let resource = {2306 let db = state.db.lock().unwrap();2307 let (viewer, resource_id) = db2308 .consume_connection_ticket(&auth::hash_token(&body.ticket), now)2309 .map_err(ApiError::internal)?2310 .ok_or_else(invalid_ticket)?;2311 let resource = db2312 .resource(resource_id)2313 .map_err(ApiError::internal)?2314 .ok_or_else(invalid_ticket)?;2315 let shared_with = db.shared_with(resource_id).map_err(ApiError::internal)?;2316 if resource.kind != ResourceKind::Service2317 || !can_view(2318 Some(viewer),2319 resource.owner_id,2320 resource.visibility,2321 &shared_with,2322 )2323 {2324 return Err(invalid_ticket());2325 }2326 db.create_tunnel_session(2327 &auth::hash_token(&session_token),2328 viewer,2329 resource_id,2330 &client_endpoint_id,2331 now,2332 expires_at,2333 )2334 .map_err(ApiError::internal)?;2335 resource2336 };23372338 Ok(Json(RedeemTicketResponse {2339 session_token,2340 relay_access: state2341 .relay_config2342 .access(&client_key)2343 .map_err(ApiError::internal)?,2344 resource_id: resource.id.to_string(),2345 name: resource.name,2346 expires_at,2347 }))2348}23492350/// Issue a fresh, short capability for one local TCP connection.2351///2352/// Denials are 404 rather than 403 throughout: a 403 would confirm that a resource id2353/// exists, which is enough to enumerate someone's private services.2354async fn tunnel_capability(2355 State(state): State<Shared>,2356 headers: HeaderMap,2357) -> ApiResult<impl IntoResponse> {2358 let token = bearer_token(&headers).ok_or_else(invalid_tunnel_session)?;2359 if !valid_prefixed_token(&token, TUNNEL_SESSION_PREFIX) {2360 return Err(invalid_tunnel_session());2361 }2362 let now = now_secs();23632364 let (session, resource, endpoint_id) = {2365 let db = state.db.lock().unwrap();2366 let session = db2367 .tunnel_session(&auth::hash_token(&token), now)2368 .map_err(ApiError::internal)?2369 .ok_or_else(invalid_tunnel_session)?;2370 let resource = db2371 .resource(session.resource_id)2372 .map_err(ApiError::internal)?2373 .ok_or_else(ApiError::not_found)?;2374 let shared_with = db2375 .shared_with(session.resource_id)2376 .map_err(ApiError::internal)?;23772378 if !can_view(2379 Some(session.viewer_id),2380 resource.owner_id,2381 resource.visibility,2382 &shared_with,2383 ) {2384 tracing::warn!(2385 viewer = %session.viewer_id,2386 resource = %session.resource_id,2387 "refused a capability request"2388 );2389 return Err(ApiError::not_found());2390 }2391 if resource.kind != ResourceKind::Service {2392 return Err(ApiError::bad_request(2393 "that resource is a link, not a service",2394 ));2395 }2396 let endpoint_id = db2397 .daemon_endpoint(resource.owner_id)2398 .map_err(ApiError::internal)?;2399 (session, resource, endpoint_id)2400 };2401 check_rate(&state, session.viewer_id, RateClass::Capability)?;2402 let client_key = parse_endpoint_id(&session.client_endpoint_id).ok_or_else(|| {2403 ApiError::internal(anyhow::anyhow!("stored client endpoint id is invalid"))2404 })?;24052406 // The only thing that can be answered from here. Whether the daemon is2407 // *running* is not knowable at this end without asking it, so the client discovers2408 // that when it connects. This case is the narrower one:2409 // no daemon has ever registered, so there is not even an address to try.2410 let endpoint_id = endpoint_id.ok_or_else(|| {2411 ApiError(2412 StatusCode::SERVICE_UNAVAILABLE,2413 "no daemon has been registered for that service — run `devsite daemon run`".into(),2414 )2415 })?;24162417 let audience = parse_endpoint_id(&endpoint_id).ok_or_else(|| {2418 ApiError::internal(anyhow::anyhow!("stored daemon endpoint id is invalid"))2419 })?;24202421 let capability = state2422 .issuer2423 .issue(session.viewer_id, resource.id, audience, client_key, now)2424 .map_err(ApiError::internal)?;24252426 Ok(Json(CapabilityResponse {2427 capability: data_encoding::BASE64URL_NOPAD.encode(&capability.to_bytes()),2428 daemon_endpoint_id: endpoint_id,2429 }))2430}24312432async fn tunnel_session_info(2433 State(state): State<Shared>,2434 headers: HeaderMap,2435) -> ApiResult<impl IntoResponse> {2436 let token = bearer_token(&headers).ok_or_else(invalid_tunnel_session)?;2437 if !valid_prefixed_token(&token, TUNNEL_SESSION_PREFIX) {2438 return Err(invalid_tunnel_session());2439 }2440 let now = now_secs();2441 let db = state.db.lock().unwrap();2442 let session = db2443 .tunnel_session(&auth::hash_token(&token), now)2444 .map_err(ApiError::internal)?2445 .ok_or_else(invalid_tunnel_session)?;2446 let resource = db2447 .resource(session.resource_id)2448 .map_err(ApiError::internal)?2449 .ok_or_else(invalid_tunnel_session)?;2450 let shared_with = db2451 .shared_with(session.resource_id)2452 .map_err(ApiError::internal)?;2453 if resource.kind != ResourceKind::Service2454 || !can_view(2455 Some(session.viewer_id),2456 resource.owner_id,2457 resource.visibility,2458 &shared_with,2459 )2460 {2461 return Err(invalid_tunnel_session());2462 }2463 let relay_access = state2464 .relay_config2465 .access(2466 &parse_endpoint_id(&session.client_endpoint_id).ok_or_else(|| {2467 ApiError::internal(anyhow::anyhow!("stored client endpoint id is invalid"))2468 })?,2469 )2470 .map_err(ApiError::internal)?;2471 Ok(Json(serde_json::json!({2472 "resource_id": resource.id.to_string(),2473 "name": resource.name,2474 "requester_endpoint_id": session.client_endpoint_id,2475 "relay_token": relay_access.relay_token,2476 "relay_urls": relay_access.relay_urls,2477 "expires_at": session.expires_at,2478 "brokered": session.issuer_credential_id.is_some(),2479 })))2480}24812482async fn delete_tunnel_session(2483 State(state): State<Shared>,2484 headers: HeaderMap,2485) -> ApiResult<impl IntoResponse> {2486 let token = bearer_token(&headers).ok_or_else(invalid_tunnel_session)?;2487 if !valid_prefixed_token(&token, TUNNEL_SESSION_PREFIX) {2488 return Err(invalid_tunnel_session());2489 }2490 // DELETE is idempotent. A share or resource revocation may already have2491 // cascaded the row away while the CLI was still running.2492 state2493 .db2494 .lock()2495 .unwrap()2496 .delete_tunnel_session(&auth::hash_token(&token))2497 .map_err(ApiError::internal)?;2498 Ok(StatusCode::NO_CONTENT)2499}25002501fn prefixed_token(prefix: &str) -> String {2502 format!("{prefix}{}", auth::generate_session_token())2503}25042505fn valid_prefixed_token(token: &str, prefix: &str) -> bool {2506 token.len() == prefix.len() + 432507 && token.starts_with(prefix)2508 && token[prefix.len()..]2509 .bytes()2510 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))2511}25122513fn invalid_ticket() -> ApiError {2514 ApiError(StatusCode::UNAUTHORIZED, "invalid or expired ticket".into())2515}25162517fn invalid_tunnel_session() -> ApiError {2518 ApiError(2519 StatusCode::UNAUTHORIZED,2520 "invalid or expired tunnel session".into(),2521 )2522}25232524/// Parse a hex iroh endpoint id into raw key bytes.2525fn parse_endpoint_id(text: &str) -> Option<KeyBytes> {2526 let raw = data_encoding::HEXLOWER_PERMISSIVE2527 .decode(text.trim().as_bytes())2528 .ok()?;2529 raw.try_into().ok()2530}253125322533mod tests {2534 use super::*;25352536 2537 fn html_fragments_escape_text_and_attributes() {2538 assert_eq!(escape_html("<&>\"'"), "<&>"'");2539 }25402541 2542 fn profile_fragments_include_layout_theme_and_safe_entries() {2543 let profile = ProfileResponse {2544 handle: "alice".into(),2545 entries: vec![ProfileEntry {2546 resource_id: "res_one".into(),2547 name: "docs <stable>".into(),2548 kind: "link",2549 visibility: "public",2550 url: Some("https://docs.example:8443/guide".into()),2551 folder: Some("Docs".into()),2552 owner_handle: None,2553 }],2554 shared_with_me: vec![ProfileEntry {2555 resource_id: "res_two".into(),2556 name: "database".into(),2557 kind: "service",2558 visibility: "shared",2559 url: None,2560 folder: Some("Services".into()),2561 owner_handle: Some("bob".into()),2562 }],2563 theme: vec![2564 theme::Declaration {2565 property: theme::SCHEME,2566 value: "dark".into(),2567 },2568 theme::Declaration {2569 property: theme::FOLDERS,2570 value: "closed".into(),2571 },2572 theme::Declaration {2573 property: theme::OPEN_FOLDERS,2574 value: "\"Services\"".into(),2575 },2576 theme::Declaration {2577 property: theme::FOLDER_ORDER,2578 value: "\"Services\", \"Docs\"".into(),2579 },2580 theme::Declaration {2581 property: "--pico-primary",2582 value: "rebeccapurple".into(),2583 },2584 ],2585 };25862587 let html = render_profile_main(&profile);2588 assert!(html.starts_with("<main class=\"container\" id=\"main\""));2589 assert!(html.contains("data-profile-scheme=\"dark\""));2590 assert!(html.contains("docs <stable>"));2591 assert!(html.contains("docs.example:8443"));2592 assert!(html.contains("data-folder=\"Services\" open"));2593 assert!(html.find("data-folder=\"Services\"") < html.find("data-folder=\"Docs\""));2594 assert!(html.contains("data-service-owner=\"bob\""));25952596 let css = render_profile_theme(&profile);2597 assert!(css.contains(":root[data-profile=\"alice\"]"));2598 assert!(css.contains("--pico-primary: rebeccapurple"));2599 assert!(!css.contains(theme::FOLDER_ORDER));2600 }26012602 2603 fn links_support_every_visibility_and_tcp_services_are_not_public() {2604 assert!(validate_resource_visibility(ResourceKind::Link, Visibility::Public).is_ok());2605 assert!(validate_resource_visibility(ResourceKind::Link, Visibility::Private).is_ok());2606 assert!(validate_resource_visibility(ResourceKind::Link, Visibility::Shared).is_ok());2607 assert!(validate_resource_visibility(ResourceKind::Service, Visibility::Private).is_ok());2608 assert!(validate_resource_visibility(ResourceKind::Service, Visibility::Shared).is_ok());2609 assert!(validate_resource_visibility(ResourceKind::Service, Visibility::Public).is_err());2610 }26112612 2613 fn parses_a_valid_endpoint_id() {2614 let hex = "89ef8f68b58d5dbfa8501010a0f2ea3afaf80952f04e6767fadbf6d16658e7a0";2615 assert!(parse_endpoint_id(hex).is_some());2616 assert!(parse_endpoint_id(&hex.to_uppercase()).is_some());2617 }26182619 2620 fn rejects_endpoint_ids_of_the_wrong_shape() {2621 for bad in [2622 "",2623 "abcd",2624 "zz",2625 "89ef8f68b58d5dbfa8501010a0f2ea3afaf80952f04e6767fadbf6d16658e7",2626 ] {2627 assert!(2628 parse_endpoint_id(bad).is_none(),2629 "{bad:?} should be rejected"2630 );2631 }2632 }26332634 2635 fn endpoint_proof_requires_the_matching_private_key() {2636 use ed25519_dalek::{Signer, SigningKey};26372638 let key = SigningKey::from_bytes(&[7; 32]);2639 let endpoint = key.verifying_key().to_bytes();2640 let endpoint_id = data_encoding::HEXLOWER.encode(&endpoint);2641 let signature = key.sign(&devsite_proto::machine_endpoint_proof_message(&endpoint));2642 let proof = data_encoding::BASE64URL_NOPAD.encode(&signature.to_bytes());2643 assert!(verify_endpoint_proof(&endpoint_id, &proof).is_ok());26442645 let attacker = SigningKey::from_bytes(&[8; 32]);2646 let forged = attacker.sign(&devsite_proto::machine_endpoint_proof_message(&endpoint));2647 let forged = data_encoding::BASE64URL_NOPAD.encode(&forged.to_bytes());2648 assert!(verify_endpoint_proof(&endpoint_id, &forged).is_err());2649 }26502651 2652 fn broker_grants_require_both_endpoint_signatures_and_matching_service_intent() {2653 use ed25519_dalek::{Signer, SigningKey};26542655 let mut db = Db::open_in_memory().unwrap();2656 let account = db.upsert_account("test", "broker", 0).unwrap();2657 db.set_handle(account.id, "broker").unwrap();2658 let resource = db2659 .create_resource(2660 account.id,2661 "postgres-primary",2662 ResourceKind::Service,2663 Visibility::Private,2664 None,2665 None,2666 0,2667 )2668 .unwrap();2669 let broker_key = SigningKey::from_bytes(&[11; 32]);2670 let requester_key = SigningKey::from_bytes(&[12; 32]);2671 let broker_endpoint = broker_key.verifying_key().to_bytes();2672 let requester_endpoint = requester_key.verifying_key().to_bytes();2673 let request_id = "agr_1234567890";2674 let request_expires = 500;2675 let grant_expires = 700;2676 let request_message = devsite_proto::service_grant_request_message(2677 request_id,2678 "postgres",2679 &requester_endpoint,2680 request_expires,2681 );2682 let issue_message = devsite_proto::service_grant_issue_message(2683 request_id,2684 &resource.to_string(),2685 &requester_endpoint,2686 grant_expires,2687 );2688 let request = ServiceGrantRequestEnvelope {2689 schema_version: 1,2690 request_id: request_id.into(),2691 service: "postgres".into(),2692 requester_endpoint_id: data_encoding::HEXLOWER.encode(&requester_endpoint),2693 expires_at: request_expires,2694 proof: data_encoding::BASE64URL_NOPAD2695 .encode(&requester_key.sign(&request_message).to_bytes()),2696 };2697 let machine = MachineAuthentication {2698 account,2699 credential_id: "machine_broker".into(),2700 endpoint_id: data_encoding::HEXLOWER.encode(&broker_endpoint),2701 scopes: vec![SERVICE_GRANT_SCOPE.into()],2702 };2703 let body = ServiceGrantIssueRequest {2704 request,2705 resource_id: resource.to_string(),2706 expires_at: grant_expires,2707 broker_proof: data_encoding::BASE64URL_NOPAD2708 .encode(&broker_key.sign(&issue_message).to_bytes()),2709 approved_plan: None,2710 };27112712 assert!(require_service_grant_scope(&machine).is_ok());2713 let (validated, _, plan_claims) =2714 validate_service_grant(&db, &machine, &body, 100).unwrap();2715 assert_eq!(validated.id, resource);2716 assert_eq!(plan_claims.issuer_credential_id, "machine_broker");2717 assert_eq!(plan_claims.request_id, request_id);2718 assert_eq!(plan_claims.resource_id, resource.to_string());2719 assert_eq!(plan_claims.grant_expires_at, grant_expires);27202721 let mut wrong_intent = body;2722 wrong_intent.request.service = "redis".into();2723 assert!(validate_service_grant(&db, &machine, &wrong_intent, 100).is_err());2724 }27252726 2727 fn bootstrap_and_session_tokens_have_distinct_bounded_shapes() {2728 let ticket = prefixed_token(CONNECTION_TICKET_PREFIX);2729 let session = prefixed_token(TUNNEL_SESSION_PREFIX);2730 assert!(valid_prefixed_token(&ticket, CONNECTION_TICKET_PREFIX));2731 assert!(valid_prefixed_token(&session, TUNNEL_SESSION_PREFIX));2732 assert!(!valid_prefixed_token(&ticket, TUNNEL_SESSION_PREFIX));2733 assert!(!valid_prefixed_token(2734 &format!("{ticket}extra"),2735 CONNECTION_TICKET_PREFIX2736 ));2737 assert!(!valid_prefixed_token(2738 "dst_not/base64url________________________________",2739 CONNECTION_TICKET_PREFIX2740 ));2741 }27422743 2744 fn blank_folder_names_are_the_same_as_none() {2745 // Otherwise a profile grows a fold with no label that nothing can remove,2746 // because the only way to remove a folder is to stop naming it.2747 for blank in [Some(""), Some(" "), Some("\t"), None] {2748 assert_eq!(validate_folder(blank).unwrap(), None, "{blank:?}");2749 }2750 assert_eq!(2751 validate_folder(Some(" Games ")).unwrap().as_deref(),2752 Some("Games")2753 );2754 }27552756 2757 fn folder_names_are_bounded_and_printable() {2758 assert!(validate_folder(Some(&"x".repeat(theme::MAX_FOLDER_NAME))).is_ok());2759 assert!(validate_folder(Some(&"x".repeat(theme::MAX_FOLDER_NAME + 1))).is_err());2760 assert!(validate_folder(Some("Games\u{0}")).is_err());2761 assert!(validate_folder(Some("two\nlines")).is_err());2762 }27632764 2765 fn resource_names_are_bounded_and_printable() {2766 assert_eq!(validate_resource_name("Agent").unwrap(), "Agent");2767 for bad in ["", " Agent", "Agent ", "two\nlines"] {2768 assert!(2769 validate_resource_name(bad).is_err(),2770 "{bad:?} should be rejected"2771 );2772 }2773 assert!(validate_resource_name(&"x".repeat(MAX_RESOURCE_NAME)).is_ok());2774 assert!(validate_resource_name(&"x".repeat(MAX_RESOURCE_NAME + 1)).is_err());2775 }27762777 2778 fn public_links_are_http_urls_without_embedded_credentials() {2779 assert_eq!(2780 validate_public_url("https://example.com/path").unwrap(),2781 "https://example.com/path"2782 );2783 for bad in [2784 "javascript:alert(1)",2785 "data:text/html,hi",2786 "/relative",2787 "https://user:secret@example.com/",2788 "not a url",2789 ] {2790 assert!(2791 validate_public_url(bad).is_err(),2792 "{bad:?} should be rejected"2793 );2794 }2795 }27962797 2798 fn rate_limits_reset_after_the_window() {2799 let account = AccountId::from_bytes([9; 16]);2800 let mut limits = RateLimits::default();2801 for _ in 0..RateClass::Session.limit() {2802 assert!(limits.check(account, RateClass::Session, 100));2803 }2804 assert!(!limits.check(account, RateClass::Session, 100));2805 assert!(limits.check(account, RateClass::Session, 160));2806 }28072808 2809 fn reads_the_session_cookie_among_others() {2810 let mut headers = HeaderMap::new();2811 headers.insert(2812 header::COOKIE,2813 "theme=dark; devsite_session=abc123; other=x"2814 .parse()2815 .unwrap(),2816 );2817 assert_eq!(cookie_token(&headers).as_deref(), Some("abc123"));2818 }28192820 2821 fn prefers_an_explicit_bearer_token() {2822 let mut headers = HeaderMap::new();2823 headers.insert(header::AUTHORIZATION, "Bearer cli-token".parse().unwrap());2824 assert_eq!(bearer_token(&headers).as_deref(), Some("cli-token"));2825 }28262827 2828 fn planning_a_resource_validates_without_writing() {2829 let db = Db::open(":memory:").unwrap();2830 let alice = db.upsert_account("test", "alice", 1).unwrap();2831 let body = CreateResourceRequest {2832 name: "docs".into(),2833 kind: "link".into(),2834 visibility: "public".into(),2835 url: Some("https://example.com/docs".into()),2836 share_with: Vec::new(),2837 folder: Some("Projects".into()),2838 };28392840 let prepared = prepare_resource(&db, alice.id, &body).unwrap();28412842 assert_eq!(prepared.plan.operation, "create");2843 assert_eq!(db.resource_count(alice.id).unwrap(), 0);2844 assert_eq!(prepared.plan.target.resource_id, None);2845 }28462847 2848 fn changing_a_shared_link_destination_plans_reapproval() {2849 let mut db = Db::open(":memory:").unwrap();2850 let alice = db.upsert_account("test", "alice", 1).unwrap();2851 let bob = db.upsert_account("test", "bob", 1).unwrap();2852 db.set_handle(bob.id, "bob").unwrap();2853 let resource = db2854 .create_resource(2855 alice.id,2856 "staging",2857 ResourceKind::Link,2858 Visibility::Shared,2859 Some("https://old.example.com/"),2860 None,2861 1,2862 )2863 .unwrap();2864 db.set_shares(resource, &[bob.id]).unwrap();2865 assert!(db.accept_share(bob.id, resource).unwrap());2866 let body = CreateResourceRequest {2867 name: "staging".into(),2868 kind: "link".into(),2869 visibility: "shared".into(),2870 url: Some("https://new.example.com/".into()),2871 share_with: vec!["bob".into()],2872 folder: None,2873 };28742875 let prepared = prepare_resource(&db, alice.id, &body).unwrap();28762877 assert_eq!(prepared.plan.operation, "update");2878 assert!(prepared.plan.recipient_changes.iter().any(|change| {2879 change.handle == "bob"2880 && change.from == Some("accepted")2881 && change.to == Some("pending")2882 && change.reason == "destination_changed"2883 }));2884 assert!(prepared.plan.effects.iter().any(|effect| {2885 effect.code == "recipient_reapproval_required" && effect.handles == ["bob"]2886 }));2887 assert_eq!(2888 db.share_recipients(resource).unwrap()[0].status,2889 ShareStatus::Accepted,2890 "planning must not mutate recipient state"2891 );2892 }2893}