From 1ffd3b96d14f3f3d48d1a17aa1d0b8cb5ce6fe4a Mon Sep 17 00:00:00 2001 From: dawn Date: Tue, 22 Sep 2026 21:01:51 +0300 Subject: [PATCH] sites: deploy trigger outcomes, write-once ownership, well-known error split Signed-off-by: dawn --- lexicons/temp/repo/updateSiteConfig.json | 3 +- sites/src/lib.rs | 21 ++-- sites/src/sites.rs | 11 +- sites/src/xrpc.rs | 124 ++++++++++++++++------- 4 files changed, 110 insertions(+), 49 deletions(-) diff --git a/lexicons/temp/repo/updateSiteConfig.json b/lexicons/temp/repo/updateSiteConfig.json index 635f5332d..cc5d12d7b 100644 --- a/lexicons/temp/repo/updateSiteConfig.json +++ b/lexicons/temp/repo/updateSiteConfig.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Create or update the site configuration for a repository owned by the authenticated user. Writing the config does not deploy; deploys run on a manual org.tangled.temp.repo.deploySite call or on a push to the configured branch.", + "description": "Create or update the site configuration for a repository owned by the authenticated user. Writing the config queues a deploy of the site; a manual org.tangled.temp.repo.deploySite call or a push to the configured branch also deploys.", "input": { "encoding": "application/json", "schema": { @@ -44,6 +44,7 @@ } }, "errors": [ + { "name": "NotOwner", "description": "The repository's site configuration belongs to a different account." }, { "name": "NoDomainClaim", "description": "The account does not have an active domain claim." }, { "name": "InvalidDirectory", "description": "The deploy directory path is invalid." } ] diff --git a/sites/src/lib.rs b/sites/src/lib.rs index 225a97574..25c366f6f 100644 --- a/sites/src/lib.rs +++ b/sites/src/lib.rs @@ -68,11 +68,6 @@ fn response_from_object(obj: Object) -> Result { Ok(resp) } -fn is_excluded(path: &str) -> bool { - let excluded = ["/.well-known/atproto-did"]; - excluded.iter().any(|&prefix| path.starts_with(prefix)) -} - #[event(fetch)] async fn fetch(req: Request, env: Env, ctx: Context) -> Result { let origin = req.headers().get("Origin")?.filter(|o| !o.is_empty()); @@ -121,15 +116,17 @@ async fn fetch_inner(req: Request, env: Env, ctx: Context) -> Result { return Response::error("Bad Request: missing host", 400); } - if is_excluded(path) { - // a claimed domain publishes its owner's did at this path; site content - // is never served here, so repos cannot spoof handle verification + if path == "/.well-known/atproto-did" { + // from the domain claim, never the site let store = SitesStore::d1(env.d1("SITES_DB")?); - let did = match store.select_claim_by_domain(&host).await { - Ok(Some(claim)) if claim.deleted.is_none() => claim.did, - _ => return Response::error("Not Found", 404), + return match store.select_claim_by_domain(&host).await { + Ok(Some(claim)) if claim.deleted.is_none() => Response::ok(claim.did), + Ok(_) => Response::error("Not Found", 404), + Err(e) => { + console_warn!("atproto-did claim lookup for {} failed: {}", host, e); + Response::error("Internal Error", 500) + } }; - return Response::ok(did); } if needs_trailing_slash(path) { diff --git a/sites/src/sites.rs b/sites/src/sites.rs index 11b3a9c28..893bb3f33 100644 --- a/sites/src/sites.rs +++ b/sites/src/sites.rs @@ -66,6 +66,9 @@ struct SiteConfigRowD1 { is_index: i64, } +/// caller-attested: the did comes from the serviceauth token, not the knot's repo record. +pub const ERR_NOT_OWNER: &str = "site config belongs to another owner"; + impl SitesStore { pub async fn get_repo(&self, repo_did: &str) -> Result, String> { let db = self.d1_db()?; @@ -122,13 +125,19 @@ impl SitesStore { is_index: bool, ) -> Result<(), String> { let db = self.d1_db()?; + if let Some(existing) = self.get_repo(repo_did).await? { + if existing.owner_did != owner_did { + return Err(ERR_NOT_OWNER.to_string()); + } + } let now = utc_now_rfc3339(); let is_index_int = if is_index { 1 } else { 0 }; let mut stmts = vec![db .prepare( + // owner_did is written once "INSERT INTO site_repos (repo_did, owner_did, name, rkey, knot) VALUES (?, ?, ?, ?, ?) \ - ON CONFLICT(repo_did) DO UPDATE SET owner_did = excluded.owner_did, name = excluded.name, \ + ON CONFLICT(repo_did) DO UPDATE SET name = excluded.name, \ rkey = excluded.rkey, knot = excluded.knot", ) .bind_refs(&[ diff --git a/sites/src/xrpc.rs b/sites/src/xrpc.rs index 11bd2c9fd..3f42d1d01 100644 --- a/sites/src/xrpc.rs +++ b/sites/src/xrpc.rs @@ -11,7 +11,7 @@ use crate::sites; fn render_error(e: XrpcError) -> Result { - if e.status >= 500 { + if e.name == "Internal" { console_warn!("xrpc {} error: {}", e.status, e.message); return Response::from_json(&json!({ "error": "Internal", "message": "Internal error" })) .map(|r| r.with_status(500)); @@ -121,7 +121,7 @@ struct UpdateSiteConfigBody { } #[derive(Deserialize)] -struct DisableSiteBody { +struct RepoDidBody { #[serde(rename = "repoDid")] repo_did: String, } @@ -313,7 +313,17 @@ async fn handle_update_site_config( body.is_index, ) .await - .map_err(|e| internal(e))?; + .map_err(|e| { + if e == sites::ERR_NOT_OWNER { + XrpcError::new( + "NotOwner", + "This repository's site config belongs to another account.", + 403, + ) + } else { + internal(e) + } + })?; let kv = env .kv("SITES") @@ -335,45 +345,88 @@ async fn handle_update_site_config( ))); } - if let Some(aud) = env + let _ = trigger_deploy(env, ctx, body.repo_did); + + ok_json(json!({})) +} + +/// queued = the fetch reached the deploy service; the deploy runs detached (ctx.wait_until) +enum DeployTrigger { + Queued, + Disabled, +} + +fn trigger_deploy(env: &Env, ctx: &Context, repo_did: String) -> DeployTrigger { + let Some(aud) = env .var("SITES_SD_URL") .ok() .map(|v| v.to_string()) .filter(|v| !v.is_empty()) - { - let url = format!("{}/deploy", aud.trim_end_matches('/')); - let repo_did = body.repo_did.clone(); - ctx.wait_until(async move { - let payload = json!({ "repoDid": repo_did }).to_string(); - let mut headers = Headers::new(); - if headers.set("content-type", "application/json").is_err() { - return; + else { + console_warn!("deploy triggers are disabled: SITES_SD_URL is not set"); + return DeployTrigger::Disabled; + }; + let url = format!("{}/deploy", aud.trim_end_matches('/')); + ctx.wait_until(async move { + let payload = json!({ "repoDid": repo_did }).to_string(); + let mut headers = Headers::new(); + if headers.set("content-type", "application/json").is_err() { + return; + } + let mut init = RequestInit::new(); + init.with_method(Method::Post) + .with_headers(headers) + .with_body(Some(wasm_bindgen::JsValue::from_str(&payload))); + let Ok(request) = Request::new_with_init(&url, &init) else { + return; + }; + match Fetch::Request(request).send().await { + Ok(res) if res.status_code() >= 300 => { + console_warn!("deploy trigger rejected: HTTP {}", res.status_code()); } - let mut init = RequestInit::new(); - init.with_method(Method::Post) - .with_headers(headers) - .with_body(Some(wasm_bindgen::JsValue::from_str(&payload))); - let Ok(request) = Request::new_with_init(&url, &init) else { - return; - }; - match Fetch::Request(request).send().await { - Ok(res) if res.status_code() >= 300 => { - console_warn!( - "updateSiteConfig: deploy trigger rejected: HTTP {}", - res.status_code() - ); - } - Ok(_) => {} - Err(e) => { - console_warn!("updateSiteConfig: deploy trigger failed: {}", e); - } + Ok(_) => {} + Err(e) => { + console_warn!("deploy trigger failed: {}", e); } - }); - } else { - console_warn!("updateSiteConfig: SITES_SD_URL is not set; config-change deploys are disabled"); + } + }); + DeployTrigger::Queued +} + +async fn handle_deploy_site(mut req: Request, env: &Env, ctx: &Context) -> Result { + let did = serviceauth::verify(&req, env, "org.tangled.temp.repo.deploySite").await?; + let body: RepoDidBody = req + .json() + .await + .map_err(|_| XrpcError::new("InvalidRequest", "request body must be JSON", 400))?; + + let store = store(env)?; + match store.deploy_context(&body.repo_did).await.map_err(internal)? { + Some(dc) if dc.owner_did == did => {} + Some(_) => { + return Err(XrpcError::new( + "NotOwner", + "only the site's owner can redeploy it", + 403, + )) + } + None => { + return Err(XrpcError::new( + "SiteNotFound", + "no site configuration for this repository", + 404, + )) + } } - ok_json(json!({})) + match trigger_deploy(env, ctx, body.repo_did) { + DeployTrigger::Queued => ok_json(json!({})), + DeployTrigger::Disabled => Err(XrpcError::new( + "WorkerUnavailable", + "site deploys are disabled on this deployment", + 503, + )), + } } async fn handle_get_site_config(req: Request, env: &Env) -> Result { @@ -414,7 +467,7 @@ async fn handle_get_site_config(req: Request, env: &Env) -> Result Result { let did = serviceauth::verify(&req, env, "org.tangled.temp.repo.disableSite").await?; - let body: DisableSiteBody = req + let body: RepoDidBody = req .json() .await .map_err(|_| XrpcError::new("InvalidRequest", "request body must be JSON", 400))?; @@ -586,6 +639,7 @@ pub async fn route(req: Request, env: Env, ctx: Context) -> Result { } "org.tangled.temp.repo.updateSiteConfig" => handle_update_site_config(req, &env, &ctx).await, "org.tangled.temp.repo.getSiteConfig" => handle_get_site_config(req, &env).await, + "org.tangled.temp.repo.deploySite" => handle_deploy_site(req, &env, &ctx).await, "org.tangled.temp.repo.disableSite" => handle_disable_site(req, &env).await, "org.tangled.temp.repo.getDeployContext" => handle_get_deploy_context(req, &env).await, "org.tangled.temp.repo.recordDeploy" => handle_record_deploy(req, &env).await, -- 2.51.2