From 3bf7dbeb68ca1ce8fb23c8ddb072f7c5467a56f4 Mon Sep 17 00:00:00 2001 From: Milos Colakovic Date: Fri, 2 Oct 2026 12:09:37 +0200 Subject: [PATCH] feat(db): mint tunnel token via product-prefixed app id, add --product Move the db-tunnel agent-token mint off the Node.js-specific `/v1/hosting/nodejs/apps/{id}/agent-token` path onto the shared `/v1/hosting/apps/{productPrefix}-{id}/agent-token` path (e.g. `NODEJS-{id}`), encoding the product as a prefix on the app id rather than a `/nodejs` path segment. This matches the hosting app-collection convention (the origin strips the prefix back to the raw nanoid) and leaves one path for future products. - `db tunnel` gains a required `--product` flag (reuses `HostingAppType`, case-insensitive). Its wire value from `as_str()` (`NODEJS`) becomes the app id prefix, so `--product nodejs --app-id abc` mints at `/v1/hosting/apps/NODEJS-abc/agent-token`. The agent WebSocket route keeps the raw app id (internal services use nanoids). - `HostingClient::get_agent_token` takes the app type and builds the prefixed path. Verified: cargo check / clippy -D warnings / test --workspace, fmt --check, and the module-size check all pass. Co-Authored-By: Claude Opus 4.8 --- rust/src/db/tunnel.rs | 32 +++++++++++++++++++++++++++++--- rust/src/hosting/client.rs | 16 +++++++++++----- rust/src/hosting/client_tests.rs | 4 ++-- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/rust/src/db/tunnel.rs b/rust/src/db/tunnel.rs index 191afee4..15550d37 100644 --- a/rust/src/db/tunnel.rs +++ b/rust/src/db/tunnel.rs @@ -9,7 +9,7 @@ //! matching `platform app deploy`. //! //! Auth: the CLI mints a short-lived agent token from the hosting API -//! (`POST /v1/hosting/nodejs/apps/:id/agent-token`) using your GoDaddy OAuth +//! (`POST /v1/hosting/apps/NODEJS-:id/agent-token`) using your GoDaddy OAuth //! credential, stepped up to the dedicated `hosting.database.tunnel:execute` //! scope alongside deploy-execute — the tunnel scope is a separate grant, so //! authority to publish a deployment does not by itself grant raw database @@ -36,6 +36,7 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async_with_conf use crate::error::GddyError; use crate::hosting::client::HostingClient; +use crate::hosting::common::HostingAppType; use crate::http::api_url_for_env; use crate::scopes::HOSTING_DATABASE_TUNNEL_EXECUTE as DATABASE_TUNNEL; use crate::scopes::HOSTING_DEPLOYMENT_EXECUTE as DEPLOY_EXECUTE; @@ -66,6 +67,11 @@ struct TunnelArgs { #[arg(long = "app-id", value_name = "APP_ID")] app_id: String, + /// Hosting product of the app (e.g. `nodejs`). Selects which product's + /// agent mints the tunnel token. + #[arg(long = "product", value_name = "PRODUCT", ignore_case = true)] + product: HostingAppType, + /// Local TCP port MySQL clients connect to. #[arg(long, value_name = "PORT", default_value_t = 3306)] port: u16, @@ -177,7 +183,7 @@ async fn run_tunnel( sender .send(json!({ "type": "step", "name": "authorize", "status": "started" })) .await; - let (agent_url, token) = match mint_agent_token(ctx, &args.app_id).await { + let (agent_url, token) = match mint_agent_token(ctx, &args.app_id, args.product).await { Ok(pair) => pair, Err(e) => return Err(fail(sender, e).await), }; @@ -305,13 +311,14 @@ async fn run_tunnel( async fn mint_agent_token( ctx: &CommandContext, app_id: &str, + product: HostingAppType, ) -> cli_engine::Result<(String, String)> { let required = vec![DEPLOY_EXECUTE.to_owned(), DATABASE_TUNNEL.to_owned()]; let token = ctx.credential_with_scopes(&required).await?.token; let base_url = api_url_for_env(&ctx.middleware.env)?; let client = HostingClient::new(base_url, token); let resp = client - .get_agent_token(app_id) + .get_agent_token(app_id, product.as_str()) .await .map_err(|e| GddyError::from(e).into_cli_error())?; let agent_url = field_str(&resp, "agentUrl")?; @@ -598,6 +605,25 @@ fn map_ws_err(err: tokio_tungstenite::tungstenite::Error) -> GddyError { mod tests { use super::build_tunnel_ws_url; + #[test] + fn product_flag_parses_case_insensitively_to_wire_value() { + use crate::hosting::common::HostingAppType; + use clap::Parser; + + // Wrap the flattened args so clap can parse them standalone in a test. + #[derive(Parser)] + struct Wrap { + #[command(flatten)] + args: super::TunnelArgs, + } + + let wrap = Wrap::try_parse_from(["db-tunnel", "--app-id", "app-1", "--product", "nodejs"]) + .expect("--product nodejs should parse"); + assert_eq!(wrap.args.product, HostingAppType::Nodejs); + // The lowercase CLI value maps to the uppercase wire value sent as `?appType=`. + assert_eq!(wrap.args.product.as_str(), "NODEJS"); + } + #[test] fn rejects_plaintext_schemes() { // The agent URL comes from the service, never the operator, so a diff --git a/rust/src/hosting/client.rs b/rust/src/hosting/client.rs index 29df9644..89e75e65 100644 --- a/rust/src/hosting/client.rs +++ b/rust/src/hosting/client.rs @@ -290,13 +290,19 @@ impl HostingClient { /// `hosting.database.tunnel:execute` scope in addition to `hosting.deployment:execute`, /// so publish authority alone does not yield a database-tunnel agent token. /// - /// This mint lives under the Node.js-specific `/v1/hosting/nodejs` path - /// rather than the flattened `/v1/hosting` base the other methods use, so it - /// is spelled out with a leading `/nodejs` segment to reach the server route. - pub async fn get_agent_token(&self, app_id: &str) -> Result { + /// The product rides as a prefix on the app id — + /// `/v1/hosting/apps/{app_type}-{id}/agent-token` (e.g. `NODEJS-{id}`) — rather + /// than a product path segment, matching how the hosting app collection is + /// addressed; the origin strips the prefix back to the raw nanoid. `app_type` + /// is the wire value from `HostingAppType::as_str` (e.g. `NODEJS`). + pub async fn get_agent_token( + &self, + app_id: &str, + app_type: &str, + ) -> Result { // Secret response: the body is a minted bearer token, so it must never // reach the `--debug transport` trace (cli-engine would print it in full). - self.post_empty_json_secret_response(&format!("/nodejs/apps/{app_id}/agent-token")) + self.post_empty_json_secret_response(&format!("/apps/{app_type}-{app_id}/agent-token")) .await } diff --git a/rust/src/hosting/client_tests.rs b/rust/src/hosting/client_tests.rs index 138d5ace..c52164b2 100644 --- a/rust/src/hosting/client_tests.rs +++ b/rust/src/hosting/client_tests.rs @@ -538,7 +538,7 @@ async fn get_agent_token_posts_empty_body_and_returns_url_and_token() { let mock = server .mock_async(|when, then| { when.method(POST) - .path("/v1/hosting/nodejs/apps/app-1/agent-token") + .path("/v1/hosting/apps/NODEJS-app-1/agent-token") .header("authorization", "Bearer test-token") .json_body(json!({})); then.status(200).json_body(json!({ @@ -549,7 +549,7 @@ async fn get_agent_token_posts_empty_body_and_returns_url_and_token() { .await; let body = client(&server.base_url()) - .get_agent_token("app-1") + .get_agent_token("app-1", "NODEJS") .await .expect("get agent token");