Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,53 @@ pub fn create_module(
})
}

/// Body of a successful `POST /v1/auth/sessions/refresh`. The platform
/// rotates the refresh token on every refresh (replay defense), so we
/// must persist the new one back to credentials. expires_at is RFC3339
/// from the server but informational only — the CLI re-derives the
/// 15-minute access TTL locally when saving.
#[derive(Debug, Deserialize)]
pub struct RefreshResponse {
pub access_token: String,
pub refresh_token: String,
#[allow(dead_code)]
pub expires_at: String,
}

/// POST /v1/auth/sessions/refresh — exchange a refresh token for a new
/// access + refresh pair. The CLI calls this on a 401 from any
/// access-token-bearing endpoint to recover without a fresh
/// `mirrorstack login`. A 401 from this endpoint means the refresh
/// token itself is gone (revoked, expired, or never existed) — surface
/// as Unauthenticated so callers can fall back to the login hint.
pub fn refresh_session(
http: &Client,
api_base: &str,
refresh_token: &str,
) -> Result<RefreshResponse, ApiError> {
#[derive(Serialize)]
struct Body<'a> {
refresh_token: &'a str,
}
let endpoint = format!(
"{}/v1/auth/sessions/refresh",
api_base.trim_end_matches('/')
);
let resp = http
.post(&endpoint)
.header("Accept", "application/json")
.json(&Body { refresh_token })
.send()?;
let status = resp.status();
if status.is_success() {
return Ok(resp.json::<RefreshResponse>()?);
}
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(ApiError::Unauthenticated);
}
Err(unexpected_body_error(resp))
}

/// DELETE /v1/auth/sessions/current — revoke the supplied refresh token
/// (CLI flow: token in body, not cookie). The platform treats a missing
/// or already-revoked session as success, so callers can call this
Expand Down
113 changes: 107 additions & 6 deletions src/commands/dev/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@

use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::time::Duration;
use std::time::{Duration, SystemTime};

use anyhow::{Context, Result, anyhow};
use clap::Args;
use console::style;
use reqwest::blocking::Client;

use super::{
DEFAULT_DISPATCH_BASE, ENV_DISPATCH_URL, ok_mark, resolve_base, session_expired, warn_prefix,
DEFAULT_API_BASE, DEFAULT_DISPATCH_BASE, ENV_API_URL, ENV_DISPATCH_URL, ok_mark, resolve_base,
session_expired, warn_prefix,
};
use crate::{api, credentials, http};

