From 9afa948f5b43b6f4b1d172cf7026bc1a64f0b137 Mon Sep 17 00:00:00 2001 From: Ryuz Date: Sun, 25 Oct 2026 13:30:39 +0100 Subject: [PATCH 1/4] Collaborators (Pro): MCP tools, console card, role scoping Domain (crates/outer/src/collaborators.rs): add/remove/list/role for app_collaborator, Pro-owner gate, 10/app cap, closed-beta allowlisting, spec-worded invite email, userId linking on invite + at sign-in. Permissions: visible_app/owner_app across MCP tools; collaborators may use reads, commit, rollback, share, list_secrets; owner-only stays owner-only (forbidden). Commits charge the owner's quota. Action log records collaborator rows; NULL/NULL credentials read 'Console'. MCP: add_collaborator/remove_collaborator/list_collaborators in registry (hints classified) + regenerated web/docs/reference/tools.md. Console: GET/POST /api/console/apps/{slug}/collaborators{,/remove}, role-aware reads (apps, app, actions, metrics, logs, errors, secrets), Settings collaborators card with invite/remove + Pro treatment, Shared rail/sidebar markers, actions/collaborators.ts registry. --- crates/outer/src/auth/email.rs | 128 +++--- crates/outer/src/auth/mod.rs | 13 +- crates/outer/src/collaborators.rs | 243 +++++++++++ crates/outer/src/http/console.rs | 371 +++++++++++++++-- crates/outer/src/http/console_actions.rs | 57 ++- crates/outer/src/http/console_code.rs | 56 ++- .../outer/src/http/console_collaborators.rs | 284 +++++++++++++ crates/outer/src/http/console_data/d1.rs | 227 +++++----- crates/outer/src/http/console_observe.rs | 49 ++- crates/outer/src/http/mod.rs | 1 + crates/outer/src/lib.rs | 1 + crates/outer/src/mcp/actions.rs | 20 +- crates/outer/src/mcp/error.rs | 3 + crates/outer/src/mcp/registry.rs | 36 +- crates/outer/src/mcp/tests.rs | 394 +++++++++++++++++- crates/outer/src/mcp/tools/apps.rs | 44 +- crates/outer/src/mcp/tools/code.rs | 29 +- crates/outer/src/mcp/tools/collaborators.rs | 150 +++++++ crates/outer/src/mcp/tools/mod.rs | 54 ++- crates/outer/src/mcp/tools/runtime.rs | 13 +- crates/outer/src/mcp/tools/secrets.rs | 16 +- web/docs/reference/tools.md | 30 ++ .../console/_islands/actions/collaborators.ts | 93 +++++ web/pages/console/_islands/api.ts | 13 + web/pages/console/_islands/console.tsx | 48 ++- web/pages/console/_islands/icons.tsx | 5 + web/pages/console/_islands/views.tsx | 254 ++++++++++- 27 files changed, 2343 insertions(+), 289 deletions(-) create mode 100644 crates/outer/src/collaborators.rs create mode 100644 crates/outer/src/http/console_collaborators.rs create mode 100644 crates/outer/src/mcp/tools/collaborators.rs create mode 100644 web/pages/console/_islands/actions/collaborators.ts diff --git a/crates/outer/src/auth/email.rs b/crates/outer/src/auth/email.rs index 318a73e..466ea57 100644 --- a/crates/outer/src/auth/email.rs +++ b/crates/outer/src/auth/email.rs @@ -137,59 +137,16 @@ impl EmailSender { } /// Send one sign-in code. The code itself goes in the body; failures - /// name the reason, never the token. + /// name the reason, never the token. The test sender captures the bare + /// code (existing sign-in tests verify with it), while [`send_text`] + /// captures subject plus body. pub async fn send_code(&self, to: &str, code: &str) -> Result<(), EmailError> { + let subject = format!("Your Outer sign-in code is {code}"); + let text = format!( + "Your Outer sign-in code is {code}. It expires in 10 minutes.\n\nIf you didn't ask for this, ignore this email." + ); match self { - Self::Cloudflare { - client, - api_base, - account_id, - api_token, - from, - } => { - let url = format!("{api_base}/client/v4/accounts/{account_id}/email/sending/send"); - let subject = format!("Your Outer sign-in code is {code}"); - let text = format!( - "Your Outer sign-in code is {code}. It expires in 10 minutes.\n\nIf you didn't ask for this, ignore this email." - ); - let response = client - .post(&url) - .bearer_auth(api_token) - .json(&serde_json::json!({ - "to": to, - "from": { "address": from.address, "name": from.name }, - "subject": subject, - "text": text, - })) - .send() - .await - .map_err(|err| EmailError::Retryable(scrubbed(&err.to_string())))?; - let status = response.status(); - let body: serde_json::Value = response.json().await.map_err(|err| { - EmailError::Retryable(format!("email API returned an unreadable body: {err}")) - })?; - if status == reqwest::StatusCode::TOO_MANY_REQUESTS - || error_code(&body) == Some(10004) - { - return Err(EmailError::Retryable( - "email provider is rate limited, try again shortly".to_string(), - )); - } - if !status.is_success() - || body.get("success").and_then(|v| v.as_bool()) != Some(true) - { - let detail = first_error(&body).unwrap_or_else(|| status.to_string()); - return Err(EmailError::Permanent(format!( - "email provider refused the message ({detail})" - ))); - } - if permanent_bounce(&body, to) { - return Err(EmailError::Permanent(format!( - "email to {to} permanently bounced" - ))); - } - Ok(()) - } + Self::Cloudflare { .. } => self.post(to, &subject, &text).await, Self::Log => { tracing::info!(email = %to, code = %code, "sign-in code (localhost log sender)"); Ok(()) @@ -204,6 +161,75 @@ impl EmailSender { } } } + + /// Send one free-form message (collaborator invites). Same transport, + /// same failure contract as [`send_code`]. + pub async fn send_text(&self, to: &str, subject: &str, text: &str) -> Result<(), EmailError> { + match self { + Self::Cloudflare { .. } => self.post(to, subject, text).await, + Self::Log => { + tracing::info!(email = %to, subject = %subject, "outbound email (localhost log sender)"); + Ok(()) + } + #[cfg(test)] + Self::Capture { codes } => { + codes + .lock() + .unwrap() + .insert(to.to_string(), format!("{subject}\n\n{text}")); + Ok(()) + } + } + } + + /// The Cloudflare Email Sending POST behind [`send_code`] and + /// [`send_text`]. + async fn post(&self, to: &str, subject: &str, text: &str) -> Result<(), EmailError> { + let Self::Cloudflare { + client, + api_base, + account_id, + api_token, + from, + } = self + else { + unreachable!("post is only called on the Cloudflare sender"); + }; + let url = format!("{api_base}/client/v4/accounts/{account_id}/email/sending/send"); + let response = client + .post(&url) + .bearer_auth(api_token) + .json(&serde_json::json!({ + "to": to, + "from": { "address": from.address, "name": from.name }, + "subject": subject, + "text": text, + })) + .send() + .await + .map_err(|err| EmailError::Retryable(scrubbed(&err.to_string())))?; + let status = response.status(); + let body: serde_json::Value = response.json().await.map_err(|err| { + EmailError::Retryable(format!("email API returned an unreadable body: {err}")) + })?; + if status == reqwest::StatusCode::TOO_MANY_REQUESTS || error_code(&body) == Some(10004) { + return Err(EmailError::Retryable( + "email provider is rate limited, try again shortly".to_string(), + )); + } + if !status.is_success() || body.get("success").and_then(|v| v.as_bool()) != Some(true) { + let detail = first_error(&body).unwrap_or_else(|| status.to_string()); + return Err(EmailError::Permanent(format!( + "email provider refused the message ({detail})" + ))); + } + if permanent_bounce(&body, to) { + return Err(EmailError::Permanent(format!( + "email to {to} permanently bounced" + ))); + } + Ok(()) + } } /// The recipient counts as permanently bounced when it appears in diff --git a/crates/outer/src/auth/mod.rs b/crates/outer/src/auth/mod.rs index cd80ce3..4d95af0 100644 --- a/crates/outer/src/auth/mod.rs +++ b/crates/outer/src/auth/mod.rs @@ -158,7 +158,7 @@ pub fn normalize_email(email: &str) -> String { email.trim().to_ascii_lowercase() } -fn valid_email(email: &str) -> bool { +pub(crate) fn valid_email(email: &str) -> bool { let parts = email.split('@').collect::>(); parts.len() == 2 && !parts[0].is_empty() && parts[1].contains('.') && !parts[1].is_empty() } @@ -493,6 +493,17 @@ pub async fn verify_code_core( }); } } + // A collaborator invite for this address links here: the row's + // userId names the new account (spec §7.4). Invites to addresses + // that already had accounts linked in `collaborators::add`. + sqlx::query( + "UPDATE app_collaborator SET userId = ?1 WHERE email = ?2 AND userId IS NULL", + ) + .bind(&id) + .bind(&email) + .execute(&mut *tx) + .await + .map_err(db)?; // Only people who joined with an invite get codes to pass on // (owner, 2026-10-24): not admins, not allowlisted emails, and // not app collaborators, who are admitted through the allowlist. diff --git a/crates/outer/src/collaborators.rs b/crates/outer/src/collaborators.rs new file mode 100644 index 0000000..0e661a1 --- /dev/null +++ b/crates/outer/src/collaborators.rs @@ -0,0 +1,243 @@ +//! Collaborators (Pro, spec §7.4): up to 10 extra emails per app. +//! +//! The owner (the account that created the app) invites by email. The invite +//! allowlists the email during the closed beta and emails it the spec +//! wording; the row's `userId` links when that email signs in (or is already +//! an account). Removal revokes access immediately but keeps nothing else: +//! the row is gone, so the next call answers `not_found` again. +//! +//! A collaborator's access lives only while the owner's plan is Pro: on +//! downgrade the rows stay (and come back on upgrade) but grant nothing +//! (spec §7.4). Everything a collaborator does counts against the owner's +//! plan (spec §3), so quota checks take the owner's id. + +use crate::apps::App; +use crate::auth::{normalize_email, EmailSender}; + +/// Collaborators per app (spec §3): Pro owners may invite this many. +pub const MAX_COLLABORATORS: u64 = 10; + +/// What the caller may do with an app. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + Owner, + Collaborator, +} +/// Why `add` refused. Callers map these to the spec §8.3 codes; nothing +/// here carries a server path or token. +#[derive(Debug, thiserror::Error)] +pub enum AddError { + /// The address is blank or has no usable shape. + #[error("email {0:?} is not a usable address")] + InvalidEmail(String), + /// The owner invited themselves. + #[error("that address already owns the app")] + SelfInvite, + /// Only Pro owners may invite (spec §7.4). + #[error("collaborators are a Pro feature")] + NotPro, + /// The app already has [`MAX_COLLABORATORS`] collaborators. + #[error("this app already has {MAX_COLLABORATORS} collaborators")] + LimitReached, + /// The address is already on the list (joined or pending). + #[error("that address is already a collaborator")] + Duplicate, + /// The database refused the insert or lookup. + #[error("collaborator store failed: {0}")] + Internal(#[from] anyhow::Error), +} + +/// One invite row: `joined` is true once the email has signed in and linked. +#[derive(Debug, Clone)] +pub struct Collaborator { + pub email: String, + pub joined: bool, + pub added_at: i64, +} + +/// The caller's [`Role`] on the app, or `None` when the slug should read as +/// missing (spec §8.2: a slug the caller has no role on answers `not_found`, +/// exactly like a missing one). Collaborators only count while the owner's +/// plan is Pro (downgrade revokes access, keeps the rows). +pub async fn role( + pool: &sqlx::SqlitePool, + app_id: &str, + owner_id: &str, + account_id: &str, + account_email: &str, +) -> anyhow::Result> { + if owner_id == account_id { + return Ok(Some(Role::Owner)); + } + let email = normalize_email(account_email); + let row: Option<(Option,)> = sqlx::query_as( + "SELECT userId FROM app_collaborator WHERE app_id = ?1 AND (userId = ?2 OR email = ?3)", + ) + .bind(app_id) + .bind(account_id) + .bind(&email) + .fetch_optional(pool) + .await?; + let Some(_) = row else { + return Ok(None); + }; + if owner_plan(pool, owner_id).await? == "pro" { + Ok(Some(Role::Collaborator)) + } else { + Ok(None) + } +} + +/// The app's collaborators, oldest first. +pub async fn list(pool: &sqlx::SqlitePool, app_id: &str) -> anyhow::Result> { + #[derive(sqlx::FromRow)] + struct Row { + email: String, + #[sqlx(rename = "userId")] + user_id: Option, + added_at: i64, + } + let rows: Vec = sqlx::query_as( + "SELECT email, userId, added_at FROM app_collaborator \ + WHERE app_id = ?1 ORDER BY added_at ASC", + ) + .bind(app_id) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|row| Collaborator { + email: row.email, + joined: row.user_id.is_some(), + added_at: row.added_at, + }) + .collect()) +} + +/// Invite `raw_email` to `app`: Pro-owner check, the cap, the insert (linking +/// `userId` when the address already has an account), the closed-beta +/// allowlist, and the spec-worded email. Returns the stored row. +pub async fn add( + pool: &sqlx::SqlitePool, + sender: &EmailSender, + mcp_url: &str, + app: &App, + owner_email: &str, + raw_email: &str, +) -> Result { + let email = normalize_email(raw_email); + if !crate::auth::valid_email(&email) { + return Err(AddError::InvalidEmail(raw_email.to_string())); + } + if email == normalize_email(owner_email) { + return Err(AddError::SelfInvite); + } + if owner_plan(pool, &app.owner_id) + .await + .map_err(AddError::Internal)? + != "pro" + { + return Err(AddError::NotPro); + } + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM app_collaborator WHERE app_id = ?1") + .bind(&app.id) + .fetch_optional(pool) + .await + .map_err(|err| AddError::Internal(err.into()))? + .unwrap_or(0); + if count.max(0) as u64 >= MAX_COLLABORATORS { + return Err(AddError::LimitReached); + } + // An existing account links immediately; a future sign-in links through + // [`link_user`]. + let user_id: Option = sqlx::query_scalar("SELECT id FROM user WHERE email = ?1") + .bind(&email) + .fetch_optional(pool) + .await + .map_err(|err| AddError::Internal(err.into()))?; + let now = now_ms(); + let inserted = sqlx::query( + "INSERT INTO app_collaborator (app_id, email, userId, added_at) \ + VALUES (?1, ?2, ?3, ?4) ON CONFLICT(app_id, email) DO NOTHING", + ) + .bind(&app.id) + .bind(&email) + .bind(&user_id) + .bind(now) + .execute(pool) + .await + .map_err(|err| AddError::Internal(err.into()))?; + if inserted.rows_affected() == 0 { + return Err(AddError::Duplicate); + } + // A Pro invite admits the email for the closed beta (spec §7.4). The + // invite never grants codes: allowlisted emails join with none (§6.5.1). + sqlx::query("INSERT INTO beta_allowlist (email, added_at) VALUES (?1, ?2) ON CONFLICT(email) DO NOTHING") + .bind(&email) + .bind(now) + .execute(pool) + .await + .map_err(|err| AddError::Internal(err.into()))?; + let owner_name = owner_email + .split('@') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or(owner_email); + let subject = format!("You've been added to {} on Outer", app.slug); + let text = format!( + "{owner_name} added you to **{}** on Outer. Ask your agent to connect to {mcp_url}.", + app.slug + ); + if let Err(err) = sender.send_text(&email, &subject, &text).await { + tracing::warn!(app = %app.slug, email = %email, "collaborator invite email failed: {err}"); + } + Ok(Collaborator { + email, + joined: user_id.is_some(), + added_at: now, + }) +} + +/// Remove `raw_email` from `app`. True when a row was there: removal revokes +/// access on the next call, since [`role`] reads this table every time. The +/// beta allowlist keeps the email (admission is sticky; re-invites resend). +pub async fn remove( + pool: &sqlx::SqlitePool, + app_id: &str, + raw_email: &str, +) -> anyhow::Result { + let email = normalize_email(raw_email); + let deleted = sqlx::query("DELETE FROM app_collaborator WHERE app_id = ?1 AND email = ?2") + .bind(app_id) + .bind(&email) + .execute(pool) + .await?; + Ok(deleted.rows_affected() > 0) +} + +/// Link `app_collaborator.userId` when an address becomes an account. Called +/// from account creation (both the email-code and OAuth paths create rows +/// there); invites to existing accounts link in [`add`] instead. +pub async fn link_user(pool: &sqlx::SqlitePool, email: &str, user_id: &str) -> anyhow::Result<()> { + sqlx::query("UPDATE app_collaborator SET userId = ?1 WHERE email = ?2 AND userId IS NULL") + .bind(user_id) + .bind(normalize_email(email)) + .execute(pool) + .await?; + Ok(()) +} + +async fn owner_plan(pool: &sqlx::SqlitePool, owner_id: &str) -> anyhow::Result { + let plan: Option = sqlx::query_scalar("SELECT plan FROM user WHERE id = ?1") + .bind(owner_id) + .fetch_optional(pool) + .await?; + Ok(plan.unwrap_or_else(|| "free".to_string())) +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as i64) + .unwrap_or(0) +} diff --git a/crates/outer/src/http/console.rs b/crates/outer/src/http/console.rs index 83aa1c0..82d5e22 100644 --- a/crates/outer/src/http/console.rs +++ b/crates/outer/src/http/console.rs @@ -87,6 +87,7 @@ pub fn router(state: super::AppState) -> Router { .route("/api/console/sign-out", post(api_sign_out)) .merge(super::console_observe::routes()) .merge(super::console_account::routes()) + .merge(super::console_collaborators::routes()) .with_state(state) } @@ -103,6 +104,26 @@ pub(crate) fn nostore_json(status: StatusCode, error: &str, message: &str) -> Re .into_response() } +/// The `user_message` variant (contract): the UI shows `user_message` in an +/// `alert`, and agents relay it word for word. Same codes as the MCP tools. +pub(crate) fn nostore_json_user( + status: StatusCode, + error: &str, + message: impl Into, + user_message: impl Into, +) -> Response { + ( + status, + [ + ("cache-control", "no-store"), + ("pragma", "no-cache"), + ("content-type", "application/json"), + ], + json!({"error": error, "message": message.into(), "user_message": user_message.into()}) + .to_string(), + ) + .into_response() +} pub(crate) fn not_found() -> Response { nostore_json(StatusCode::NOT_FOUND, "not_found", "no such app") } @@ -156,16 +177,26 @@ pub(crate) async fn guard( Ok(account) } -/// The app `slug` names, owned by `account`. Anything else — missing, or -/// owned by someone else — is `None`, and the caller answers 404 (spec §8.2). -/// Shared with `console_data`. -pub(crate) async fn owned_app( +/// The app `slug` names, visible to `account` as owner or collaborator +/// (Pro-gated: a downgrade hides the app again). Anything else is `None`, +/// and the caller answers 404 (spec §8.2). +pub(crate) async fn visible_app( mcp: &McpState, account: &AccountRow, slug: &str, -) -> anyhow::Result> { - let app = crate::apps::get_by_slug(&mcp.pool, slug).await?; - Ok(app.filter(|app| app.owner_id == account.id)) +) -> anyhow::Result> { + let Some(app) = crate::apps::get_by_slug(&mcp.pool, slug).await? else { + return Ok(None); + }; + let role = crate::collaborators::role( + &mcp.pool, + &app.id, + &app.owner_id, + &account.id, + &account.email, + ) + .await?; + Ok(role.map(|role| (app, role))) } async fn api_me(State(state): State, headers: HeaderMap) -> Response { @@ -427,14 +458,38 @@ async fn api_apps(State(state): State, headers: HeaderMap) -> R } }; let mut out = Vec::new(); - for app in apps.iter().filter(|a| a.owner_id == account.id) { + for app in &apps { + // Owners match inline; collaborators read their row (Pro-gated, so + // a downgrade hides the app from the rail again). + let role = match crate::collaborators::role( + &mcp.pool, + &app.id, + &app.owner_id, + &account.id, + &account.email, + ) + .await + { + Ok(role) => role, + Err(_) => { + return nostore_json( + StatusCode::INTERNAL_SERVER_ERROR, + "unavailable", + "could not list apps right now", + ); + } + }; + let Some(role) = role else { continue }; let last = latest_deploy(&mcp.pool, &app.id).await.unwrap_or(None); let status = crate::mcp::tools::apps::app_status(app, last.as_ref().map(|r| r.status.as_str())); let mut v = json!({ "slug": app.slug, "url": mcp.app_url(&app.slug), - "role": "owner", + "role": match role { + crate::collaborators::Role::Owner => "owner", + crate::collaborators::Role::Collaborator => "collaborator", + }, "visibility": app.visibility.as_str(), "status": status, "last_request_at": app.last_request_at, @@ -487,8 +542,8 @@ async fn api_app( Ok(account) => account, Err(res) => return *res, }; - let app = match owned_app(mcp, &account, &slug).await { - Ok(Some(app)) => app, + let (app, role) = match visible_app(mcp, &account, &slug).await { + Ok(Some(found)) => found, Ok(None) => return not_found(), Err(_) => { return nostore_json( @@ -550,7 +605,10 @@ async fn api_app( json!({ "slug": app.slug, "url": mcp.app_url(&app.slug), - "role": "owner", + "role": match role { + crate::collaborators::Role::Owner => "owner", + crate::collaborators::Role::Collaborator => "collaborator", + }, "visibility": app.visibility.as_str(), "status": status, "last_request_at": app.last_request_at, @@ -650,8 +708,8 @@ async fn api_logs( Ok(account) => account, Err(res) => return *res, }; - let app = match owned_app(mcp, &account, &slug).await { - Ok(Some(app)) => app, + let (app, _role) = match visible_app(mcp, &account, &slug).await { + Ok(Some(found)) => found, Ok(None) => return not_found(), Err(_) => { return nostore_json( @@ -703,8 +761,8 @@ async fn api_errors( Ok(account) => account, Err(res) => return *res, }; - let app = match owned_app(mcp, &account, &slug).await { - Ok(Some(app)) => app, + let (app, _role) = match visible_app(mcp, &account, &slug).await { + Ok(Some(found)) => found, Ok(None) => return not_found(), Err(_) => { return nostore_json( @@ -775,7 +833,7 @@ async fn api_errors( } /// `GET /api/console/apps/:slug/secrets`: the app's secret names plus /// `updated_at`, ordered by name. Values never leave the server. The caller -/// MUST own the app (see `owned_app`): anything else answers 404 exactly +/// must see the app (see `visible_app`): anything else answers 404 exactly /// like a missing app. async fn api_secrets( State(state): State, @@ -787,8 +845,8 @@ async fn api_secrets( Ok(account) => account, Err(res) => return *res, }; - let app = match owned_app(mcp, &account, &slug).await { - Ok(Some(app)) => app, + let (app, _role) = match visible_app(mcp, &account, &slug).await { + Ok(Some(found)) => found, Ok(None) => return not_found(), Err(_) => { return nostore_json( @@ -817,8 +875,8 @@ async fn api_secrets( /// newest first. `before` is the opaque `:` cursor of the /// last row on the previous page; `limit` defaults to 20 and clamps to /// 1–100. Answers `{actions, next?}` where `next` is the cursor for the -/// following page. The caller MUST own the app (see `owned_app`): anything -/// else answers 404 exactly like a missing app. +/// following page. The caller must see the app (see `visible_app`): +/// anything else answers 404 exactly like a missing app. async fn api_actions( State(state): State, headers: HeaderMap, @@ -830,8 +888,8 @@ async fn api_actions( Ok(account) => account, Err(res) => return *res, }; - let app = match owned_app(mcp, &account, &slug).await { - Ok(Some(app)) => app, + let (app, _role) = match visible_app(mcp, &account, &slug).await { + Ok(Some(found)) => found, Ok(None) => return not_found(), Err(_) => { return nostore_json( @@ -924,8 +982,8 @@ async fn api_metrics( Ok(account) => account, Err(res) => return *res, }; - let app = match owned_app(mcp, &account, &slug).await { - Ok(Some(app)) => app, + let (app, _role) = match visible_app(mcp, &account, &slug).await { + Ok(Some(found)) => found, Ok(None) => return not_found(), Err(_) => { return nostore_json( @@ -3309,4 +3367,267 @@ mod tests { "{rows:?}" ); } + + async fn seed_pro_app(pool: &SqlitePool, owner: &str, slug: &str, now: i64) { + // seed_app inserts the account row too; the plan flips after. + seed_app(pool, owner, slug, now).await; + sqlx::query("UPDATE user SET plan = 'pro' WHERE email = ?1") + .bind(owner) + .execute(pool) + .await + .unwrap(); + } + + #[tokio::test] + async fn console_collaborators_owner_invite_and_remove_flow() { + let (app, pool, _, _) = test_app().await; + let now = crate::auth::now_ms(); + seed_pro_app(&pool, "ana@example.com", "groceries", now).await; + let owner = login(&pool, "ana@example.com").await; + + let (status, _, body) = send( + &app, + authed("/api/console/apps/groceries/collaborators", &owner), + ) + .await; + assert_eq!(status, StatusCode::OK); + let list: serde_json::Value = body.parse().unwrap(); + assert_eq!(list["role"], "owner"); + assert_eq!(list["owner_plan"], "pro"); + assert!(list["collaborators"].as_array().unwrap().is_empty()); + + let (status, _, body) = send( + &app, + post_json( + "/api/console/apps/groceries/collaborators", + &owner, + serde_json::json!({"email": "bruno@example.com"}), + ), + ) + .await; + assert_eq!(status, StatusCode::OK); + let added: serde_json::Value = body.parse().unwrap(); + assert_eq!(added["collaborator"]["email"], "bruno@example.com"); + assert_eq!(added["collaborator"]["status"], "pending"); + + // The mutation is audited as the MCP tool with client "Console". + let rows = crate::mcp::actions::list(&pool, "app-groceries", None, 10) + .await + .unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].tool, "add_collaborator"); + assert_eq!(rows[0].client_label, "Console"); + + let (status, _, body) = send( + &app, + post_json( + "/api/console/apps/groceries/collaborators/remove", + &owner, + serde_json::json!({"email": "bruno@example.com"}), + ), + ) + .await; + assert_eq!(status, StatusCode::OK); + let removed: serde_json::Value = body.parse().unwrap(); + assert_eq!(removed["removed"], true); + let (status, _, body) = send( + &app, + authed("/api/console/apps/groceries/collaborators", &owner), + ) + .await; + assert_eq!(status, StatusCode::OK); + let list: serde_json::Value = body.parse().unwrap(); + assert!(list["collaborators"].as_array().unwrap().is_empty()); + } + + #[tokio::test] + async fn console_collaborators_are_scoped_like_the_tools() { + let (app, pool, _, _) = test_app().await; + let now = crate::auth::now_ms(); + seed_pro_app(&pool, "ana@example.com", "groceries", now).await; + seed_account(&pool, "bruno@example.com").await; + seed_account(&pool, "stranger@example.com").await; + sqlx::query( + "INSERT INTO app_collaborator (app_id, email, userId, added_at) \ + VALUES ('app-groceries', 'bruno@example.com', 'acct-bruno@example.com', ?1)", + ) + .bind(now) + .execute(&pool) + .await + .unwrap(); + let bruno = login(&pool, "bruno@example.com").await; + let stranger = login(&pool, "stranger@example.com").await; + + // The app shows in the collaborator's rail and detail as Shared. + let (status, _, body) = send(&app, authed("/api/console/apps", &bruno)).await; + assert_eq!(status, StatusCode::OK); + let list: serde_json::Value = body.parse().unwrap(); + let apps = list["apps"].as_array().unwrap(); + assert_eq!(apps.len(), 1); + assert_eq!(apps[0]["slug"], "groceries"); + assert_eq!(apps[0]["role"], "collaborator"); + let (status, _, body) = send(&app, authed("/api/console/apps/groceries", &bruno)).await; + assert_eq!(status, StatusCode::OK); + let detail: serde_json::Value = body.parse().unwrap(); + assert_eq!(detail["role"], "collaborator"); + + // Collaborator reads (list, secrets) but never invites. + let (status, _, _) = send( + &app, + authed("/api/console/apps/groceries/collaborators", &bruno), + ) + .await; + assert_eq!(status, StatusCode::OK); + let (status, _, _) = + send(&app, authed("/api/console/apps/groceries/secrets", &bruno)).await; + assert_eq!(status, StatusCode::OK); + let (status, _, body) = send( + &app, + post_json( + "/api/console/apps/groceries/collaborators", + &bruno, + serde_json::json!({"email": "x@example.com"}), + ), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + let refused: serde_json::Value = body.parse().unwrap(); + assert_eq!(refused["error"], "forbidden"); + assert!(refused["user_message"].as_str().unwrap().contains("owner")); + + // No role reads exactly like a missing app. + let (status, _, _) = send(&app, authed("/api/console/apps/groceries", &stranger)).await; + assert_eq!(status, StatusCode::NOT_FOUND); + let (status, _, _) = send( + &app, + post_json( + "/api/console/apps/groceries/collaborators", + &stranger, + serde_json::json!({"email": "x@example.com"}), + ), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn console_collaborator_invite_needs_a_pro_owner() { + let (app, pool, _, _) = test_app().await; + let now = crate::auth::now_ms(); + seed_app(&pool, "ana@example.com", "groceries", now).await; + let owner = login(&pool, "ana@example.com").await; + let (status, _, body) = send( + &app, + post_json( + "/api/console/apps/groceries/collaborators", + &owner, + serde_json::json!({"email": "bruno@example.com"}), + ), + ) + .await; + assert_eq!(status, StatusCode::PAYMENT_REQUIRED); + let refused: serde_json::Value = body.parse().unwrap(); + assert_eq!(refused["error"], "payment_required"); + assert!(refused["user_message"].as_str().unwrap().contains("Pro")); + } + + #[tokio::test] + async fn console_mutations_scope_collaborators_like_the_tools() { + let (app, pool, _, _) = test_app().await; + let now = crate::auth::now_ms(); + seed_app(&pool, "ana@example.com", "groceries", now).await; + seed_account(&pool, "bruno@example.com").await; + seed_account(&pool, "stranger@example.com").await; + sqlx::query("UPDATE user SET plan = 'pro' WHERE email = 'ana@example.com'") + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO app_collaborator (app_id, email, userId, added_at) \ + VALUES ('app-groceries', 'bruno@example.com', 'acct-bruno@example.com', ?1)", + ) + .bind(now) + .execute(&pool) + .await + .unwrap(); + let bruno = login(&pool, "bruno@example.com").await; + let stranger = login(&pool, "stranger@example.com").await; + + // Rollback admits collaborators like the MCP tool: the scope gate + // passes and the unknown sha refuses as `invalid_argument`. + let (status, _, body) = send( + &app, + post_json( + "/api/console/apps/groceries/rollback", + &bruno, + serde_json::json!({"sha": "0123456789abcdef0123456789abcdef01234567"}), + ), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + body.parse::().unwrap()["error"], + "invalid_argument" + ); + + // Visibility, secret writes and delete stay owner-only: a + // collaborator reads 403 with the MCP code and a user message. + for (path, body) in [ + ( + "/api/console/apps/groceries/visibility", + serde_json::json!({"visibility": "unlisted"}), + ), + ( + "/api/console/apps/groceries/delete", + serde_json::json!({"confirm": "groceries"}), + ), + ] { + let (status, _, body) = send(&app, post_json(path, &bruno, body)).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{path}"); + let refused: serde_json::Value = body.parse().unwrap(); + assert_eq!(refused["error"], "forbidden", "{path}"); + assert!( + refused["user_message"].as_str().unwrap().contains("owner"), + "{path}: {refused}" + ); + } + let (status, _, body) = send( + &app, + put_json( + "/api/console/apps/groceries/secrets/API_KEY", + &bruno, + serde_json::json!({"value": "x"}), + ), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + let refused: serde_json::Value = body.parse().unwrap(); + assert_eq!(refused["error"], "forbidden"); + assert!( + refused["user_message"].as_str().unwrap().contains("owner"), + "{refused}" + ); + + // A stranger's slug reads 404 exactly like a missing one. + let (status, _, _) = send( + &app, + post_json( + "/api/console/apps/groceries/visibility", + &stranger, + serde_json::json!({"visibility": "unlisted"}), + ), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + let (status, _, _) = send( + &app, + post_json( + "/api/console/apps/groceries/rollback", + &stranger, + serde_json::json!({"sha": "0123456789abcdef0123456789abcdef01234567"}), + ), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + } } diff --git a/crates/outer/src/http/console_actions.rs b/crates/outer/src/http/console_actions.rs index c40f5fc..f038fee 100644 --- a/crates/outer/src/http/console_actions.rs +++ b/crates/outer/src/http/console_actions.rs @@ -3,10 +3,12 @@ //! One module per the wave-2 seam: `console.rs` only gains a //! `.merge(console_actions::routes())` line, so parallel console work lands //! without touching this file. Every endpoint reuses `console.rs`'s `guard` -//! and `fetch_ok` rules (session cookie only, same-origin, owner-scoped +//! and `fetch_ok` rules (session cookie only, same-origin, role-scoped //! 404s), calls the SAME domain functions the MCP tools call, records the //! mutation in `agent_action` under the MCP tool name (client "Console"), //! and answers `{error, message, user_message?}` with the MCP codes. +//! Visibility, delete and secret writes stay owner-only (spec §7.4: a +//! collaborator reads 403); rollback admits collaborators like the MCP tool. use axum::extract::{Path, State}; use axum::http::{HeaderMap, StatusCode}; @@ -16,7 +18,7 @@ use axum::{Json, Router}; use serde::Deserialize; use serde_json::json; -use super::console::{guard, nostore_json, not_found, ok_json, owned_app}; +use super::console::{guard, nostore_json, not_found, ok_json, visible_app}; use super::AppState; use crate::auth::AccountRow; use crate::mcp::error::ToolError; @@ -71,7 +73,7 @@ pub(crate) fn tool_response(err: &ToolError) -> Response { .into_response() } -/// The session plus the owned app, or the error response. Every mutating +/// The session plus the visible app, or the error response. Every mutating /// console endpoint starts here: a foreign or missing slug answers 404 /// exactly like a missing one (spec §8.2), and cross-site requests never /// reach the mutation (the same-origin check inside [`guard`]). @@ -84,8 +86,40 @@ async fn mutation_app( Ok(account) => account, Err(res) => return Err(res), }; - match owned_app(mcp, &account, slug).await { - Ok(Some(app)) => Ok((account, app)), + match visible_app(mcp, &account, slug).await { + Ok(Some((app, _))) => Ok((account, app)), + Ok(None) => Err(not_found().into()), + Err(_) => Err(nostore_json( + StatusCode::INTERNAL_SERVER_ERROR, + "unavailable", + "could not load the app right now", + ) + .into()), + } +} + +/// [`mutation_app`] plus the owner check: visibility, delete and secret +/// writes stay owner-only (spec §7.4), so a collaborator reads 403 with the +/// same code and user message the MCP tool answers — never 404 (spec §8.2 +/// reserves 404 for slugs the caller has no role on). +async fn owner_mutation_app( + mcp: &McpState, + headers: &HeaderMap, + slug: &str, +) -> Result<(AccountRow, crate::apps::App), Box> { + let account = match guard(&mcp.auth, headers).await { + Ok(account) => account, + Err(res) => return Err(res), + }; + match visible_app(mcp, &account, slug).await { + Ok(Some((app, crate::collaborators::Role::Owner))) => Ok((account, app)), + Ok(Some(_)) => Err(tool_response( + &ToolError::forbidden(format!( + "only the app owner may change {slug:?} this way" + )) + .with_user("Only the app owner can do that. Collaborators can read and deploy, but not change these settings."), + ) + .into()), Ok(None) => Err(not_found().into()), Err(_) => Err(nostore_json( StatusCode::INTERNAL_SERVER_ERROR, @@ -144,7 +178,7 @@ async fn api_visibility( Json(body): Json, ) -> Response { let mcp = &state.mcp; - let (account, app) = match mutation_app(mcp, &headers, &slug).await { + let (account, app) = match owner_mutation_app(mcp, &headers, &slug).await { Ok(pair) => pair, Err(res) => return *res, }; @@ -193,7 +227,7 @@ async fn api_delete( Json(body): Json, ) -> Response { let mcp = &state.mcp; - let (_, app) = match mutation_app(mcp, &headers, &slug).await { + let (_, app) = match owner_mutation_app(mcp, &headers, &slug).await { Ok(pair) => pair, Err(res) => return *res, }; @@ -266,7 +300,7 @@ async fn api_secret_put( Json(body): Json, ) -> Response { let mcp = &state.mcp; - let (account, app) = match mutation_app(mcp, &headers, &slug).await { + let (account, app) = match owner_mutation_app(mcp, &headers, &slug).await { Ok(pair) => pair, Err(res) => return *res, }; @@ -313,7 +347,7 @@ async fn api_secret_delete( Path((slug, name)): Path<(String, String)>, ) -> Response { let mcp = &state.mcp; - let (account, app) = match mutation_app(mcp, &headers, &slug).await { + let (account, app) = match owner_mutation_app(mcp, &headers, &slug).await { Ok(pair) => pair, Err(res) => return *res, }; @@ -394,12 +428,13 @@ async fn api_rename( ) -> Response { let started = std::time::Instant::now(); let mcp = &state.mcp; - let (account, app) = match mutation_app(mcp, &headers, &slug).await { + let (account, app) = match owner_mutation_app(mcp, &headers, &slug).await { Ok(pair) => pair, Err(res) => return *res, }; // One audit row whatever the outcome (mirrors the MCP path, which logs - // refused calls too): the app is owned, so the row always resolves. + // refused calls too): the app is owner-gated above, so the row always + // resolves. let audit = |summary: &str, outcome: &str, error_code: Option<&str>| { let pool = mcp.pool.clone(); let app_id = app.id.clone(); diff --git a/crates/outer/src/http/console_code.rs b/crates/outer/src/http/console_code.rs index 2a89baf..618b939 100644 --- a/crates/outer/src/http/console_code.rs +++ b/crates/outer/src/http/console_code.rs @@ -14,9 +14,9 @@ //! files and patch between two versions (`to` is the live sha for the //! "compare with live" button). //! -//! Read-only. Owner-scoped exactly like the console app endpoints (and the -//! MCP read tools, which are owner-only until collaborators land): a foreign -//! or unknown slug answers 404 like a missing one. Every read goes through +//! Read-only. Role-scoped like the console app endpoints and the MCP read +//! tools (spec §7.4: owners and collaborators read code and history): a +//! slug the caller has no role on answers 404 like a missing one. Every read goes through //! the same [`crate::git::local`] functions the MCP `list_files`/`read_file` //! tools use — no second implementation, same bounds (256 KiB blob cap, //! 64 KiB patch/text caps) and the same binary detection. @@ -33,7 +33,7 @@ use serde::Deserialize; use serde_json::json; use sqlx::SqlitePool; -use super::console::{guard, nostore_json, not_found, ok_json, owned_app}; +use super::console::{guard, nostore_json, not_found, ok_json, visible_app}; use crate::git::local; use crate::mcp::McpState; @@ -52,15 +52,17 @@ const MAX_TEXT_BYTES: usize = 64 * 1024; /// Most files one tree listing carries before `truncated` is set. const MAX_TREE_FILES: usize = 5000; -/// The caller's app, or the response to answer when it isn't theirs. +/// The caller's app, or the response to answer when it isn't theirs. Reads +/// admit collaborators (spec §7.4: code and history), so this is the shared +/// role-scoped lookup: no role answers 404 exactly like a missing slug. async fn app_ctx( mcp: &McpState, headers: &HeaderMap, slug: &str, ) -> Result> { let account = guard(&mcp.auth, headers).await?; - match owned_app(mcp, &account, slug).await { - Ok(Some(app)) => Ok(app), + match visible_app(mcp, &account, slug).await { + Ok(Some((app, _))) => Ok(app), Ok(None) => Err(not_found().into()), Err(_) => Err(nostore_json( StatusCode::INTERNAL_SERVER_ERROR, @@ -965,4 +967,44 @@ mod tests { .await; assert_eq!(status, StatusCode::FORBIDDEN); } + + #[tokio::test] + async fn code_reads_admit_collaborators() { + let (app, pool, _, _, c2) = harness().await; + // Collaborators count only while the owner is Pro (spec §7.4). + sqlx::query("UPDATE user SET plan = 'pro' WHERE email = 'ana@example.com'") + .execute(&pool) + .await + .unwrap(); + let now = crate::auth::now_ms(); + sqlx::query( + "INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES ('acct-bruno@example.com', 'bruno@example.com', ?, ?, ?)", + ) + .bind(now) + .bind(now) + .bind(now) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO app_collaborator (app_id, email, userId, added_at) \ + VALUES ('app-groceries', 'bruno@example.com', 'acct-bruno@example.com', ?1)", + ) + .bind(now) + .execute(&pool) + .await + .unwrap(); + let bruno = create_session(&pool, "acct-bruno@example.com") + .await + .unwrap(); + let bruno = format!("__Host-outer_sid={bruno}"); + let (status, body) = send( + &app, + authed("/api/console/apps/groceries/code/tree", &bruno), + ) + .await; + assert_eq!(status, StatusCode::OK, "collaborator tree failed: {body}"); + let tree: serde_json::Value = body.parse().unwrap(); + assert_eq!(tree["sha"], json!(c2), "same live default as the owner"); + } } diff --git a/crates/outer/src/http/console_collaborators.rs b/crates/outer/src/http/console_collaborators.rs new file mode 100644 index 0000000..fa91c8a --- /dev/null +++ b/crates/outer/src/http/console_collaborators.rs @@ -0,0 +1,284 @@ +//! Console collaborators endpoints (Pro, spec §7.4). +//! +//! - `GET /api/console/apps/{slug}/collaborators`: the app's invites with +//! joined/pending status. Owners and collaborators read; anyone else gets +//! the owner-scoped 404 (spec §8.2). +//! - `POST …/collaborators {email}`: invite one address (owner only). Calls +//! the same domain function as MCP `add_collaborator`, audits as +//! `add_collaborator` with client "Console", and answers the same §8.3 +//! codes the tool does. +//! - `POST …/collaborators/remove {email}`: remove one address (owner only), +//! audited as `remove_collaborator`. + +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::Response; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::Deserialize; +use serde_json::json; + +use super::console::{ + fetch_ok, guard, nostore_json, nostore_json_user, not_found, ok_json, visible_app, +}; +use crate::auth::AccountRow; +use crate::collaborators::{self, AddError, Role}; +use crate::http::AppState; +use crate::mcp::McpState; + +pub fn routes() -> Router { + Router::new() + .route( + "/api/console/apps/{slug}/collaborators", + get(api_collaborators).post(api_invite), + ) + .route( + "/api/console/apps/{slug}/collaborators/remove", + post(api_remove), + ) +} + +#[derive(Debug, Deserialize)] +struct EmailBody { + email: String, +} + +fn collaborator_json(collaborator: &collaborators::Collaborator) -> serde_json::Value { + json!({ + "email": collaborator.email, + "status": if collaborator.joined { "joined" } else { "pending" }, + "added_at": collaborator.added_at, + }) +} + +/// `GET /api/console/apps/:slug/collaborators`. +async fn api_collaborators( + State(state): State, + headers: HeaderMap, + Path(slug): Path, +) -> Response { + let mcp = &state.mcp; + let account = match guard(&mcp.auth, &headers).await { + Ok(account) => account, + Err(res) => return *res, + }; + if !fetch_ok(&headers) { + return nostore_json( + StatusCode::FORBIDDEN, + "forbidden", + "same-origin requests only", + ); + } + let (app, role) = match visible_app(mcp, &account, &slug).await { + Ok(Some(found)) => found, + Ok(None) => return not_found(), + Err(_) => { + return nostore_json( + StatusCode::INTERNAL_SERVER_ERROR, + "unavailable", + "could not load the app right now", + ); + } + }; + let rows = match collaborators::list(&mcp.pool, &app.id).await { + Ok(rows) => rows, + Err(_) => { + return nostore_json( + StatusCode::INTERNAL_SERVER_ERROR, + "unavailable", + "could not load collaborators right now", + ); + } + }; + let owner_plan = crate::mcp::tools::plan_of(&mcp.pool, &app.owner_id) + .await + .unwrap_or_else(|_| "free".to_string()); + ok_json(json!({ + "app": app.slug, + "role": match role { + Role::Owner => "owner", + Role::Collaborator => "collaborator", + }, + "owner_plan": owner_plan, + "collaborators": rows.iter().map(collaborator_json).collect::>(), + })) +} + +/// The owner check behind the two mutations: no role reads 404 (spec §8.2), +/// a collaborator reads 403 (spec §8.2 table: owner-only). +async fn owner_app( + mcp: &McpState, + account: &AccountRow, + slug: &str, +) -> Result> { + match visible_app(mcp, account, slug).await { + Ok(Some((app, Role::Owner))) => Ok(app), + Ok(Some(_)) => Err(nostore_json_user( + StatusCode::FORBIDDEN, + "forbidden", + "only the app owner manages collaborators", + "Only the app owner can invite or remove collaborators.", + ) + .into()), + Ok(None) => Err(not_found().into()), + Err(_) => Err(nostore_json( + StatusCode::INTERNAL_SERVER_ERROR, + "unavailable", + "could not load the app right now", + ) + .into()), + } +} + +/// `POST /api/console/apps/:slug/collaborators {email}`. +async fn api_invite( + State(state): State, + headers: HeaderMap, + Path(slug): Path, + Json(body): Json, +) -> Response { + let mcp = &state.mcp; + let account = match guard(&mcp.auth, &headers).await { + Ok(account) => account, + Err(res) => return *res, + }; + if !fetch_ok(&headers) { + return nostore_json( + StatusCode::FORBIDDEN, + "forbidden", + "same-origin requests only", + ); + } + if body.email.len() > 320 { + return nostore_json( + StatusCode::BAD_REQUEST, + "invalid_argument", + "that address is not usable", + ); + } + let app = match owner_app(mcp, &account, &slug).await { + Ok(app) => app, + Err(res) => return *res, + }; + match collaborators::add( + &mcp.pool, + &mcp.auth.config.email, + &mcp.mcp_url(), + &app, + &account.email, + &body.email, + ) + .await + { + Ok(collaborator) => { + crate::mcp::actions::record_console( + &mcp.pool, + &app.id, + &account.id, + "add_collaborator", + &format!("add_collaborator {}", collaborator.email), + "ok", + None, + 0, + ) + .await; + ok_json(json!({ + "app": app.slug, + "collaborator": collaborator_json(&collaborator), + })) + } + Err(AddError::NotPro) => nostore_json_user( + StatusCode::PAYMENT_REQUIRED, + "payment_required", + format!("collaborators on {} need a Pro owner", app.slug), + "Collaborators are a Pro feature. Upgrade to Pro to invite people to your apps.", + ), + Err(AddError::LimitReached) => nostore_json_user( + StatusCode::TOO_MANY_REQUESTS, + "quota_exceeded", + format!( + "app quota: {} collaborators allowed, {} on {}", + collaborators::MAX_COLLABORATORS, + collaborators::MAX_COLLABORATORS, + app.slug + ), + format!( + "{} already has the maximum {} collaborators.", + app.slug, + collaborators::MAX_COLLABORATORS + ), + ), + Err(AddError::InvalidEmail(_)) => nostore_json( + StatusCode::BAD_REQUEST, + "invalid_argument", + "that address is not usable", + ), + Err(AddError::SelfInvite) => nostore_json( + StatusCode::BAD_REQUEST, + "invalid_argument", + "you already own this app", + ), + Err(AddError::Duplicate) => nostore_json( + StatusCode::BAD_REQUEST, + "invalid_argument", + "that address is already a collaborator", + ), + Err(AddError::Internal(_)) => nostore_json( + StatusCode::INTERNAL_SERVER_ERROR, + "unavailable", + "could not invite that address right now", + ), + } +} + +/// `POST /api/console/apps/:slug/collaborators/remove {email}`. +async fn api_remove( + State(state): State, + headers: HeaderMap, + Path(slug): Path, + Json(body): Json, +) -> Response { + let mcp = &state.mcp; + let account = match guard(&mcp.auth, &headers).await { + Ok(account) => account, + Err(res) => return *res, + }; + if !fetch_ok(&headers) { + return nostore_json( + StatusCode::FORBIDDEN, + "forbidden", + "same-origin requests only", + ); + } + let app = match owner_app(mcp, &account, &slug).await { + Ok(app) => app, + Err(res) => return *res, + }; + let email = crate::auth::normalize_email(&body.email); + match collaborators::remove(&mcp.pool, &app.id, &email).await { + Ok(true) => { + crate::mcp::actions::record_console( + &mcp.pool, + &app.id, + &account.id, + "remove_collaborator", + &format!("remove_collaborator {email}"), + "ok", + None, + 0, + ) + .await; + ok_json(json!({"app": app.slug, "email": email, "removed": true})) + } + Ok(false) => nostore_json( + StatusCode::BAD_REQUEST, + "invalid_argument", + "that address is not a collaborator", + ), + Err(_) => nostore_json( + StatusCode::INTERNAL_SERVER_ERROR, + "unavailable", + "could not remove that address right now", + ), + } +} diff --git a/crates/outer/src/http/console_data/d1.rs b/crates/outer/src/http/console_data/d1.rs index 3ced399..e29881c 100644 --- a/crates/outer/src/http/console_data/d1.rs +++ b/crates/outer/src/http/console_data/d1.rs @@ -22,9 +22,47 @@ use sqlx::SqlitePool; use crate::deploy::migrate::{self, D1Database}; use crate::fleet::manager::{FleetManager, FleetStorage}; -use crate::http::console::{guard, owned_app}; +use crate::http::console::{guard, visible_app}; use crate::store::Store; +/// D1 is owner-only in v1 (spec §7.4): the shared role-scoped lookup, then +/// the owner check. A slug the caller has no role on answers 404 exactly +/// like a missing one (spec §8.2); a collaborator reads 403 with a user +/// message, the same shape the owner-only MCP tools answer. +async fn owner_app( + mcp: &crate::mcp::McpState, + account: &crate::auth::AccountRow, + slug: &str, +) -> Result> { + match visible_app(mcp, account, slug).await { + Ok(Some((app, crate::collaborators::Role::Owner))) => Ok(app), + Ok(Some(_)) => Err(crate::http::console_data::data_error( + StatusCode::FORBIDDEN, + "forbidden", + "only the app owner may open this database", + Some( + "Only the app owner can open this database. D1 editing stays owner-only." + .to_string(), + ), + ) + .into()), + Ok(None) => Err(crate::http::console_data::data_error( + StatusCode::NOT_FOUND, + "not_found", + "no such app", + None, + ) + .into()), + Err(_) => Err(crate::http::console_data::data_error( + StatusCode::INTERNAL_SERVER_ERROR, + "unavailable", + "could not load the app right now", + None, + ) + .into()), + } +} + /// Filters are AND-ed and bounded so a query can't grow without limit. pub const MAX_FILTERS: usize = 10; /// One `delete-rows` call carries at most this many keys. @@ -1451,24 +1489,9 @@ async fn api_databases( Ok(account) => account, Err(res) => return *res, }; - let app = match owned_app(mcp, &account, &slug).await { - Ok(Some(app)) => app, - Ok(None) => { - return crate::http::console_data::data_error( - StatusCode::NOT_FOUND, - "not_found", - "no such app", - None, - ); - } - Err(_) => { - return crate::http::console_data::data_error( - StatusCode::INTERNAL_SERVER_ERROR, - "unavailable", - "could not load the app right now", - None, - ); - } + let app = match owner_app(mcp, &account, &slug).await { + Ok(app) => app, + Err(res) => return *res, }; let ctx = D1Ctx::from_state(&state); let result = async { @@ -1494,24 +1517,9 @@ async fn api_tables( Ok(account) => account, Err(res) => return *res, }; - let app = match owned_app(mcp, &account, &slug).await { - Ok(Some(app)) => app, - Ok(None) => { - return crate::http::console_data::data_error( - StatusCode::NOT_FOUND, - "not_found", - "no such app", - None, - ); - } - Err(_) => { - return crate::http::console_data::data_error( - StatusCode::INTERNAL_SERVER_ERROR, - "unavailable", - "could not load the app right now", - None, - ); - } + let app = match owner_app(mcp, &account, &slug).await { + Ok(app) => app, + Err(res) => return *res, }; if binding.len() > 128 { return d1_response(Err(D1Error::UnknownDatabase(binding))); @@ -1539,24 +1547,9 @@ async fn api_schema( Ok(account) => account, Err(res) => return *res, }; - let app = match owned_app(mcp, &account, &slug).await { - Ok(Some(app)) => app, - Ok(None) => { - return crate::http::console_data::data_error( - StatusCode::NOT_FOUND, - "not_found", - "no such app", - None, - ); - } - Err(_) => { - return crate::http::console_data::data_error( - StatusCode::INTERNAL_SERVER_ERROR, - "unavailable", - "could not load the app right now", - None, - ); - } + let app = match owner_app(mcp, &account, &slug).await { + Ok(app) => app, + Err(res) => return *res, }; let ctx = D1Ctx::from_state(&state); let result = async { @@ -1585,24 +1578,9 @@ async fn api_rows( Ok(account) => account, Err(res) => return *res, }; - let app = match owned_app(mcp, &account, &slug).await { - Ok(Some(app)) => app, - Ok(None) => { - return crate::http::console_data::data_error( - StatusCode::NOT_FOUND, - "not_found", - "no such app", - None, - ); - } - Err(_) => { - return crate::http::console_data::data_error( - StatusCode::INTERNAL_SERVER_ERROR, - "unavailable", - "could not load the app right now", - None, - ); - } + let app = match owner_app(mcp, &account, &slug).await { + Ok(app) => app, + Err(res) => return *res, }; let ctx = D1Ctx::from_state(&state); let result = async { @@ -1631,24 +1609,9 @@ async fn api_write( Ok(account) => account, Err(res) => return *res, }; - let app = match owned_app(mcp, &account, &slug).await { - Ok(Some(app)) => app, - Ok(None) => { - return crate::http::console_data::data_error( - StatusCode::NOT_FOUND, - "not_found", - "no such app", - None, - ); - } - Err(_) => { - return crate::http::console_data::data_error( - StatusCode::INTERNAL_SERVER_ERROR, - "unavailable", - "could not load the app right now", - None, - ); - } + let app = match owner_app(mcp, &account, &slug).await { + Ok(app) => app, + Err(res) => return *res, }; let ctx = D1Ctx::from_state(&state); let started = Instant::now(); @@ -1719,24 +1682,9 @@ async fn api_delete_rows( Ok(account) => account, Err(res) => return *res, }; - let app = match owned_app(mcp, &account, &slug).await { - Ok(Some(app)) => app, - Ok(None) => { - return crate::http::console_data::data_error( - StatusCode::NOT_FOUND, - "not_found", - "no such app", - None, - ); - } - Err(_) => { - return crate::http::console_data::data_error( - StatusCode::INTERNAL_SERVER_ERROR, - "unavailable", - "could not load the app right now", - None, - ); - } + let app = match owner_app(mcp, &account, &slug).await { + Ok(app) => app, + Err(res) => return *res, }; let ctx = D1Ctx::from_state(&state); let started = Instant::now(); @@ -2935,6 +2883,67 @@ exit 0 assert_eq!(status, StatusCode::NOT_FOUND); } + #[tokio::test] + async fn d1_stays_owner_only_for_collaborators() { + let pool = test_pool().await; + let state = tempfile::tempdir().unwrap(); + let (app, _health) = router_app(&pool, &state).await; + // Collaborators count only while the owner is Pro (spec §7.4): the + // 403 below is the owner check, not a missing role. + sqlx::query("UPDATE user SET plan = 'pro' WHERE email = 'ana@example.com'") + .execute(&pool) + .await + .unwrap(); + let now = crate::auth::now_ms(); + sqlx::query( + "INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)", + ) + .bind("acct-bruno@example.com") + .bind("bruno@example.com") + .bind(now) + .bind(now) + .bind(now) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)", + ) + .bind("acct-stranger@example.com") + .bind("stranger@example.com") + .bind(now) + .bind(now) + .bind(now) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO app_collaborator (app_id, email, userId, added_at) \ + VALUES ('app-acme', 'bruno@example.com', 'acct-bruno@example.com', ?1)", + ) + .bind(now) + .execute(&pool) + .await + .unwrap(); + let bruno = login(&pool, "bruno@example.com").await; + let stranger = login(&pool, "stranger@example.com").await; + + // D1 writes and reads are owner-only in v1: a collaborator reads + // 403 with a user message, before any fleet work runs. + let (status, body) = + authed_get(&app, "/api/console/apps/acme/data/d1/databases", &bruno).await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["error"], "forbidden"); + assert!( + body["user_message"].as_str().is_some_and(|m| !m.is_empty()), + "{body}" + ); + // A stranger's slug still reads 404 exactly like a missing one. + let (status, _) = + authed_get(&app, "/api/console/apps/acme/data/d1/databases", &stranger).await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + #[tokio::test] async fn tables_round_trip_through_router() { let pool = test_pool().await; diff --git a/crates/outer/src/http/console_observe.rs b/crates/outer/src/http/console_observe.rs index 9be99c2..46c22bd 100644 --- a/crates/outer/src/http/console_observe.rs +++ b/crates/outer/src/http/console_observe.rs @@ -1,7 +1,7 @@ //! Console observe endpoints, wave 2 (spec §13.1): error triage and the //! live log tail. New endpoints live here (not in `console.rs`), which //! only gains the one `.merge(routes())` line; the session guard, the -//! same-origin check and the owner-scoped 404 come from `console.rs` as +//! same-origin check and the role-scoped 404 come from `console.rs` as //! `pub(crate)` helpers. //! //! Follow is server-sent events, not long-poll: one connection streams a @@ -25,7 +25,7 @@ use axum::{Json, Router}; use serde::Deserialize; use serde_json::json; -use super::console::{guard, logs_query, nostore_json, not_found, ok_json, owned_app}; +use super::console::{guard, logs_query, nostore_json, not_found, ok_json, visible_app}; /// Seconds between follow polls; each tick emits at most one page. const FOLLOW_POLL_SECS: u64 = 2; @@ -75,8 +75,8 @@ async fn api_logs_stream( Ok(account) => account, Err(res) => return *res, }; - let app = match owned_app(mcp, &account, &slug).await { - Ok(Some(app)) => app, + let app = match visible_app(mcp, &account, &slug).await { + Ok(Some((app, _))) => app, Ok(None) => return not_found(), Err(_) => { return nostore_json( @@ -113,23 +113,33 @@ async fn api_logs_stream( mcp.pool.clone(), mcp.fleets.clone(), account.id.clone(), + account.email.clone(), app.id.clone(), app.slug.clone(), filter, seed, true, ), - move |(pool, fleets, account_id, app_id, slug, filter, mut cursor, mut first)| async move { - // The ownership check runs every tick: an app deleted or - // transferred mid-follow ends the stream instead of leaking - // another owner's lines. + move |(pool, fleets, account_id, account_email, app_id, slug, filter, mut cursor, mut first)| async move { + // The visibility check runs every tick: an app deleted, + // transferred, or unshared mid-follow ends the stream + // instead of leaking lines to a caller with no role left. loop { tokio::time::sleep(Duration::from_secs(FOLLOW_POLL_SECS)).await; let visible = crate::apps::get_by_slug(&pool, &slug).await.ok()?; - if visible - .as_ref() - .is_none_or(|app| app.owner_id != account_id) - { + let Some(app) = visible.as_ref() else { + return None; + }; + let role = crate::collaborators::role( + &pool, + &app.id, + &app.owner_id, + &account_id, + &account_email, + ) + .await + .ok()?; + if role.is_none() { return None; } let lines = crate::observe::logs::read_filtered( @@ -152,7 +162,8 @@ async fn api_logs_stream( }) .to_string(); let state = ( - pool, fleets, account_id, app_id, slug, filter, cursor, first, + pool, fleets, account_id, account_email, app_id, slug, filter, cursor, + first, ); return Some((Ok::(Event::default().data(data)), state)); } @@ -182,8 +193,8 @@ async fn api_error_detail( Ok(account) => account, Err(res) => return *res, }; - let app = match owned_app(mcp, &account, &slug).await { - Ok(Some(app)) => app, + let app = match visible_app(mcp, &account, &slug).await { + Ok(Some((app, _))) => app, Ok(None) => return not_found(), Err(_) => { return nostore_json( @@ -318,8 +329,8 @@ async fn api_error_triage_one( Ok(account) => account, Err(res) => return *res, }; - let app = match owned_app(mcp, &account, &slug).await { - Ok(Some(app)) => app, + let app = match visible_app(mcp, &account, &slug).await { + Ok(Some((app, _))) => app, Ok(None) => return not_found(), Err(_) => { return nostore_json( @@ -361,8 +372,8 @@ async fn api_errors_bulk( Ok(account) => account, Err(res) => return *res, }; - let app = match owned_app(mcp, &account, &slug).await { - Ok(Some(app)) => app, + let app = match visible_app(mcp, &account, &slug).await { + Ok(Some((app, _))) => app, Ok(None) => return not_found(), Err(_) => { return nostore_json( diff --git a/crates/outer/src/http/mod.rs b/crates/outer/src/http/mod.rs index 3e0ebcb..c1ca892 100644 --- a/crates/outer/src/http/mod.rs +++ b/crates/outer/src/http/mod.rs @@ -6,6 +6,7 @@ pub mod console; pub mod console_account; pub mod console_actions; pub mod console_code; +pub mod console_collaborators; pub mod console_data; pub mod console_observe; pub mod proxy; diff --git a/crates/outer/src/lib.rs b/crates/outer/src/lib.rs index 9bbbc1a..bbd03e5 100644 --- a/crates/outer/src/lib.rs +++ b/crates/outer/src/lib.rs @@ -7,6 +7,7 @@ pub mod accounts; pub mod apps; pub mod auth; pub mod cloudflare; +pub mod collaborators; pub mod config; pub mod cron; pub mod db; diff --git a/crates/outer/src/mcp/actions.rs b/crates/outer/src/mcp/actions.rs index ff5eb44..77c3aed 100644 --- a/crates/outer/src/mcp/actions.rs +++ b/crates/outer/src/mcp/actions.rs @@ -68,9 +68,11 @@ const LOGGED_TOOLS: &[&str] = &[ "list_secrets", "set_secret", "unset_secret", + "add_collaborator", + "remove_collaborator", + "list_collaborators", ]; -/// Truncate to `n` chars on a char boundary, marking the cut. /// Ceiling on one stored summary, in chars. const MAX_SUMMARY_CHARS: usize = 2000; @@ -179,7 +181,10 @@ fn summary_for(tool: &str, args: &Value) -> Option { arg_str(args, "slug").unwrap_or("?") ), "share_app" | "get_app" => tool.to_string(), - "list_secrets" => tool.to_string(), + "list_secrets" | "list_collaborators" => tool.to_string(), + "add_collaborator" | "remove_collaborator" => { + format!("{} {}", tool, arg_str(args, "email").unwrap_or("?")) + } "set_secret" | "unset_secret" => { format!("{} {}", tool, arg_str(args, "key").unwrap_or("?"),) } @@ -326,7 +331,14 @@ pub async fn record( let Some((app_id, owner_id)) = found else { return; }; - if owner_id != caller.account_id { + // Owners and collaborators both leave rows here (the console shows the + // log to both); a slug with no role writes nothing, so `not_found` + // probes leave no trace. + let role = + crate::collaborators::role(pool, &app_id, &owner_id, &caller.account_id, &caller.email) + .await + .unwrap_or(None); + if role.is_none() { return; } // Recompute: `summary_for` returned Some above, so this is Some too. @@ -472,7 +484,7 @@ fn client_label( } /// The app's action log, newest first. The caller must have checked that the -/// signed-in account may see `app_id` (e.g. the console's `owned_app`): this +/// signed-in account may see `app_id` (e.g. the console's `visible_app`): this /// filters by app only. `before` is the `(created_at, id)` /// cursor of the last row on the previous page; `limit` is clamped to /// 1–100. diff --git a/crates/outer/src/mcp/error.rs b/crates/outer/src/mcp/error.rs index a179f87..0b6b2d1 100644 --- a/crates/outer/src/mcp/error.rs +++ b/crates/outer/src/mcp/error.rs @@ -50,6 +50,9 @@ impl ToolError { Self::new("forbidden", message) } + pub fn payment_required(message: impl Into, user_message: impl Into) -> Self { + Self::new("payment_required", message).with_user(user_message) + } pub fn conflict(message: impl Into, current_sha: &str) -> Self { Self::new("conflict", message).with_extra("current_sha", json!(current_sha)) } diff --git a/crates/outer/src/mcp/registry.rs b/crates/outer/src/mcp/registry.rs index b972774..61c054d 100644 --- a/crates/outer/src/mcp/registry.rs +++ b/crates/outer/src/mcp/registry.rs @@ -5,14 +5,14 @@ use serde_json::Value; use crate::mcp::tools::{ - account, apps as app_inputs, code as code_inputs, runtime as runtime_inputs, - secrets as secrets_inputs, NoArgs, + account, apps as app_inputs, code as code_inputs, collaborators as collab_inputs, + runtime as runtime_inputs, secrets as secrets_inputs, NoArgs, }; /// Who may call the tool. `Public` tools run without a token; anything else /// answers `401` before it runs when the caller is anonymous (spec §6.1). -/// M1 has no admin tools; collaborators are Pro/M2, so every account tool is -/// owner-only. +/// Collaborators (Pro, spec §7.4) may call the read and change tools; the +/// owner-only tools refuse them with `forbidden`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Access { Public, @@ -88,9 +88,15 @@ pub fn hints_for(name: &str) -> Option { "about" | "docs" | "whoami" | "list_api_keys" | "list_connections" | "list_apps" | "get_app" | "share_app" | "list_files" | "read_file" | "search_code" | "history" | "show_commit" | "deploy_status" | "deploy_log" | "app_logs" | "app_errors" - | "list_secrets" => Hints::READ, + | "list_secrets" | "list_collaborators" => Hints::READ, "verify_code" | "create_api_key" | "create_app" | "commit" | "rollback" | "rename_app" | "set_secret" | "resolve_error" | "ignore_error" | "reopen_error" => Hints::WRITE, + // Emails an invite to an address outside Outer. + "add_collaborator" => Hints { + open_world: true, + ..Hints::WRITE + }, + "remove_collaborator" => Hints::DESTRUCTIVE, "set_visibility" => Hints { idempotent: true, ..Hints::WRITE @@ -235,6 +241,21 @@ pub fn registry() -> Vec { "Rename an app: {app, slug}. Moves its slug, hostname, code, data and files to the new name (D1 and Durable Object data survive); the old name stays reserved for 30 days and redirects to the new one. Counts one code change; a running app redeploys.", Account, false, ), + tool::( + "add_collaborator", + "Invite a collaborator to your Pro app by email: {app, email}. The address joins the closed beta and gets an invite email; the app shows in their list_apps once they sign in. Owner only; at most 10 collaborators per app.", + Account, false, + ), + tool::( + "remove_collaborator", + "Remove a collaborator from your app by email: {app, email}. Their access ends immediately. Owner only.", + Account, false, + ), + tool::( + "list_collaborators", + "List your app's collaborators: email, joined or pending status, and when each was added. Owner only.", + Account, false, + ), tool::( "list_files", "List an app's files at a commit: {app, sha?, path_prefix?}.", @@ -346,7 +367,7 @@ mod tests { #[test] fn registry_shapes() { let tools = registry(); - assert_eq!(tools.len(), 35); + assert_eq!(tools.len(), 38); } } @@ -355,7 +376,7 @@ mod hint_tests { use super::*; #[test] - fn every_tool_is_classified_and_only_revoke_delete_and_unset_are_destructive() { + fn every_tool_is_classified_and_only_revoke_delete_unset_and_remove_are_destructive() { for tool in registry() { let hints = hints_for(tool.name) .unwrap_or_else(|| panic!("{} has no annotation hints", tool.name)); @@ -371,6 +392,7 @@ mod hint_tests { | "delete_app" | "delete_account" | "unset_secret" + | "remove_collaborator" ); assert_eq!(hints.destructive, destructive, "{}", tool.name); // Untrusted tools return app content; they must never change state. diff --git a/crates/outer/src/mcp/tests.rs b/crates/outer/src/mcp/tests.rs index fea96c6..87e484a 100644 --- a/crates/outer/src/mcp/tests.rs +++ b/crates/outer/src/mcp/tests.rs @@ -334,7 +334,7 @@ async fn tools_list_filters_per_caller() { .iter() .map(|t| t["name"].as_str().unwrap().to_string()) .collect(); - assert_eq!(anon.len(), 35); + assert_eq!(anon.len(), 38); assert!(anon.contains(&"sign_in".to_string())); assert!(anon.contains(&"create_app".to_string())); @@ -351,7 +351,7 @@ async fn tools_list_filters_per_caller() { .iter() .map(|t| t["name"].as_str().unwrap().to_string()) .collect(); - assert_eq!(authed.len(), 33); + assert_eq!(authed.len(), 36); assert!(!authed.contains(&"sign_in".to_string())); assert!(!authed.contains(&"verify_code".to_string())); @@ -2515,6 +2515,396 @@ async fn secrets_cap_counts_distinct_names_and_values_stay_out_of_logs() { assert!(tools.contains(&"set_secret".to_string()), "{tools:?}"); } +/// Flip an account's plan by email. +async fn set_plan(state: &McpState, email: &str, plan: &str) { + sqlx::query("UPDATE user SET plan = ?1 WHERE email = ?2") + .bind(plan) + .bind(email) + .execute(&state.pool) + .await + .unwrap(); +} + +/// A Pro owner's app, returning the owner's token. +async fn pro_app(state: &McpState, owner_email: &str, slug: &str) -> String { + let token = api_key_for(state, owner_email).await; + set_plan(state, owner_email, "pro").await; + create_app(state, &token, slug).await; + token +} + +async fn invite(state: &McpState, token: &str, slug: &str, email: &str) -> Value { + let (_, body) = call( + state, + Some(token), + "add_collaborator", + json!({"app": slug, "email": email}), + ) + .await; + body +} + +#[tokio::test] +async fn collaborator_invite_needs_a_pro_owner() { + let (state, codes) = setup().await; + let owner = api_key_for(&state, "ana@example.com").await; + create_app(&state, &owner, "groceries").await; + + // A Free owner is refused with payment_required, pointing at Pro. + let refused = invite(&state, &owner, "groceries", "bruno@example.com").await; + let err = is_error(&refused); + assert_eq!(err["error"], "payment_required"); + assert!(err["user_message"].as_str().unwrap().contains("Pro")); + + set_plan(&state, "ana@example.com", "pro").await; + let added = result(&invite(&state, &owner, "groceries", "Bruno@Example.com").await) + ["structuredContent"] + .clone(); + assert_eq!(added["collaborator"]["email"], "bruno@example.com"); + assert_eq!(added["collaborator"]["status"], "pending"); + + // The invite allowlists the email and emails the spec wording. + let allowlisted: Option = + sqlx::query_scalar("SELECT email FROM beta_allowlist WHERE email = 'bruno@example.com'") + .fetch_optional(&state.pool) + .await + .unwrap(); + assert_eq!(allowlisted.as_deref(), Some("bruno@example.com")); + let mail = codes + .lock() + .unwrap() + .get("bruno@example.com") + .cloned() + .expect("invite email sent"); + assert!(mail.contains("groceries"), "{mail}"); + assert!(mail.contains("/mcp"), "{mail}"); + + // Inviting twice, inviting the owner, and inviting junk all refuse. + let dup_body = invite(&state, &owner, "groceries", "bruno@example.com").await; + assert_eq!(is_error(&dup_body)["error"], "invalid_argument"); + let self_body = invite(&state, &owner, "groceries", "ana@example.com").await; + assert_eq!(is_error(&self_body)["error"], "invalid_argument"); + let bad_body = invite(&state, &owner, "groceries", "not-an-email").await; + assert_eq!(is_error(&bad_body)["error"], "invalid_argument"); +} + +#[tokio::test] +async fn collaborator_cap_is_ten_per_app() { + let (state, _) = setup().await; + let owner = pro_app(&state, "ana@example.com", "groceries").await; + for n in 0..10 { + let body = invite( + &state, + &owner, + "groceries", + &format!("friend{n}@example.com"), + ) + .await; + assert!(result(&body).get("isError").is_none(), "{body}"); + } + let capped = invite(&state, &owner, "groceries", "extra@example.com").await; + let err = is_error(&capped); + assert_eq!(err["error"], "quota_exceeded"); + assert_eq!(err["quota"], "collaborators"); + assert_eq!(err["limit"], 10); + assert_eq!(err["used"], 10); + + let (_, list) = call( + &state, + Some(&owner), + "list_collaborators", + json!({"app": "groceries"}), + ) + .await; + let collaborators = result(&list)["structuredContent"]["collaborators"] + .as_array() + .unwrap(); + assert_eq!(collaborators.len(), 10); + assert!(collaborators.iter().all(|c| c["status"] == "pending")); +} + +#[tokio::test] +async fn collaborator_joins_through_the_closed_beta_without_invite_codes() { + let (mut state, codes) = setup().await; + state.auth.config.signup_open = false; + let owner = pro_app(&state, "ana@example.com", "groceries").await; + invite(&state, &owner, "groceries", "bruno@example.com").await; + + // A non-allowlisted stranger is still refused a code (the auth layer's + // `closed_beta` maps to `forbidden` on the wire). + let (_, refused) = call( + &state, + None, + "sign_in", + json!({"email": "stranger@example.com"}), + ) + .await; + assert_eq!(is_error(&refused)["error"], "forbidden"); + + // The invited email signs straight in: no invite code involved. + let (_, sent) = call( + &state, + None, + "sign_in", + json!({"email": "bruno@example.com"}), + ) + .await; + assert_eq!(result(&sent)["structuredContent"]["sent"], true); + let code = auth_ts::captured(&codes, "bruno@example.com"); + let (_, verified) = call( + &state, + None, + "verify_code", + json!({"email": "bruno@example.com", "code": code, "accept_terms": true}), + ) + .await; + let key = result(&verified)["structuredContent"]["api_key"] + .as_str() + .unwrap() + .to_string(); + + // The row links to the new account: joined, visible in list_apps. + let linked: Option = + sqlx::query_scalar("SELECT userId FROM app_collaborator WHERE email = 'bruno@example.com'") + .fetch_optional(&state.pool) + .await + .unwrap() + .flatten(); + assert!(linked.is_some()); + let (_, listed) = call( + &state, + Some(&owner), + "list_collaborators", + json!({"app": "groceries"}), + ) + .await; + assert_eq!( + result(&listed)["structuredContent"]["collaborators"][0]["status"], + "joined" + ); + let (_, apps) = call(&state, Some(&key), "list_apps", json!({})).await; + let apps = result(&apps)["structuredContent"]["apps"].clone(); + assert_eq!(apps.as_array().unwrap().len(), 1); + assert_eq!(apps[0]["slug"], "groceries"); + assert_eq!(apps[0]["role"], "collaborator"); +} + +#[tokio::test] +async fn collaborator_calls_allowed_tools_and_is_refused_owner_only_ones() { + let (state, _) = setup().await; + let owner = pro_app(&state, "ana@example.com", "groceries").await; + invite(&state, &owner, "groceries", "bruno@example.com").await; + let bruno = api_key_for(&state, "bruno@example.com").await; + + // Reads, changes and deploys all answer: none of these is owner-only. + for (name, args) in [ + ("get_app", json!({"app": "groceries"})), + ("share_app", json!({"app": "groceries"})), + ("list_files", json!({"app": "groceries"})), + ( + "read_file", + json!({"app": "groceries", "path": "pages/index.tsx"}), + ), + ( + "search_code", + json!({"app": "groceries", "query": "Groceries"}), + ), + ("history", json!({"app": "groceries"})), + ("deploy_status", json!({"app": "groceries"})), + ("app_logs", json!({"app": "groceries"})), + ("app_errors", json!({"app": "groceries"})), + ("list_secrets", json!({"app": "groceries"})), + ] { + let (_, body) = call(&state, Some(&bruno), name, args).await; + assert!(result(&body).get("isError").is_none(), "{name}: {body}"); + } + let (_, commit) = call( + &state, + Some(&bruno), + "commit", + json!({"app": "groceries", "message": "bruno was here", + "writes": [{"path": "note.txt", "content": "hi\n"}]}), + ) + .await; + assert!(result(&commit).get("isError").is_none(), "{commit}"); + let first = result(&commit)["structuredContent"]["commit"]["sha"].clone(); + // A second commit moves the head, so the rollback has a target. + let (_, second) = call( + &state, + Some(&bruno), + "commit", + json!({"app": "groceries", "message": "bruno again", + "writes": [{"path": "note.txt", "content": "hi again\n"}]}), + ) + .await; + assert!(result(&second).get("isError").is_none(), "{second}"); + let (_, rollback) = call( + &state, + Some(&bruno), + "rollback", + json!({"app": "groceries", "sha": first}), + ) + .await; + assert!(result(&rollback).get("isError").is_none(), "{rollback}"); + + // Owner-only tools refuse a collaborator with forbidden. + for (name, args) in [ + ( + "set_visibility", + json!({"app": "groceries", "visibility": "public"}), + ), + ( + "set_secret", + json!({"app": "groceries", "key": "K", "value": "v"}), + ), + ("unset_secret", json!({"app": "groceries", "key": "K"})), + ( + "delete_app", + json!({"app": "groceries", "confirm": "groceries"}), + ), + ( + "add_collaborator", + json!({"app": "groceries", "email": "x@example.com"}), + ), + ( + "remove_collaborator", + json!({"app": "groceries", "email": "bruno@example.com"}), + ), + ("list_collaborators", json!({"app": "groceries"})), + ] { + let (_, body) = call(&state, Some(&bruno), name, args).await; + assert_eq!(is_error(&body)["error"], "forbidden", "{name}: {body}"); + } + + // A slug with no role reads exactly like a missing one. + let (_, missing) = call(&state, Some(&bruno), "get_app", json!({"app": "nope"})).await; + assert_eq!(is_error(&missing)["error"], "not_found"); +} + +#[tokio::test] +async fn collaborator_removal_takes_effect_on_the_next_call() { + let (state, _) = setup().await; + let owner = pro_app(&state, "ana@example.com", "groceries").await; + invite(&state, &owner, "groceries", "bruno@example.com").await; + let bruno = api_key_for(&state, "bruno@example.com").await; + let (_, before) = call(&state, Some(&bruno), "get_app", json!({"app": "groceries"})).await; + assert!(result(&before).get("isError").is_none()); + + let (_, removed) = call( + &state, + Some(&owner), + "remove_collaborator", + json!({"app": "groceries", "email": "bruno@example.com"}), + ) + .await; + assert_eq!(result(&removed)["structuredContent"]["removed"], true); + let (_, after) = call(&state, Some(&bruno), "get_app", json!({"app": "groceries"})).await; + assert_eq!(is_error(&after)["error"], "not_found"); + let (_, apps) = call(&state, Some(&bruno), "list_apps", json!({})).await; + assert_eq!( + result(&apps)["structuredContent"]["apps"] + .as_array() + .unwrap() + .len(), + 0 + ); + // Removing someone who isn't there is the caller's bug. + let (_, again) = call( + &state, + Some(&owner), + "remove_collaborator", + json!({"app": "groceries", "email": "bruno@example.com"}), + ) + .await; + assert_eq!(is_error(&again)["error"], "invalid_argument"); +} + +#[tokio::test] +async fn collaborator_commit_counts_against_the_owner_quota() { + let (state, _) = setup().await; + let owner = pro_app(&state, "ana@example.com", "groceries").await; + invite(&state, &owner, "groceries", "bruno@example.com").await; + let bruno = api_key_for(&state, "bruno@example.com").await; + let owner_id: String = + sqlx::query_scalar("SELECT id FROM user WHERE email = 'ana@example.com'") + .fetch_one(&state.pool) + .await + .unwrap(); + let bruno_id: String = + sqlx::query_scalar("SELECT id FROM user WHERE email = 'bruno@example.com'") + .fetch_one(&state.pool) + .await + .unwrap(); + + let (_, commit) = call( + &state, + Some(&bruno), + "commit", + json!({"app": "groceries", "message": "bruno's change", + "writes": [{"path": "note.txt", "content": "hi\n"}]}), + ) + .await; + assert!(result(&commit).get("isError").is_none(), "{commit}"); + // create_app seeds git directly (no claim); the one commit lands on the + // owner, and nothing lands on the collaborator. + let owner_used: Option = + sqlx::query_scalar("SELECT code_changes FROM usage_day WHERE userId = ?1") + .bind(&owner_id) + .fetch_optional(&state.pool) + .await + .unwrap(); + assert_eq!(owner_used, Some(1)); + let bruno_used: Option = + sqlx::query_scalar("SELECT code_changes FROM usage_day WHERE userId = ?1") + .bind(&bruno_id) + .fetch_optional(&state.pool) + .await + .unwrap(); + assert_eq!(bruno_used, None); +} + +#[tokio::test] +async fn collaborator_access_follows_the_owner_plan() { + let (state, _) = setup().await; + let owner = pro_app(&state, "ana@example.com", "groceries").await; + invite(&state, &owner, "groceries", "bruno@example.com").await; + let bruno = api_key_for(&state, "bruno@example.com").await; + let (_, seen) = call(&state, Some(&bruno), "get_app", json!({"app": "groceries"})).await; + assert!(result(&seen).get("isError").is_none()); + + // Downgrade: the rows stay, but the app reads as missing. + set_plan(&state, "ana@example.com", "free").await; + let (_, hidden) = call(&state, Some(&bruno), "get_app", json!({"app": "groceries"})).await; + assert_eq!(is_error(&hidden)["error"], "not_found"); + let (_, apps) = call(&state, Some(&bruno), "list_apps", json!({})).await; + assert_eq!( + result(&apps)["structuredContent"]["apps"] + .as_array() + .unwrap() + .len(), + 0 + ); + // The invite list itself stays put through the downgrade. + let (_, listed) = call( + &state, + Some(&owner), + "list_collaborators", + json!({"app": "groceries"}), + ) + .await; + assert_eq!( + result(&listed)["structuredContent"]["collaborators"] + .as_array() + .unwrap() + .len(), + 1 + ); + + // Re-upgrade: access is back with no re-invite. + set_plan(&state, "ana@example.com", "pro").await; + let (_, back) = call(&state, Some(&bruno), "get_app", json!({"app": "groceries"})).await; + assert!(result(&back).get("isError").is_none()); +} /// `public` is a Pro feature, gated in the shared domain function: a Free /// owner is refused with `payment_required` (and a user message pointing to /// Pro), `unlisted` is always allowed, and Pro flips the row. diff --git a/crates/outer/src/mcp/tools/apps.rs b/crates/outer/src/mcp/tools/apps.rs index d8d3009..1d14812 100644 --- a/crates/outer/src/mcp/tools/apps.rs +++ b/crates/outer/src/mcp/tools/apps.rs @@ -1,14 +1,18 @@ //! The app tools: `create_app`, `list_apps`, `get_app`, `set_visibility`, -//! `share_app`. Owner only in M1 (collaborators are Pro/M2). +//! `share_app`, plus the collaborator tools (Pro, spec §7.4). Reads and +//! `share_app` admit collaborators; visibility, collaborators and delete +//! stay owner-only (spec §8.2 table). use schemars::JsonSchema; use serde::Deserialize; use serde_json::{json, Value}; use super::{ - authed, check_app_quota, internal, latest_deploy, ok, owned_app, run_deploy, Ctx, NoArgs, + authed, check_app_quota, internal, latest_deploy, ok, owner_app, run_deploy, visible_app, Ctx, + NoArgs, }; use crate::apps::{self, App, Visibility}; +use crate::collaborators::Role; use crate::mcp::error::ToolError; #[derive(Debug, Deserialize, JsonSchema)] @@ -183,13 +187,16 @@ pub(crate) fn app_status(app: &App, last_status: Option<&str>) -> &'static str { } } -pub async fn app_summary(ctx: &Ctx<'_>, app: &App) -> Result { +pub async fn app_summary(ctx: &Ctx<'_>, app: &App, role: Role) -> Result { let last = latest_deploy(&ctx.state.pool, &app.id).await?; let status = app_status(app, last.as_ref().map(|r| r.status.as_str())); let mut v = json!({ "slug": app.slug, "url": ctx.state.app_url(&app.slug), - "role": "owner", + "role": match role { + Role::Owner => "owner", + Role::Collaborator => "collaborator", + }, "visibility": app.visibility.as_str(), "status": status, }); @@ -203,22 +210,35 @@ pub async fn list_apps(_input: NoArgs, ctx: &Ctx<'_>) -> Result) -> Result { - let app = owned_app(ctx, &input.app).await?; + let (app, role) = visible_app(ctx, &input.app).await?; Ok(ok( format!("{} ({})", app.slug, ctx.state.app_url(&app.slug)), - app_summary(ctx, &app).await?, + app_summary(ctx, &app, role).await?, )) } pub async fn set_visibility(input: SetVisibilityInput, ctx: &Ctx<'_>) -> Result { - let app = owned_app(ctx, &input.app).await?; + let app = owner_app(ctx, &input.app).await?; let visibility = match input.visibility.as_str() { "unlisted" => Visibility::Unlisted, "public" => Visibility::Public, @@ -263,7 +283,7 @@ pub fn visibility_tool_error(slug: &str, err: crate::apps::VisibilityError) -> T } pub async fn share_app(input: ShareAppInput, ctx: &Ctx<'_>) -> Result { - let app = owned_app(ctx, &input.app).await?; + let (app, _) = visible_app(ctx, &input.app).await?; let url = ctx.state.app_url(&app.slug); let name = app.name.as_deref().unwrap_or(&app.slug); let text = format!("{name} is live at {url}"); @@ -288,7 +308,7 @@ pub async fn share_app(input: ShareAppInput, ctx: &Ctx<'_>) -> Result) -> Result { // `not_found` first: a foreign slug never reaches the confirm check. - let app = owned_app(ctx, &input.app).await?; + let app = owner_app(ctx, &input.app).await?; // Serialize against a racing rollback, deploy and wake: rollback waits // on its guard instead of failing, while a deploy or wake holds its own // lock and runs to a state the idempotent steps tolerate. @@ -352,7 +372,7 @@ pub async fn delete_app(input: DeleteAppInput, ctx: &Ctx<'_>) -> Result) -> Result { // `not_found` first: a foreign slug never reaches validation or quota. - let app = owned_app(ctx, &input.app).await?; + let app = owner_app(ctx, &input.app).await?; let caller = authed(ctx)?; if input.app == input.slug { return Err(ToolError::invalid_argument(format!( diff --git a/crates/outer/src/mcp/tools/code.rs b/crates/outer/src/mcp/tools/code.rs index fc43426..7447372 100644 --- a/crates/outer/src/mcp/tools/code.rs +++ b/crates/outer/src/mcp/tools/code.rs @@ -6,8 +6,8 @@ use serde::Deserialize; use serde_json::{json, Value}; use super::{ - authed, check_rev, claim_change, internal, ok, ok_deploy, ok_untrusted, owned_app, - refund_change, run_deploy, Ctx, + authed, check_rev, claim_change, internal, ok, ok_deploy, ok_untrusted, refund_change, + run_deploy, visible_app, Ctx, }; use crate::apps::App; use crate::git::{self, CommitError}; @@ -137,7 +137,7 @@ async fn mirror(ctx: &Ctx<'_>, slug: &str) -> Result) -> Result { - let app = owned_app(ctx, &input.app).await?; + let (app, _) = visible_app(ctx, &input.app).await?; // Validate before git runs: bad shape is `invalid_argument` with a // message built only from agent input, so no server path can leak. // Anything the mirror itself reports after that is `internal`. @@ -175,7 +175,7 @@ pub async fn read_file(input: ReadFileInput, ctx: &Ctx<'_>) -> Result) -> Result) -> Result { - let app = owned_app(ctx, &input.app).await?; + let (app, _) = visible_app(ctx, &input.app).await?; let bare = mirror(ctx, &app.slug).await?; let (sha, hits, truncated) = crate::git::local::grep( &bare, @@ -354,7 +354,7 @@ fn to_commit_request( } pub async fn commit(input: CommitInput, ctx: &Ctx<'_>) -> Result { - let app = owned_app(ctx, &input.app).await?; + let (app, _) = visible_app(ctx, &input.app).await?; let caller = authed(ctx)?; let author = noreply_author(&caller.account_id, &caller.email, &ctx.state.domain); let request = to_commit_request(&input, author)?; @@ -364,7 +364,9 @@ pub async fn commit(input: CommitInput, ctx: &Ctx<'_>) -> Result) -> Result outcome, Err(e) => { - if let Err(refund) = refund_change(&ctx.state.pool, &caller.account_id).await { + if let Err(refund) = refund_change(&ctx.state.pool, &app.owner_id).await { tracing::warn!("commit refund failed: {refund:#}"); } return Err(ToolError::from_commit(e)); @@ -411,7 +413,7 @@ pub async fn commit(input: CommitInput, ctx: &Ctx<'_>) -> Result) -> Result { - let app = owned_app(ctx, &input.app).await?; + let (app, _) = visible_app(ctx, &input.app).await?; if let Some(path) = input.path.as_deref() { crate::git::local::validate_read_path(path) .map_err(|e| ToolError::invalid_argument(e.to_string()))?; @@ -440,7 +442,7 @@ pub async fn history(input: HistoryInput, ctx: &Ctx<'_>) -> Result) -> Result { - let app = owned_app(ctx, &input.app).await?; + let (app, _) = visible_app(ctx, &input.app).await?; check_rev("sha", &input.sha)?; let bare = mirror(ctx, &app.slug).await?; let shown = crate::git::local::show(&bare, &input.sha) @@ -477,7 +479,7 @@ pub async fn show_commit(input: ShowCommitInput, ctx: &Ctx<'_>) -> Result) -> Result { - let app = owned_app(ctx, &input.app).await?; + let (app, _) = visible_app(ctx, &input.app).await?; let caller = authed(ctx)?; rollback_to( ctx.state, @@ -493,8 +495,9 @@ pub async fn rollback(input: RollbackInput, ctx: &Ctx<'_>) -> Result Value { + json!({ + "email": collaborator.email, + "status": if collaborator.joined { "joined" } else { "pending" }, + "added_at": collaborator.added_at, + }) +} + +pub async fn add_collaborator( + input: AddCollaboratorInput, + ctx: &Ctx<'_>, +) -> Result { + let app = owner_app(ctx, &input.app).await?; + let caller = super::authed(ctx)?; + match collaborators::add( + &ctx.state.pool, + &ctx.state.auth.config.email, + &ctx.state.mcp_url(), + &app, + &caller.email, + &input.email, + ) + .await + { + Ok(collaborator) => Ok(ok( + format!("{} can now change {}.", collaborator.email, app.slug), + json!({ + "app": app.slug, + "collaborator": collaborator_json(&collaborator), + }), + )), + Err(AddError::NotPro) => Err(ToolError::payment_required( + format!("collaborators on {} need a Pro owner", app.slug), + "Collaborators are a Pro feature. Upgrade to Pro to invite people to your apps.", + )), + Err(AddError::LimitReached) => Err(ToolError::quota_exceeded( + format!( + "app quota: {} collaborators allowed, {} on {}", + collaborators::MAX_COLLABORATORS, + collaborators::MAX_COLLABORATORS, + app.slug + ), + format!( + "{} already has the maximum {} collaborators.", + app.slug, + collaborators::MAX_COLLABORATORS + ), + "collaborators", + collaborators::MAX_COLLABORATORS, + collaborators::MAX_COLLABORATORS, + None, + )), + Err(AddError::InvalidEmail(_)) => Err(ToolError::invalid_argument(format!( + "{:?} is not an address that can be invited to {}", + input.email, app.slug + ))), + Err(AddError::SelfInvite) => Err(ToolError::invalid_argument(format!( + "you already own {}; invite someone else's address", + app.slug + ))), + Err(AddError::Duplicate) => Err(ToolError::invalid_argument(format!( + "{:?} is already a collaborator on {}", + crate::auth::normalize_email(&input.email), + app.slug + ))), + Err(AddError::Internal(err)) => Err(internal(err)), + } +} + +pub async fn remove_collaborator( + input: RemoveCollaboratorInput, + ctx: &Ctx<'_>, +) -> Result { + let app = owner_app(ctx, &input.app).await?; + let removed = collaborators::remove(&ctx.state.pool, &app.id, &input.email) + .await + .map_err(internal)?; + if !removed { + return Err(ToolError::invalid_argument(format!( + "{:?} is not a collaborator on {}", + crate::auth::normalize_email(&input.email), + app.slug + ))); + } + Ok(ok( + format!( + "{} can no longer change {}.", + crate::auth::normalize_email(&input.email), + app.slug + ), + json!({ + "app": app.slug, + "email": crate::auth::normalize_email(&input.email), + "removed": true, + }), + )) +} + +pub async fn list_collaborators( + input: ListCollaboratorsInput, + ctx: &Ctx<'_>, +) -> Result { + let app = owner_app(ctx, &input.app).await?; + let collaborators = collaborators::list(&ctx.state.pool, &app.id) + .await + .map_err(internal)?; + Ok(ok( + format!("{} collaborator(s) on {}.", collaborators.len(), app.slug), + json!({ + "app": app.slug, + "collaborators": collaborators.iter().map(collaborator_json).collect::>(), + }), + )) +} diff --git a/crates/outer/src/mcp/tools/mod.rs b/crates/outer/src/mcp/tools/mod.rs index 8f06285..047bbc3 100644 --- a/crates/outer/src/mcp/tools/mod.rs +++ b/crates/outer/src/mcp/tools/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod account; pub(crate) mod apps; pub(crate) mod code; +pub(crate) mod collaborators; pub(crate) mod runtime; pub(crate) mod secrets; @@ -108,17 +109,47 @@ pub fn authed<'x, 'a>(ctx: &'x Ctx<'a>) -> Result<&'x Principal, ToolError> { .ok_or_else(|| ToolError::internal("unauthenticated tool call")) } -/// The app `slug` names, owned by the caller. Anything else — missing, or -/// owned by someone else — is `not_found`, never `forbidden` (spec §8.2: a -/// slug the caller has no role on answers exactly like a missing one). -pub async fn owned_app(ctx: &Ctx<'_>, slug: &str) -> Result { +/// The app `slug` names, visible to the caller as owner or collaborator. +/// Anything else — missing, or an app the caller has no role on — is +/// `not_found`, never `forbidden` (spec §8.2: a slug the caller has no role +/// on answers exactly like a missing one). Collaborators only count while +/// the owner's plan is Pro (spec §7.4). +pub async fn visible_app( + ctx: &Ctx<'_>, + slug: &str, +) -> Result<(App, crate::collaborators::Role), ToolError> { let caller = authed(ctx)?; let app = crate::apps::get_by_slug(&ctx.state.pool, slug) .await .map_err(internal)?; - match app { - Some(app) if app.owner_id == caller.account_id => Ok(app), - _ => Err(ToolError::not_found(format!("no app {slug:?}"))), + let Some(app) = app else { + return Err(ToolError::not_found(format!("no app {slug:?}"))); + }; + match crate::collaborators::role( + &ctx.state.pool, + &app.id, + &app.owner_id, + &caller.account_id, + &caller.email, + ) + .await + .map_err(internal)? + { + Some(role) => Ok((app, role)), + None => Err(ToolError::not_found(format!("no app {slug:?}"))), + } +} + +/// The app `slug` names, owned by the caller. A collaborator's call answers +/// `forbidden` (spec §8.2 table); anything else is `not_found` as usual. +pub async fn owner_app(ctx: &Ctx<'_>, slug: &str) -> Result { + let (app, role) = visible_app(ctx, slug).await?; + if role == crate::collaborators::Role::Owner { + Ok(app) + } else { + Err(ToolError::forbidden(format!( + "only the app owner may do this to {slug:?}" + ))) } } @@ -737,6 +768,15 @@ pub async fn dispatch(tool: &str, args: Option, ctx: &Ctx<'_>) -> Result< "rename_app" => apps::rename_app(parse_args(args)?, ctx) .await .map_err(CallError::from), + "add_collaborator" => collaborators::add_collaborator(parse_args(args)?, ctx) + .await + .map_err(CallError::from), + "remove_collaborator" => collaborators::remove_collaborator(parse_args(args)?, ctx) + .await + .map_err(CallError::from), + "list_collaborators" => collaborators::list_collaborators(parse_args(args)?, ctx) + .await + .map_err(CallError::from), "list_files" => code::list_files(parse_args(args)?, ctx) .await .map_err(CallError::from), diff --git a/crates/outer/src/mcp/tools/runtime.rs b/crates/outer/src/mcp/tools/runtime.rs index f7821a1..a1e6bf5 100644 --- a/crates/outer/src/mcp/tools/runtime.rs +++ b/crates/outer/src/mcp/tools/runtime.rs @@ -5,8 +5,7 @@ use schemars::JsonSchema; use serde::Deserialize; use serde_json::{json, Value}; -use super::{await_row, deploy_by, latest_deploy, ok, ok_untrusted, owned_app, Ctx}; -use crate::mcp::error::ToolError; +use super::{await_row, deploy_by, latest_deploy, ok, ok_untrusted, visible_app, Ctx}; #[derive(Debug, Deserialize, JsonSchema)] pub struct DeployStatusInput { @@ -37,7 +36,7 @@ pub struct AppLogsInput { } pub async fn deploy_status(input: DeployStatusInput, ctx: &Ctx<'_>) -> Result { - let app = owned_app(ctx, &input.app).await?; + let (app, _) = visible_app(ctx, &input.app).await?; if let Some(deploy_id) = input.deploy_id.as_deref() { if deploy_by(&ctx.state.pool, &app.id, Some(deploy_id)) .await? @@ -62,7 +61,7 @@ pub async fn deploy_status(input: DeployStatusInput, ctx: &Ctx<'_>) -> Result) -> Result { - let app = owned_app(ctx, &input.app).await?; + let (app, _) = visible_app(ctx, &input.app).await?; let row = deploy_by(&ctx.state.pool, &app.id, input.deploy_id.as_deref()).await?; let Some(row) = row else { return Err(ToolError::not_found("no deploys for this app yet")); @@ -99,7 +98,7 @@ pub async fn log_lines( } pub async fn app_logs(input: AppLogsInput, ctx: &Ctx<'_>) -> Result { - let app = owned_app(ctx, &input.app).await?; + let (app, _) = visible_app(ctx, &input.app).await?; let limit = input.limit.unwrap_or(100).clamp(1, 500) as usize; let lines: Vec = log_lines( &ctx.state.pool, @@ -142,7 +141,7 @@ pub struct AppErrorsInput { /// from `metric_minute`, never counted twice). Untrusted like `app_logs`: /// messages and frames can hold app or visitor text. pub async fn app_errors(input: AppErrorsInput, ctx: &Ctx<'_>) -> Result { - let app = owned_app(ctx, &input.app).await?; + let (app, _) = visible_app(ctx, &input.app).await?; if let Some(status) = input.status.as_deref() { if !matches!(status, "open" | "resolved" | "ignored") { return Err(ToolError::invalid_argument(format!( @@ -242,7 +241,7 @@ async fn set_issue_status( fingerprint: &str, status: &'static str, ) -> Result { - let app = owned_app(ctx, app_slug).await?; + let (app, _) = visible_app(ctx, app_slug).await?; if fingerprint.is_empty() || fingerprint.len() > 64 { return Err(ToolError::invalid_argument(format!( "bad fingerprint {fingerprint:?}: expected the app_errors fingerprint" diff --git a/crates/outer/src/mcp/tools/secrets.rs b/crates/outer/src/mcp/tools/secrets.rs index 41d42fd..3b1c016 100644 --- a/crates/outer/src/mcp/tools/secrets.rs +++ b/crates/outer/src/mcp/tools/secrets.rs @@ -1,7 +1,7 @@ -//! The secret tools: `list_secrets`, `set_secret`, `unset_secret` (spec -//! §8.2, §17). Owner only in M1 (collaborators are Pro/M2): [`owned_app`] -//! refuses anything else as `not_found`, so the spec's owner/collaborator -//! table lands automatically when roles do. +//! The secret tools: `list_secrets` (owners and collaborators, spec §8.2 +//! table), `set_secret` and `unset_secret` (owner only). Owner-only calls +//! from a collaborator answer `forbidden`; a slug with no role answers +//! `not_found`, exactly like a missing one. //! //! Values are never returned, logged or put in an action-log summary: only //! names travel. What a set changes takes effect at the next deploy, when @@ -11,7 +11,7 @@ use schemars::JsonSchema; use serde::Deserialize; use serde_json::{json, Value}; -use super::{internal, ok, owned_app, Ctx}; +use super::{internal, ok, owner_app, visible_app, Ctx}; use crate::mcp::error::ToolError; use crate::secrets; @@ -50,7 +50,7 @@ pub struct UnsetSecretInput { } pub async fn list_secrets(input: ListSecretsInput, ctx: &Ctx<'_>) -> Result { - let app = owned_app(ctx, &input.app).await?; + let (app, _) = visible_app(ctx, &input.app).await?; let metas = secrets::list(&ctx.state.pool, &app.id) .await .map_err(internal)?; @@ -65,7 +65,7 @@ pub async fn list_secrets(input: ListSecretsInput, ctx: &Ctx<'_>) -> Result) -> Result { - let app = owned_app(ctx, &input.app).await?; + let app = owner_app(ctx, &input.app).await?; secrets::check_name(&input.key).map_err(ToolError::invalid_argument)?; secrets::check_reserved(&input.key).map_err(ToolError::invalid_argument)?; if input.value.len() > secrets::MAX_VALUE_BYTES { @@ -107,7 +107,7 @@ pub async fn set_secret(input: SetSecretInput, ctx: &Ctx<'_>) -> Result) -> Result { - let app = owned_app(ctx, &input.app).await?; + let app = owner_app(ctx, &input.app).await?; secrets::check_name(&input.key).map_err(ToolError::invalid_argument)?; let removed = secrets::unset(&ctx.state.pool, &app.id, &input.key) .await diff --git a/web/docs/reference/tools.md b/web/docs/reference/tools.md index a96340e..165fe17 100644 --- a/web/docs/reference/tools.md +++ b/web/docs/reference/tools.md @@ -187,12 +187,42 @@ Every Outer tool in `tools/list` order, rendered from the registry. Generated - **Access:** signed in - **Description:** Rename an app: {app, slug}. Moves its slug, hostname, code, data and files to the new name (D1 and Durable Object data survive); the old name stays reserved for 30 days and redirects to the new one. Counts one code change; a running app redeploys. +## `add_collaborator` + +- **Access:** signed in +- **Description:** Invite a collaborator to your Pro app by email: {app, email}. The address joins the closed beta and gets an invite email; the app shows in their list_apps once they sign in. Owner only; at most 10 collaborators per app. + **Input:** | Property | Type | Required | Description | | --- | --- | --- | --- | | `app` | string | yes | The app's current slug. | | `slug` | string | yes | The new slug: lowercase letters, digits and dashes, 1-48 chars. The old name stays reserved for 30 days and redirects to the new one. | +| `app` | string | yes | The app slug. | +| `email` | string | yes | The collaborator's email: allowlisted for the closed beta and emailed the invite. | + +## `remove_collaborator` + +- **Access:** signed in +- **Description:** Remove a collaborator from your app by email: {app, email}. Their access ends immediately. Owner only. + +**Input:** + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `app` | string | yes | The app slug. | +| `email` | string | yes | The collaborator's email to remove. | + +## `list_collaborators` + +- **Access:** signed in +- **Description:** List your app's collaborators: email, joined or pending status, and when each was added. Owner only. + +**Input:** + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `app` | string | yes | The app slug. | ## `list_files` diff --git a/web/pages/console/_islands/actions/collaborators.ts b/web/pages/console/_islands/actions/collaborators.ts new file mode 100644 index 0000000..fc91d3b --- /dev/null +++ b/web/pages/console/_islands/actions/collaborators.ts @@ -0,0 +1,93 @@ +// Console action registry for collaborators (Pro, spec §7.4): every +// collaborators mutation or query the Settings card performs goes through +// one exported entry here, so the later ⌘K and WebMCP layer can import +// `actions/*.ts` wholesale. The Settings UI calls `run`. +import { request } from "./http.ts"; +import type { ConsoleAction } from "./settings.ts"; +import type { CollaboratorEntry, CollaboratorsSummary } from "../api.ts"; + +function slugPath(slug: string, suffix: string): string { + return `/api/console/apps/${encodeURIComponent(slug)}${suffix}`; +} + +const slugSchema = { + type: "object", + properties: { app: { type: "string", description: "The app slug." } }, + required: ["app"], +}; + +const emailSchema = { + type: "object", + properties: { + app: { type: "string", description: "The app slug." }, + email: { type: "string", description: "The collaborator's email address." }, + }, + required: ["app", "email"], +}; + +export interface ListCollaboratorsInput { + app: string; +} + +export interface AddCollaboratorInput { + app: string; + email: string; +} + +export interface AddCollaboratorOutput { + collaborator: CollaboratorEntry; +} + +export interface RemoveCollaboratorOutput { + removed: boolean; +} + +const listCollaborators: ConsoleAction = { + name: "list_collaborators", + title: "List collaborators", + description: + "List an app's collaborators: email, joined or pending status, and when each was added. Owners and collaborators can read.", + inputSchema: slugSchema, + readOnly: true, + consequential: false, + untrusted: true, + run(input, signal?: AbortSignal): Promise { + return request("GET", slugPath(input.app, "/collaborators"), undefined, signal); + }, +}; + +const addCollaborator: ConsoleAction = { + name: "add_collaborator", + title: "Invite collaborator", + description: + "Invite a collaborator to a Pro app by email. The address joins the closed beta and gets an invite email. Owner only; at most 10 collaborators per app.", + inputSchema: emailSchema, + readOnly: false, + consequential: true, + untrusted: false, + run(input, signal?: AbortSignal): Promise { + return request("POST", slugPath(input.app, "/collaborators"), { email: input.email }, signal); + }, +}; + +const removeCollaborator: ConsoleAction = { + name: "remove_collaborator", + title: "Remove collaborator", + description: + "Remove a collaborator from an app by email. Their access ends immediately. Owner only; confirms in the UI before running.", + inputSchema: emailSchema, + readOnly: false, + consequential: true, + untrusted: false, + run(input, signal?: AbortSignal): Promise { + return request("POST", slugPath(input.app, "/collaborators/remove"), { email: input.email }, signal); + }, +}; + +export const actions: ConsoleAction[] = [ + listCollaborators as ConsoleAction, + addCollaborator as ConsoleAction, + removeCollaborator as ConsoleAction, +]; + +export const collaboratorActions = { listCollaborators, addCollaborator, removeCollaborator }; diff --git a/web/pages/console/_islands/api.ts b/web/pages/console/_islands/api.ts index bb8e3d2..283024c 100644 --- a/web/pages/console/_islands/api.ts +++ b/web/pages/console/_islands/api.ts @@ -199,6 +199,19 @@ export interface SecretsSummary { secrets: SecretEntry[]; } +export interface CollaboratorEntry { + email: string; + status: "joined" | "pending"; + added_at: number; +} + +export interface CollaboratorsSummary { + app: string; + role: string; + owner_plan: string; + collaborators: CollaboratorEntry[]; +} + export interface InviteEntry { code: string; kind: string; diff --git a/web/pages/console/_islands/console.tsx b/web/pages/console/_islands/console.tsx index 76cf71e..00e4c05 100644 --- a/web/pages/console/_islands/console.tsx +++ b/web/pages/console/_islands/console.tsx @@ -16,6 +16,7 @@ import { type AdminInvitesPage, type ApiKeysSummary, type AppDetail, + type CollaboratorsSummary, type ConnectionsSummary, type ConsoleApp, type ErrorsSummary, @@ -198,6 +199,7 @@ export function Console() { const connections = atom>({ kind: "loading" }); const apiKeys = atom>({ kind: "loading" }); const accountSummary = atom>({ kind: "loading" }); + const collaborators = atom>({ kind: "loading" }); const ui = atom({ collapsed: readCollapsed(), drawer: false, @@ -219,7 +221,7 @@ export function Console() { : "Console · Outer"; } - function loadersFor(view: ViewId): Array<"metrics" | "actions" | "errors" | "logs" | "secrets"> { + function loadersFor(view: ViewId): Array<"metrics" | "actions" | "errors" | "logs" | "secrets" | "collaborators"> { switch (view) { case "overview": return ["metrics", "actions", "errors"]; @@ -238,6 +240,8 @@ export function Console() { case "secrets": return ["secrets"]; case "settings": + case "settings": + return ["collaborators"]; case "connections": case "usage": case "account": @@ -358,6 +362,24 @@ export function Console() { }), ); } + if (loadersFor(view).includes("collaborators")) { + if (!quiet) { + collaborators.set({ kind: "loading" }); + } + jobs.push( + api( + `/api/console/apps/${encodeURIComponent(slug)}/collaborators`, + ) + .then((summary) => { + put(collaborators, { kind: "ready", value: summary }); + }) + .catch((err: unknown) => { + if (!quiet) { + put(collaborators, { kind: "error", message: String(err) }); + } + }), + ); + } await Promise.all(jobs); } @@ -505,6 +527,7 @@ export function Console() { errors.set({ kind: "loading" }); logs.set({ kind: "loading" }); secrets.set({ kind: "loading" }); + collaborators.set({ kind: "loading" }); try { const app = await api( `/api/console/apps/${encodeURIComponent(r.app)}`, @@ -871,9 +894,9 @@ export function Console() { type="button" role="listitem" class={`rail-tile tooltip tooltip-right${a.slug === r.app ? " is-active" : ""}`} - data-tip={`${a.slug} · ${a.status}`} - title={`${a.slug} · ${a.status}`} - aria-label={`${a.slug}, ${a.status}`} + data-tip={a.role === "collaborator" ? `${a.slug} · ${a.status} · Shared` : `${a.slug} · ${a.status}`} + title={a.role === "collaborator" ? `${a.slug} · ${a.status} · Shared` : `${a.slug} · ${a.status}`} + aria-label={a.role === "collaborator" ? `${a.slug}, ${a.status}, shared with you` : `${a.slug}, ${a.status}`} aria-current={a.slug === r.app ? "page" : undefined} onclick={() => { if (a.slug !== r.app) { @@ -1147,6 +1170,7 @@ export function Console() { errors={errors()} logs={logs()} secrets={secrets()} + collaborators={collaborators()} expanded={ui().expanded} onToggleExpand={(id) => { const cur = ui().expanded; @@ -1185,6 +1209,14 @@ export function Console() { onRenamed={afterRename} onDeleted={afterDelete} plan={plan()} + onCollaboratorsChanged={() => { + const rr = route(); + // Refresh the card through the same quiet path as the + // 10 s poll: no skeleton flash, epoch-guarded. + if (rr.app) { + void loadViewData(rr.app, "settings", rr.range, epoch, true); + } + }} /> )} @@ -1203,6 +1235,9 @@ export function Console() {

