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..267a62d
--- /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..a371467 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,351 @@ 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, 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"}),
+ ),
+ ] {
+ 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);
+ }
+
+ #[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_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..a273830 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,25 +113,40 @@ 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)
- {
- return None;
- }
+ let app = visible.as_ref()?;
+ crate::collaborators::role(
+ &pool,
+ &app.id,
+ &app.owner_id,
+ &account_id,
+ &account_email,
+ )
+ .await
+ .ok()??;
let lines = crate::observe::logs::read_filtered(
&pool,
&fleets,
@@ -152,7 +167,15 @@ 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 +205,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(
@@ -234,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,
@@ -318,8 +343,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 +386,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..e7123f3 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,458 @@ 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"}),
+ ),
+ (
+ "rename_app",
+ json!({"app": "groceries", "slug": "brand-new"}),
+ ),
+ (
+ "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_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;
+ 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..c33485d 100644
--- a/crates/outer/src/mcp/tools/runtime.rs
+++ b/crates/outer/src/mcp/tools/runtime.rs
@@ -5,7 +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 super::{await_row, deploy_by, latest_deploy, ok, ok_untrusted, visible_app, Ctx};
use crate::mcp::error::ToolError;
#[derive(Debug, Deserialize, JsonSchema)]
@@ -37,7 +37,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 +62,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 +99,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 +142,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!(
@@ -233,16 +233,19 @@ 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,
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..6ad119a 100644
--- a/web/docs/reference/tools.md
+++ b/web/docs/reference/tools.md
@@ -194,6 +194,41 @@ Every Outer tool in `tools/list` order, rendered from the registry. Generated
| `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
+- **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 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`
- **Access:** signed in
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..98b69a4 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,
@@ -52,6 +53,7 @@ import {
ActionsView,
AdminView,
DeploysView,
+ EmptyState,
InvitesView,
MetricsView,
OverviewView,
@@ -198,6 +200,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 +222,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 +241,7 @@ export function Console() {
case "secrets":
return ["secrets"];
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
+ )}
))}
- navigate(next)}
- slug={active.slug}
- />
+ {/* D1 is owner-only in v1 (every endpoint 403s for collaborators). */}
+ {active.role === "owner" && (
+ navigate(next)}
+ slug={active.slug}
+ />
+ )}
{(["R2", "Durable Objects"] as const).map((name) => (