From 9afa948f5b43b6f4b1d172cf7026bc1a64f0b137 Mon Sep 17 00:00:00 2001
From: Ryuz
Date: Sun, 25 Oct 2026 13:30:39 +0100
Subject: [PATCH 1/4] Collaborators (Pro): MCP tools, console card, role
scoping
Domain (crates/outer/src/collaborators.rs): add/remove/list/role for
app_collaborator, Pro-owner gate, 10/app cap, closed-beta allowlisting,
spec-worded invite email, userId linking on invite + at sign-in.
Permissions: visible_app/owner_app across MCP tools; collaborators may
use reads, commit, rollback, share, list_secrets; owner-only stays
owner-only (forbidden). Commits charge the owner's quota. Action log
records collaborator rows; NULL/NULL credentials read 'Console'.
MCP: add_collaborator/remove_collaborator/list_collaborators in
registry (hints classified) + regenerated web/docs/reference/tools.md.
Console: GET/POST /api/console/apps/{slug}/collaborators{,/remove},
role-aware reads (apps, app, actions, metrics, logs, errors,
secrets), Settings collaborators card with invite/remove + Pro
treatment, Shared rail/sidebar markers, actions/collaborators.ts
registry.
---
crates/outer/src/auth/email.rs | 128 +++---
crates/outer/src/auth/mod.rs | 13 +-
crates/outer/src/collaborators.rs | 243 +++++++++++
crates/outer/src/http/console.rs | 371 +++++++++++++++--
crates/outer/src/http/console_actions.rs | 57 ++-
crates/outer/src/http/console_code.rs | 56 ++-
.../outer/src/http/console_collaborators.rs | 284 +++++++++++++
crates/outer/src/http/console_data/d1.rs | 227 +++++-----
crates/outer/src/http/console_observe.rs | 49 ++-
crates/outer/src/http/mod.rs | 1 +
crates/outer/src/lib.rs | 1 +
crates/outer/src/mcp/actions.rs | 20 +-
crates/outer/src/mcp/error.rs | 3 +
crates/outer/src/mcp/registry.rs | 36 +-
crates/outer/src/mcp/tests.rs | 394 +++++++++++++++++-
crates/outer/src/mcp/tools/apps.rs | 44 +-
crates/outer/src/mcp/tools/code.rs | 29 +-
crates/outer/src/mcp/tools/collaborators.rs | 150 +++++++
crates/outer/src/mcp/tools/mod.rs | 54 ++-
crates/outer/src/mcp/tools/runtime.rs | 13 +-
crates/outer/src/mcp/tools/secrets.rs | 16 +-
web/docs/reference/tools.md | 30 ++
.../console/_islands/actions/collaborators.ts | 93 +++++
web/pages/console/_islands/api.ts | 13 +
web/pages/console/_islands/console.tsx | 48 ++-
web/pages/console/_islands/icons.tsx | 5 +
web/pages/console/_islands/views.tsx | 254 ++++++++++-
27 files changed, 2343 insertions(+), 289 deletions(-)
create mode 100644 crates/outer/src/collaborators.rs
create mode 100644 crates/outer/src/http/console_collaborators.rs
create mode 100644 crates/outer/src/mcp/tools/collaborators.rs
create mode 100644 web/pages/console/_islands/actions/collaborators.ts
diff --git a/crates/outer/src/auth/email.rs b/crates/outer/src/auth/email.rs
index 318a73e..466ea57 100644
--- a/crates/outer/src/auth/email.rs
+++ b/crates/outer/src/auth/email.rs
@@ -137,59 +137,16 @@ impl EmailSender {
}
/// Send one sign-in code. The code itself goes in the body; failures
- /// name the reason, never the token.
+ /// name the reason, never the token. The test sender captures the bare
+ /// code (existing sign-in tests verify with it), while [`send_text`]
+ /// captures subject plus body.
pub async fn send_code(&self, to: &str, code: &str) -> Result<(), EmailError> {
+ let subject = format!("Your Outer sign-in code is {code}");
+ let text = format!(
+ "Your Outer sign-in code is {code}. It expires in 10 minutes.\n\nIf you didn't ask for this, ignore this email."
+ );
match self {
- Self::Cloudflare {
- client,
- api_base,
- account_id,
- api_token,
- from,
- } => {
- let url = format!("{api_base}/client/v4/accounts/{account_id}/email/sending/send");
- let subject = format!("Your Outer sign-in code is {code}");
- let text = format!(
- "Your Outer sign-in code is {code}. It expires in 10 minutes.\n\nIf you didn't ask for this, ignore this email."
- );
- let response = client
- .post(&url)
- .bearer_auth(api_token)
- .json(&serde_json::json!({
- "to": to,
- "from": { "address": from.address, "name": from.name },
- "subject": subject,
- "text": text,
- }))
- .send()
- .await
- .map_err(|err| EmailError::Retryable(scrubbed(&err.to_string())))?;
- let status = response.status();
- let body: serde_json::Value = response.json().await.map_err(|err| {
- EmailError::Retryable(format!("email API returned an unreadable body: {err}"))
- })?;
- if status == reqwest::StatusCode::TOO_MANY_REQUESTS
- || error_code(&body) == Some(10004)
- {
- return Err(EmailError::Retryable(
- "email provider is rate limited, try again shortly".to_string(),
- ));
- }
- if !status.is_success()
- || body.get("success").and_then(|v| v.as_bool()) != Some(true)
- {
- let detail = first_error(&body).unwrap_or_else(|| status.to_string());
- return Err(EmailError::Permanent(format!(
- "email provider refused the message ({detail})"
- )));
- }
- if permanent_bounce(&body, to) {
- return Err(EmailError::Permanent(format!(
- "email to {to} permanently bounced"
- )));
- }
- Ok(())
- }
+ Self::Cloudflare { .. } => self.post(to, &subject, &text).await,
Self::Log => {
tracing::info!(email = %to, code = %code, "sign-in code (localhost log sender)");
Ok(())
@@ -204,6 +161,75 @@ impl EmailSender {
}
}
}
+
+ /// Send one free-form message (collaborator invites). Same transport,
+ /// same failure contract as [`send_code`].
+ pub async fn send_text(&self, to: &str, subject: &str, text: &str) -> Result<(), EmailError> {
+ match self {
+ Self::Cloudflare { .. } => self.post(to, subject, text).await,
+ Self::Log => {
+ tracing::info!(email = %to, subject = %subject, "outbound email (localhost log sender)");
+ Ok(())
+ }
+ #[cfg(test)]
+ Self::Capture { codes } => {
+ codes
+ .lock()
+ .unwrap()
+ .insert(to.to_string(), format!("{subject}\n\n{text}"));
+ Ok(())
+ }
+ }
+ }
+
+ /// The Cloudflare Email Sending POST behind [`send_code`] and
+ /// [`send_text`].
+ async fn post(&self, to: &str, subject: &str, text: &str) -> Result<(), EmailError> {
+ let Self::Cloudflare {
+ client,
+ api_base,
+ account_id,
+ api_token,
+ from,
+ } = self
+ else {
+ unreachable!("post is only called on the Cloudflare sender");
+ };
+ let url = format!("{api_base}/client/v4/accounts/{account_id}/email/sending/send");
+ let response = client
+ .post(&url)
+ .bearer_auth(api_token)
+ .json(&serde_json::json!({
+ "to": to,
+ "from": { "address": from.address, "name": from.name },
+ "subject": subject,
+ "text": text,
+ }))
+ .send()
+ .await
+ .map_err(|err| EmailError::Retryable(scrubbed(&err.to_string())))?;
+ let status = response.status();
+ let body: serde_json::Value = response.json().await.map_err(|err| {
+ EmailError::Retryable(format!("email API returned an unreadable body: {err}"))
+ })?;
+ if status == reqwest::StatusCode::TOO_MANY_REQUESTS || error_code(&body) == Some(10004) {
+ return Err(EmailError::Retryable(
+ "email provider is rate limited, try again shortly".to_string(),
+ ));
+ }
+ if !status.is_success() || body.get("success").and_then(|v| v.as_bool()) != Some(true) {
+ let detail = first_error(&body).unwrap_or_else(|| status.to_string());
+ return Err(EmailError::Permanent(format!(
+ "email provider refused the message ({detail})"
+ )));
+ }
+ if permanent_bounce(&body, to) {
+ return Err(EmailError::Permanent(format!(
+ "email to {to} permanently bounced"
+ )));
+ }
+ Ok(())
+ }
}
/// The recipient counts as permanently bounced when it appears in
diff --git a/crates/outer/src/auth/mod.rs b/crates/outer/src/auth/mod.rs
index cd80ce3..4d95af0 100644
--- a/crates/outer/src/auth/mod.rs
+++ b/crates/outer/src/auth/mod.rs
@@ -158,7 +158,7 @@ pub fn normalize_email(email: &str) -> String {
email.trim().to_ascii_lowercase()
}
-fn valid_email(email: &str) -> bool {
+pub(crate) fn valid_email(email: &str) -> bool {
let parts = email.split('@').collect::>();
parts.len() == 2 && !parts[0].is_empty() && parts[1].contains('.') && !parts[1].is_empty()
}
@@ -493,6 +493,17 @@ pub async fn verify_code_core(
});
}
}
+ // A collaborator invite for this address links here: the row's
+ // userId names the new account (spec §7.4). Invites to addresses
+ // that already had accounts linked in `collaborators::add`.
+ sqlx::query(
+ "UPDATE app_collaborator SET userId = ?1 WHERE email = ?2 AND userId IS NULL",
+ )
+ .bind(&id)
+ .bind(&email)
+ .execute(&mut *tx)
+ .await
+ .map_err(db)?;
// Only people who joined with an invite get codes to pass on
// (owner, 2026-10-24): not admins, not allowlisted emails, and
// not app collaborators, who are admitted through the allowlist.
diff --git a/crates/outer/src/collaborators.rs b/crates/outer/src/collaborators.rs
new file mode 100644
index 0000000..0e661a1
--- /dev/null
+++ b/crates/outer/src/collaborators.rs
@@ -0,0 +1,243 @@
+//! Collaborators (Pro, spec §7.4): up to 10 extra emails per app.
+//!
+//! The owner (the account that created the app) invites by email. The invite
+//! allowlists the email during the closed beta and emails it the spec
+//! wording; the row's `userId` links when that email signs in (or is already
+//! an account). Removal revokes access immediately but keeps nothing else:
+//! the row is gone, so the next call answers `not_found` again.
+//!
+//! A collaborator's access lives only while the owner's plan is Pro: on
+//! downgrade the rows stay (and come back on upgrade) but grant nothing
+//! (spec §7.4). Everything a collaborator does counts against the owner's
+//! plan (spec §3), so quota checks take the owner's id.
+
+use crate::apps::App;
+use crate::auth::{normalize_email, EmailSender};
+
+/// Collaborators per app (spec §3): Pro owners may invite this many.
+pub const MAX_COLLABORATORS: u64 = 10;
+
+/// What the caller may do with an app.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Role {
+ Owner,
+ Collaborator,
+}
+/// Why `add` refused. Callers map these to the spec §8.3 codes; nothing
+/// here carries a server path or token.
+#[derive(Debug, thiserror::Error)]
+pub enum AddError {
+ /// The address is blank or has no usable shape.
+ #[error("email {0:?} is not a usable address")]
+ InvalidEmail(String),
+ /// The owner invited themselves.
+ #[error("that address already owns the app")]
+ SelfInvite,
+ /// Only Pro owners may invite (spec §7.4).
+ #[error("collaborators are a Pro feature")]
+ NotPro,
+ /// The app already has [`MAX_COLLABORATORS`] collaborators.
+ #[error("this app already has {MAX_COLLABORATORS} collaborators")]
+ LimitReached,
+ /// The address is already on the list (joined or pending).
+ #[error("that address is already a collaborator")]
+ Duplicate,
+ /// The database refused the insert or lookup.
+ #[error("collaborator store failed: {0}")]
+ Internal(#[from] anyhow::Error),
+}
+
+/// One invite row: `joined` is true once the email has signed in and linked.
+#[derive(Debug, Clone)]
+pub struct Collaborator {
+ pub email: String,
+ pub joined: bool,
+ pub added_at: i64,
+}
+
+/// The caller's [`Role`] on the app, or `None` when the slug should read as
+/// missing (spec §8.2: a slug the caller has no role on answers `not_found`,
+/// exactly like a missing one). Collaborators only count while the owner's
+/// plan is Pro (downgrade revokes access, keeps the rows).
+pub async fn role(
+ pool: &sqlx::SqlitePool,
+ app_id: &str,
+ owner_id: &str,
+ account_id: &str,
+ account_email: &str,
+) -> anyhow::Result
{active.status}
+ {active.role === "collaborator" && (
+ Shared
+ )}