{active.status} + {active.role === "collaborator" && ( + Shared + )} + + + ); + } + const summary = panel.value; + const isOwner = summary.role === "owner"; + const isPro = summary.owner_plan === "pro"; + const entries = summary.collaborators; + + async function invite(): Promise { + const input = document.getElementById("collab-email") as HTMLInputElement | null; + const email = (input?.value ?? "").trim(); + if (!email) { + error.set("Type an email address to invite."); + return; + } + busy.set(true); + error.set(null); + try { + await collaboratorAction("add_collaborator").run({ app: app.slug, email }); + if (input) { + input.value = ""; + } + onChanged(); + } catch (err: unknown) { + error.set(err instanceof Error ? err.message : String(err)); + } finally { + busy.set(false); + } + } + + async function remove(email: string): Promise { + busy.set(true); + error.set(null); + try { + await collaboratorAction("remove_collaborator").run({ app: app.slug, email }); + confirming.set(null); + onChanged(); + } catch (err: unknown) { + error.set(err instanceof Error ? err.message : String(err)); + } finally { + busy.set(false); + } + } + + return ( +
+
+
+

Collaborators

+ + {entries.length} / 10 + + {isOwner && !isPro && ( + + Pro + + )} +
+ {isOwner && !isPro && ( +

+ Collaborators are a Pro feature. Upgrade to Pro to invite people to + your apps — up to 10 per app. +

+ )} + {!isOwner && ( +

+ Shared with you — you can change this app, but only the owner + manages collaborators. +

+ )} + {isOwner && ( +
+ + +
+ )} + {error() && ( + + )} + {entries.length === 0 ? ( + } title="No collaborators" plain> + {isOwner + ? "Invite someone by email and they show here once added." + : "Nobody else has been invited to this app."} + + ) : ( +
+ + + + + + {isOwner && } + + + + + + + {isOwner && } + + + + {entries.map((entry) => ( + + + + + {isOwner && ( + + )} + + ))} + +
EmailStatusRemove
+ + {entry.email} + + + + {entry.status === "joined" ? "Joined" : "Pending"} + + + +
+
+ )} + {confirming() && ( + + )} +
+
+ ); +} + export function SettingsView({ app, plan, onRefresh, onRenamed, onDeleted, + collaborators, + onCollaboratorsRetry, + onCollaboratorsChanged, }: { app: AppDetail; plan: string; onRefresh: () => void; onRenamed: (slug: string) => void; onDeleted: () => void; + collaborators: Panel; + onCollaboratorsRetry: () => void; + onCollaboratorsChanged: () => void; }) { return (
@@ -1541,8 +1785,14 @@ export function SettingsView({
- - + {app.role === "owner" && } + + {app.role === "owner" && } ); } From d4333bd21aaa7f610998953583a0609ff84d1154 Mon Sep 17 00:00:00 2001 From: omp Date: Sun, 25 Oct 2026 14:19:50 +0100 Subject: [PATCH 2/4] Collaborators on new endpoints: rename owner-only, triage+observe shared, UI read-only MCP: rename_app gates on owner_app (forbidden for collaborators); resolve/ignore/reopen gate on visible_app (collaborators may triage: spec 7.4 owner-only is membership/visibility/secrets/destructive, triage is operational like commit/rollback). Observe reads unchanged. Console: rename endpoint uses owner_mutation_app; error detail, triage and logs stream use visible_app; the follow tick rechecks the caller role every tick. Collaborator audit rows carry the new duration_ms. UI: Secrets add/replace/remove and all D1 write controls hide for collaborators (read-only notice instead); triage buttons stay. Tests: MCP rename forbidden + collaborator triage round-trip; console rename 403, collaborator triage/observe 200, stranger 404. --- crates/outer/src/http/console.rs | 86 ++++++++++++++- crates/outer/src/http/console_observe.rs | 34 ++++-- crates/outer/src/mcp/tests.rs | 62 +++++++++++ crates/outer/src/mcp/tools/runtime.rs | 10 +- web/docs/reference/tools.md | 9 +- web/pages/console/_islands/console.tsx | 12 +- web/pages/console/_islands/data/d1/grid.tsx | 103 ++++++++++-------- web/pages/console/_islands/data/d1/panel.tsx | 9 ++ .../console/_islands/data/d1/toolbar.tsx | 31 +++--- web/pages/console/_islands/views.tsx | 99 ++++++++++------- web/pages/console/_islands/views/d1.tsx | 12 ++ 11 files changed, 350 insertions(+), 117 deletions(-) diff --git a/crates/outer/src/http/console.rs b/crates/outer/src/http/console.rs index 82d5e22..a371467 100644 --- a/crates/outer/src/http/console.rs +++ b/crates/outer/src/http/console.rs @@ -3570,13 +3570,17 @@ mod tests { "invalid_argument" ); - // Visibility, secret writes and delete stay owner-only: a + // Visibility, rename, secret writes and delete stay owner-only: a // collaborator reads 403 with the MCP code and a user message. for (path, body) in [ ( "/api/console/apps/groceries/visibility", serde_json::json!({"visibility": "unlisted"}), ), + ( + "/api/console/apps/groceries/rename", + serde_json::json!({"slug": "brand-new"}), + ), ( "/api/console/apps/groceries/delete", serde_json::json!({"confirm": "groceries"}), @@ -3630,4 +3634,84 @@ mod tests { .await; assert_eq!(status, StatusCode::NOT_FOUND); } + + #[tokio::test] + async fn console_collaborator_may_triage_and_read_observe() { + let (app, pool, _, _) = test_app().await; + let now = crate::auth::now_ms(); + seed_app(&pool, "ana@example.com", "groceries", now).await; + sqlx::query("UPDATE user SET plan = 'pro' WHERE email = 'ana@example.com'") + .execute(&pool) + .await + .unwrap(); + seed_account(&pool, "bruno@example.com").await; + seed_account(&pool, "stranger@example.com").await; + sqlx::query( + "INSERT INTO app_collaborator (app_id, email, userId, added_at) \ + VALUES ('app-groceries', 'bruno@example.com', 'acct-bruno@example.com', ?1)", + ) + .bind(now) + .execute(&pool) + .await + .unwrap(); + seed_issue(&pool, "fp1", "open", None, now).await; + let bruno = login(&pool, "bruno@example.com").await; + let stranger = login(&pool, "stranger@example.com").await; + + // Observe reads admit collaborators: the error list and one issue. + let (status, _, _) = send(&app, authed("/api/console/apps/groceries/errors", &bruno)).await; + assert_eq!(status, StatusCode::OK); + let (status, _, _) = send( + &app, + authed("/api/console/apps/groceries/errors/fp1", &bruno), + ) + .await; + assert_eq!(status, StatusCode::OK); + + // Triage admits collaborators, like the MCP tools: resolve one + // issue, then ignore the selection through bulk. + let (status, _, body) = send( + &app, + post_json( + "/api/console/apps/groceries/errors/fp1/resolve", + &bruno, + serde_json::json!({}), + ), + ) + .await; + assert_eq!(status, StatusCode::OK); + let triaged: serde_json::Value = body.parse().unwrap(); + assert_eq!(triaged["status"], "resolved"); + let (status, _, body) = send( + &app, + post_json( + "/api/console/apps/groceries/errors/bulk", + &bruno, + serde_json::json!({"fingerprints": ["fp1"], "action": "ignore"}), + ), + ) + .await; + assert_eq!(status, StatusCode::OK); + let bulk: serde_json::Value = body.parse().unwrap(); + assert_eq!(bulk["updated"], 1); + + // A stranger's slug reads 404 exactly like a missing one, for + // reads and triage alike. + let (status, _, _) = send( + &app, + authed("/api/console/apps/groceries/errors/fp1", &stranger), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + let (status, _, _) = send( + &app, + post_json( + "/api/console/apps/groceries/errors/fp1/resolve", + &stranger, + serde_json::json!({}), + ), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + } } diff --git a/crates/outer/src/http/console_observe.rs b/crates/outer/src/http/console_observe.rs index 46c22bd..a273830 100644 --- a/crates/outer/src/http/console_observe.rs +++ b/crates/outer/src/http/console_observe.rs @@ -120,17 +120,25 @@ async fn api_logs_stream( seed, true, ), - move |(pool, fleets, account_id, account_email, app_id, slug, filter, mut cursor, mut first)| async move { + move |( + pool, + fleets, + account_id, + account_email, + app_id, + slug, + filter, + mut cursor, + mut first, + )| async move { // The visibility check runs every tick: an app deleted, // transferred, or unshared mid-follow ends the stream // instead of leaking lines to a caller with no role left. loop { tokio::time::sleep(Duration::from_secs(FOLLOW_POLL_SECS)).await; let visible = crate::apps::get_by_slug(&pool, &slug).await.ok()?; - let Some(app) = visible.as_ref() else { - return None; - }; - let role = crate::collaborators::role( + let app = visible.as_ref()?; + crate::collaborators::role( &pool, &app.id, &app.owner_id, @@ -138,10 +146,7 @@ async fn api_logs_stream( &account_email, ) .await - .ok()?; - if role.is_none() { - return None; - } + .ok()??; let lines = crate::observe::logs::read_filtered( &pool, &fleets, @@ -162,7 +167,14 @@ async fn api_logs_stream( }) .to_string(); let state = ( - pool, fleets, account_id, account_email, app_id, slug, filter, cursor, + pool, + fleets, + account_id, + account_email, + app_id, + slug, + filter, + cursor, first, ); return Some((Ok::(Event::default().data(data)), state)); @@ -245,6 +257,8 @@ async fn api_error_detail( /// Move one issue (or a bulk list) to a new status through the same /// domain function the MCP triage tools call, and record the console /// mutation as an `agent_action` row under the MCP tool's name. +/// Collaborators may triage, like the MCP tools (spec §7.4): the endpoints +/// below gate on `visible_app`, not the owner. async fn triage( pool: &sqlx::SqlitePool, account_id: &str, diff --git a/crates/outer/src/mcp/tests.rs b/crates/outer/src/mcp/tests.rs index 87e484a..e7123f3 100644 --- a/crates/outer/src/mcp/tests.rs +++ b/crates/outer/src/mcp/tests.rs @@ -2753,6 +2753,10 @@ async fn collaborator_calls_allowed_tools_and_is_refused_owner_only_ones() { "set_visibility", json!({"app": "groceries", "visibility": "public"}), ), + ( + "rename_app", + json!({"app": "groceries", "slug": "brand-new"}), + ), ( "set_secret", json!({"app": "groceries", "key": "K", "value": "v"}), @@ -2781,6 +2785,64 @@ async fn collaborator_calls_allowed_tools_and_is_refused_owner_only_ones() { assert_eq!(is_error(&missing)["error"], "not_found"); } +#[tokio::test] +async fn collaborator_may_triage_errors() { + let (state, _) = setup().await; + let owner = pro_app(&state, "ana@example.com", "groceries").await; + invite(&state, &owner, "groceries", "bruno@example.com").await; + let bruno = api_key_for(&state, "bruno@example.com").await; + let stranger = api_key_for(&state, "stranger@example.com").await; + let app_id = app_id(&state, "groceries").await; + let now = crate::apps::now_ms(); + sqlx::query( + "INSERT INTO error_issue (app_id, fingerprint, message, frame, count, first_seen, last_seen, status) \ + VALUES (?1, 'fp1', 'Error: kaboom', 'at handler (x:1:2)', 1, ?2, ?3, 'open')", + ) + .bind(&app_id) + .bind(now - 1_000) + .bind(now - 500) + .execute(&state.pool) + .await + .unwrap(); + // A collaborator triages like the owner: resolve, ignore, reopen. + let (_, resolved) = call( + &state, + Some(&bruno), + "resolve_error", + json!({"app": "groceries", "fingerprint": "fp1"}), + ) + .await; + assert_eq!( + result(&resolved)["structuredContent"], + json!({"fingerprint": "fp1", "status": "resolved"}) + ); + let (_, ignored) = call( + &state, + Some(&bruno), + "ignore_error", + json!({"app": "groceries", "fingerprint": "fp1"}), + ) + .await; + assert_eq!(result(&ignored)["structuredContent"]["status"], "ignored"); + let (_, reopened) = call( + &state, + Some(&bruno), + "reopen_error", + json!({"app": "groceries", "fingerprint": "fp1"}), + ) + .await; + assert_eq!(result(&reopened)["structuredContent"]["status"], "open"); + // A stranger's slug still answers not_found, exactly like a missing one. + let (_, refused) = call( + &state, + Some(&stranger), + "resolve_error", + json!({"app": "groceries", "fingerprint": "fp1"}), + ) + .await; + assert_eq!(is_error(&refused)["error"], "not_found"); +} + #[tokio::test] async fn collaborator_removal_takes_effect_on_the_next_call() { let (state, _) = setup().await; diff --git a/crates/outer/src/mcp/tools/runtime.rs b/crates/outer/src/mcp/tools/runtime.rs index a1e6bf5..c33485d 100644 --- a/crates/outer/src/mcp/tools/runtime.rs +++ b/crates/outer/src/mcp/tools/runtime.rs @@ -6,6 +6,7 @@ use serde::Deserialize; use serde_json::{json, Value}; use super::{await_row, deploy_by, latest_deploy, ok, ok_untrusted, visible_app, Ctx}; +use crate::mcp::error::ToolError; #[derive(Debug, Deserialize, JsonSchema)] pub struct DeployStatusInput { @@ -232,9 +233,12 @@ pub struct ReopenErrorInput { /// triage buttons: the same [`crate::observe::logs::set_status`] domain /// function, so MCP and console agree on the rules (resolve/ignore stamp /// `resolved_at`; a resolved issue that recurs reopens automatically; -/// reopen clears the stamp). The answer carries no message or frame -/// text, so the tool stays trusted (`Hints::WRITE`): read the issue -/// through `app_errors` instead. +/// reopen clears the stamp). Collaborators may triage: spec §7.4 keeps +/// owner-only for membership, visibility, secrets and destructive actions, +/// while collaborators already make operational writes (`commit`, +/// `rollback`) — triage is in that second class. The answer carries no +/// message or frame text, so the tool stays trusted (`Hints::WRITE`): +/// read the issue through `app_errors` instead. async fn set_issue_status( ctx: &Ctx<'_>, app_slug: &str, diff --git a/web/docs/reference/tools.md b/web/docs/reference/tools.md index 165fe17..6ad119a 100644 --- a/web/docs/reference/tools.md +++ b/web/docs/reference/tools.md @@ -187,6 +187,13 @@ Every Outer tool in `tools/list` order, rendered from the registry. Generated - **Access:** signed in - **Description:** Rename an app: {app, slug}. Moves its slug, hostname, code, data and files to the new name (D1 and Durable Object data survive); the old name stays reserved for 30 days and redirects to the new one. Counts one code change; a running app redeploys. +**Input:** + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `app` | string | yes | The app's current slug. | +| `slug` | string | yes | The new slug: lowercase letters, digits and dashes, 1-48 chars. The old name stays reserved for 30 days and redirects to the new one. | + ## `add_collaborator` - **Access:** signed in @@ -196,8 +203,6 @@ Every Outer tool in `tools/list` order, rendered from the registry. Generated | Property | Type | Required | Description | | --- | --- | --- | --- | -| `app` | string | yes | The app's current slug. | -| `slug` | string | yes | The new slug: lowercase letters, digits and dashes, 1-48 chars. The old name stays reserved for 30 days and redirects to the new one. | | `app` | string | yes | The app slug. | | `email` | string | yes | The collaborator's email: allowlisted for the closed beta and emailed the invite. | diff --git a/web/pages/console/_islands/console.tsx b/web/pages/console/_islands/console.tsx index 00e4c05..9e5196d 100644 --- a/web/pages/console/_islands/console.tsx +++ b/web/pages/console/_islands/console.tsx @@ -1439,6 +1439,7 @@ function ViewBody(props: { case "secrets": return ( + ); } } diff --git a/web/pages/console/_islands/data/d1/grid.tsx b/web/pages/console/_islands/data/d1/grid.tsx index 4109cae..f47fb54 100644 --- a/web/pages/console/_islands/data/d1/grid.tsx +++ b/web/pages/console/_islands/data/d1/grid.tsx @@ -116,6 +116,7 @@ const Pager = ({ export const RowsGrid = ({ binding, + canWrite, onChanged, onOpenRow, onPage, @@ -128,6 +129,8 @@ export const RowsGrid = ({ viewSlot, }: { binding: string; + /** False for collaborators: rows render read-only, no selection. */ + canWrite: boolean; onChanged: () => void; onOpenRow: (row: Record) => void; onPage: (page: number) => void; @@ -313,26 +316,28 @@ export const RowsGrid = ({ - + const next: Record = {}; + for (const [index] of data.rows.entries()) { + next[String(index)] = true; + } + selected.set(next); + }} + /> + + ) : null} {data.columns.map((name) => { const column = schemaColumn(name); const sort = query.sort; @@ -379,35 +384,41 @@ export const RowsGrid = ({ return ( { - onOpenRow(record); - }} - > - + > + { + const next: Record = {}; + for (const [key] of Object.entries(selected())) { + if (key !== String(index)) { + next[key] = true; + } + } + if (event.currentTarget.checked) { + next[String(index)] = true; + } + selected.set(next); + }} + /> + + ) : null} {data.columns.map((name, columnIndex) => { const column = schemaColumn(name); diff --git a/web/pages/console/_islands/data/d1/panel.tsx b/web/pages/console/_islands/data/d1/panel.tsx index 857cdff..6c931cd 100644 --- a/web/pages/console/_islands/data/d1/panel.tsx +++ b/web/pages/console/_islands/data/d1/panel.tsx @@ -95,6 +95,7 @@ const ViewSwitch = ({ /** One table's workspace: schema, toolbar, grid/definition, pager, editor. */ const TableWorkspace = ({ binding, + canWrite, generation, nav, onChanged, @@ -105,6 +106,8 @@ const TableWorkspace = ({ table, }: { binding: string; + /** False for collaborators: toolbar and grid render read-only. */ + canWrite: boolean; generation: number; nav: { openForeignKey: (target: { @@ -228,6 +231,7 @@ const TableWorkspace = ({ {urlState.view === "data" ? ( { @@ -248,6 +252,7 @@ const TableWorkspace = ({ { editor.set({ row }); @@ -295,11 +300,14 @@ const TableWorkspace = ({ export const D1Editor = ({ binding, + canWrite, route, setRoute, slug, }: { binding: string; + /** False for collaborators: tables and rows stay browsable, writes hide. */ + canWrite: boolean; route: Route; setRoute: (next: Partial) => void; slug: string; @@ -425,6 +433,7 @@ export const D1Editor = ({ { diff --git a/web/pages/console/_islands/data/d1/toolbar.tsx b/web/pages/console/_islands/data/d1/toolbar.tsx index 01c6351..8361e34 100644 --- a/web/pages/console/_islands/data/d1/toolbar.tsx +++ b/web/pages/console/_islands/data/d1/toolbar.tsx @@ -282,6 +282,7 @@ const SortPopover = ({ export const TableToolbar = ({ applied, + canWrite, columns, onApplyFilters, onApplySort, @@ -290,6 +291,8 @@ export const TableToolbar = ({ onSearch, }: { applied: D1RowsQuery; + /** False for collaborators: reads stay, the Insert button hides. */ + canWrite: boolean; /** Columns the table can be filtered/sorted on. */ columns: D1Column[]; onApplyFilters: (filters: D1Filter[]) => void; @@ -347,19 +350,21 @@ export const TableToolbar = ({ > -
- -
+ {canWrite ? ( +
+ +
+ ) : null} ); }; diff --git a/web/pages/console/_islands/views.tsx b/web/pages/console/_islands/views.tsx index 2696fc6..2e4dfe4 100644 --- a/web/pages/console/_islands/views.tsx +++ b/web/pages/console/_islands/views.tsx @@ -1144,11 +1144,14 @@ export function DeploysView({ // --- Secrets ------------------------------------------------------------------- export function SecretsView({ + canWrite, slug, panel, onRetry, onChanged, }: { + /** False for collaborators: names stay visible, writes hide. */ + canWrite: boolean; slug: string; panel: Panel; onRetry: () => void; @@ -1256,6 +1259,7 @@ export function SecretsView({ {error()} )} + {canWrite ? (

Add secret

@@ -1313,25 +1317,44 @@ export function SecretsView({
+ ) : ( +
+ + Secret values stay hidden and only the app owner can add or remove + them. Ask the owner to add anything this app needs. + +
+ )} {secrets.length === 0 ? ( } title="No secrets set"> - Add an API key or token above, or ask your agent to set one. + {canWrite + ? "Add an API key or token above, or ask your agent to set one." + : "Nothing here yet. Ask the app owner to add the secrets this app needs."} ) : (
- { - if (allSelected) { - selected.set({}); - return; - } + {canWrite ? ( + + { + if (allSelected) { + selected.set({}); + return; + } - const next: Record = {}; - for (const [index] of data.rows.entries()) { - next[String(index)] = true; - } - selected.set(next); - }} - /> -
{ - event.stopPropagation(); - }} - > - { - const next: Record = {}; - for (const [key] of Object.entries(selected())) { - if (key !== String(index)) { - next[key] = true; - } - } - if (event.currentTarget.checked) { - next[String(index)] = true; + class={canWrite ? "hover cursor-pointer" : undefined} + onclick={ + canWrite + ? () => { + onOpenRow(record); } - selected.set(next); + : undefined + } + > + {canWrite ? ( + { + event.stopPropagation(); }} - /> -
- - - - - + {canWrite ? ( + + + + + + ) : ( + + + + + )} - + {canWrite ? ( + + ) : null} @@ -1346,34 +1369,36 @@ export function SecretsView({ - + {canWrite ? ( + + ) : null} ))} @@ -1382,7 +1407,7 @@ export function SecretsView({ )} - {replacing && ( + {replacing && canWrite && (
Name UpdatedActionsActions
{timeAgo(s.updated_at)} - - - - - + + + + +