Expand Down Expand Up @@ -125,8 +127,9 @@ fn open_tunnel(
module_dir: &Path,
local_url: Option<&str>,
) -> Result<(tunnel::TunnelHandle, tokio::runtime::Runtime)> {
let creds = credentials::load_or_login_hint()?;
let mut creds = credentials::load_or_login_hint()?;
let dispatch_base = resolve_base(ENV_DISPATCH_URL, DEFAULT_DISPATCH_BASE);
let api_base = resolve_base(ENV_API_URL, DEFAULT_API_BASE);
let client = http::client(Duration::from_secs(15))?;
let module_id = module_meta::read_module_id(module_dir)?;
let local_url = local_url.unwrap_or(DEFAULT_LOCAL_URL);
Expand All @@ -136,7 +139,7 @@ fn open_tunnel(
ok_mark(),
style(&dispatch_base).cyan().dim()
);
let token = match api::tunnel_token(&client, &dispatch_base, &creds.access_token) {
let token = match mint_tunnel_token(&client, &dispatch_base, &api_base, &mut creds) {
Ok(t) => t,
Err(api::ApiError::Unauthenticated) => return Err(session_expired()),
Err(e) => {
Expand All @@ -159,7 +162,7 @@ fn open_tunnel(
.build()
.context("dev: build tokio runtime")?;

let handle = runtime.block_on(async {
let handle = match runtime.block_on(async {
tunnel::open(
&token.wss_url,
&token.token,
Expand All @@ -170,7 +173,21 @@ fn open_tunnel(
},
)
.await
})?;
}) {
Ok(h) => h,
Err(tunnel::RegisterError::ModuleDevModeOff { slug }) => {
return Err(dev_mode_off_hint(&dispatch_base, slug.as_deref()));
}
Err(tunnel::RegisterError::ModuleNotYours) => {
return Err(anyhow!(
"dev: this module isn't owned by you — only the module owner can open a dev tunnel.\nIf you scaffolded with `mirrorstack module init`, your `Config.ID` should match the platform's record; re-check it under `mirrorstack module list`."
));
}
Err(tunnel::RegisterError::Rejected { code, message }) => {
return Err(anyhow!("dev: register rejected ({code}): {message}"));
}
Err(tunnel::RegisterError::Transport(e)) => return Err(e),
};

eprintln!(
"{} tunnel registered (session {})",
Expand All @@ -180,6 +197,90 @@ fn open_tunnel(
Ok((handle, runtime))
}

/// Access tokens are short (15 min per the platform's TokenConfig).
/// `mirrorstack dev` is a long-running command, so we silently refresh
/// the access token via the stored refresh token (30-day TTL) on a 401
/// and retry the mint once. The refresh endpoint rotates the refresh
/// token too, so the rotated pair is persisted back to credentials.
/// If the refresh ALSO 401s (revoked / expired session), the original
/// Unauthenticated bubbles up and the caller surfaces session_expired.
fn mint_tunnel_token(
client: &Client,
dispatch_base: &str,
api_base: &str,
creds: &mut credentials::Credentials,
) -> Result<api::TunnelToken, api::ApiError> {
match api::tunnel_token(client, dispatch_base, &creds.access_token) {
Ok(t) => Ok(t),
Err(api::ApiError::Unauthenticated) => {
let refreshed = api::refresh_session(client, api_base, &creds.refresh_token)?;
creds.access_token = refreshed.access_token;
creds.refresh_token = refreshed.refresh_token;
// expires_at is informational only on the credentials struct;
// match the platform's 15-minute access TTL.
creds.expires_at = SystemTime::now() + Duration::from_secs(15 * 60);
if let Err(e) = credentials::save(creds) {
// Save failure isn't fatal for the in-process retry (we
// hold the new access token in `creds`), but log so the
// user sees the warning if the next run re-prompts login.
eprintln!(
"{} refreshed session but failed to save credentials: {e:#}",
warn_prefix()
);
}
api::tunnel_token(client, dispatch_base, &creds.access_token)
}
Err(e) => Err(e),
}
}

/// Build a user-facing error for the `module_dev_mode_off` rpc.err. The
/// platform refused the tunnel because the module's `dev_mode_enabled`
/// flag is false; coach the user toward the right toggle and offer to
/// open it. When the platform carries the module slug in the error
/// payload, deep-link straight to the Dev tab; otherwise degrade to
/// the modules list (older platforms predate the slug field).
fn dev_mode_off_hint(dispatch_base: &str, slug: Option<&str>) -> anyhow::Error {
let console = dev_console_url(dispatch_base, slug);
eprintln!();
eprintln!(
"{} dev mode is disabled for this module on the platform.",
warn_prefix()
);
eprintln!(" Open the dev console and toggle Dev mode on:");
eprintln!(" {}", style(&console).cyan().underlined());
eprintln!();
eprint!(" Press Enter to open in your browser, or Ctrl-C to cancel...");
let _ = std::io::Write::flush(&mut std::io::stderr());
let mut buf = String::new();
if std::io::stdin().read_line(&mut buf).is_ok() {
let _ = open::that(&console);
}
anyhow!("dev: module dev_mode disabled — re-run after enabling it")
}

/// Derive the dev-console web URL from the dispatch HTTP URL.
/// localhost/127.0.0.1 → http://localhost:3001; production
/// (`api.<host>`) → `https://apps.<host>`; anything else falls back to
/// the canonical prod console so the message stays useful. When `slug`
/// is provided, deep-link to /dev/module/<slug>/dev; otherwise the
/// modules-list landing page.
fn dev_console_url(dispatch_base: &str, slug: Option<&str>) -> String {
let parsed = url::Url::parse(dispatch_base).ok();
let host = parsed.as_ref().and_then(|u| u.host_str());
let base = match host {
Some("localhost") | Some("127.0.0.1") | Some("::1") => "http://localhost:3001".to_string(),
Some(h) if h.starts_with("api.") => {
format!("https://apps.{}", &h["api.".len()..])
}
_ => "https://apps.mirrorstack.ai".to_string(),
};
match slug {
Some(s) if !s.is_empty() => format!("{base}/dev/module/{s}/dev"),
_ => format!("{base}/dev"),
}
}

fn resolve_db_url(args: &DevArgs) -> Result<String> {
if let Some(url) = &args.db_url {
return Ok(url.clone());
Expand Down
94 changes: 81 additions & 13 deletions src/commands/dev/tunnel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,52 @@ pub(super) struct RegisterAck {
pub expires_at: String,
}

#[derive(Debug, Deserialize)]
struct ErrorPayload {
code: String,
message: String,
#[serde(default)]
slug: Option<String>,
}

/// Typed register-time rejection. Distinguishes the cases the CLI knows
/// how to coach the user through (`module_dev_mode_off`,
/// `module_not_yours`) from "every other rpc.err on the register
/// channel" so callers can match without string-sniffing.
#[derive(Debug, thiserror::Error)]
pub(super) enum RegisterError {
#[error("dev mode is disabled on this module")]
ModuleDevModeOff {
/// Module slug from the rpc.err payload — empty when the
/// platform predates the slug-in-error PR. Callers should
/// degrade to the /dev list URL when None.
slug: Option<String>,
},
#[error("module is not owned by you")]
ModuleNotYours,
#[error("register rejected ({code}): {message}")]
Rejected { code: String, message: String },
#[error(transparent)]
Transport(anyhow::Error),
}

impl From<anyhow::Error> for RegisterError {
fn from(e: anyhow::Error) -> Self {
RegisterError::Transport(e)
}
}

impl From<serde_json::Error> for RegisterError {
fn from(e: serde_json::Error) -> Self {
RegisterError::Transport(anyhow::Error::new(e))
}
}

/// Local Result alias — anyhow::Result is in scope at module level, so
/// every typed handshake return path would otherwise need the
/// fully-qualified std form. Keeps signatures readable.
type RegisterResult<T> = std::result::Result<T, RegisterError>;

/// Spawned-task handle. Drop or call [`shutdown`] to close the WSS.
///
/// `service_token` is intentionally NOT held here — it lives on the
Expand All @@ -166,7 +212,7 @@ pub(super) async fn open(
wss_url: &str,
ttok: &str,
register: RegisterPayload<'_>,
) -> Result<TunnelHandle> {
) -> RegisterResult<TunnelHandle> {
let url = with_token_param(wss_url, ttok)?;
let config = WebSocketConfig {
max_message_size: Some(MAX_INBOUND_FRAME_BYTES),
Expand All @@ -193,8 +239,7 @@ pub(super) async fn open(
await_register_ack(&mut sink, &mut stream, &register_id),
)
.await
.context("dev: register_ack timeout")?
.context("dev: register_ack")?;
.context("dev: register_ack timeout")??;

let shutdown = Arc::new(Notify::new());
tokio::spawn(run_tunnel_loop(sink, stream, shutdown.clone()));
Expand All @@ -219,11 +264,14 @@ fn with_token_param(wss_url: &str, ttok: &str) -> Result<String> {
/// `register_id` arrives. Server-side ping/binary/text-without-ack are
/// tolerated — the server may send heartbeat or status frames before
/// the ack lands. Wrapped in `tokio::time::timeout` by the caller.
// The server replies on the register's corr_id either with
// register_ack (success) or rpc.err (rejection). Anything else during
// handshake (server-initiated ping, future frame types) gets ignored.
async fn await_register_ack(
sink: &mut WsSink,
stream: &mut WsReader,
register_id: &str,
) -> Result<RegisterAck> {
) -> RegisterResult<RegisterAck> {
loop {
let next = stream
.next()
Expand All @@ -237,20 +285,40 @@ async fn await_register_ack(
let _ = sink.send(Message::Pong(p)).await;
continue;
}
Message::Close(_) => return Err(anyhow!("server closed before register_ack")),
Message::Close(_) => return Err(anyhow!("server closed before register_ack").into()),
_ => continue,
};
let frame: Frame = serde_json::from_str(&text)
.with_context(|| format!("dev: parse server frame: {text}"))?;
if frame.frame_type == FrameType::RegisterAck
&& frame.corr_id.as_deref() == Some(register_id)
{
let payload = frame
.payload
.ok_or_else(|| anyhow!("register_ack missing payload"))?;
return serde_json::from_value(payload).context("dev: deserialize register_ack");
if frame.corr_id.as_deref() != Some(register_id) {
continue;
}
match frame.frame_type {
FrameType::RegisterAck => {
let payload = frame
.payload
.ok_or_else(|| anyhow!("register_ack missing payload"))?;
return Ok(
serde_json::from_value(payload).context("dev: deserialize register_ack")?
);
}
FrameType::RpcErr => {
let payload = frame
.payload
.ok_or_else(|| anyhow!("rpc.err missing payload"))?;
let err: ErrorPayload =
serde_json::from_value(payload).context("dev: deserialize rpc.err")?;
return Err(match err.code.as_str() {
"module_dev_mode_off" => RegisterError::ModuleDevModeOff { slug: err.slug },
"module_not_yours" => RegisterError::ModuleNotYours,
_ => RegisterError::Rejected {
code: err.code,
message: err.message,
},
});
}
_ => continue,
}
// Other frames during handshake are ignored — see fn comment.
}
}

Expand Down
Loading