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
44 changes: 44 additions & 0 deletions docs/platform-module-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Platform → Module Auth: Per-Tunnel Service Token

## Problem

Platform calls modules via tunnel (lifecycle install, RPC). Today uses `MS_INTERNAL_SECRET` shared symmetric key. Breaks because CLI generates a random one for compose while the platform has a different one.

## Design

Use the **`stk_*` service token** already minted by dispatch during tunnel registration. Platform-minted, per-tunnel, delivered through the authenticated WSS channel.

### Flow

```
mirrorstack dev --tunnel
→ CLI registers tunnel via WSS (authenticated by user OAuth)
→ dispatch mints stk_* token, stores in session, returns in register_ack
→ CLI receives stk_*, passes as MS_PLATFORM_TOKEN to compose env
→ module SDK reads MS_PLATFORM_TOKEN from env

Platform → Module request
→ lifecycle client looks up stk_* from tunnel session (Redis)
→ sends as X-MS-Platform-Token header
→ SDK validates via constant-time compare against MS_PLATFORM_TOKEN env
```

### Changes

**api-platform** (3 files):
1. `internal/dispatch/ws/sessions.go` — add `ServiceToken` to Session struct
2. `internal/dispatch/ws/handlers.go` — store service token in session during handleRegister
3. `internal/applications/service/module_lifecycle.go` — read service token from session, send as `X-MS-Platform-Token`

**app-module-sdk** (1 file):
1. `auth/middleware.go` — `InternalAuth` checks `X-MS-Platform-Token` against `MS_PLATFORM_TOKEN` env. Fallback to `MS_INTERNAL_SECRET` for backward compat.

**mirrorstack-cli** (2 files):
1. `src/commands/dev/tunnel.rs` — expose `service_token` from `RegisterAck` on `TunnelHandle`
2. `src/commands/dev/mod.rs` — pass `MS_PLATFORM_TOKEN` from tunnel handles to compose env. Remove `MS_INTERNAL_SECRET` generation.

### Build Order

1. api-platform: store + expose service token
2. app-module-sdk: validate `X-MS-Platform-Token`
3. mirrorstack-cli: pass token through to compose
123 changes: 123 additions & 0 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,71 @@ pub fn create_module(
})
}

#[derive(Debug, Deserialize)]
#[allow(dead_code)] // name / owner_id / created_at are part of the API surface
pub struct App {
pub id: String,
pub name: String,
pub slug: String,
#[serde(default)]
pub owner_id: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct CreateAppInput<'a> {
pub name: &'a str,
pub slug: &'a str,
}

/// POST /v1/apps — create an application on the platform.
pub fn create_app(
http: &Client,
apps_base: &str,
access_token: &str,
input: &CreateAppInput,
) -> Result<App, ApiError> {
let endpoint = format!("{}/v1/apps", apps_base.trim_end_matches('/'));

let resp = http
.post(&endpoint)
.bearer_auth(access_token)
.header("Accept", "application/json")
.json(input)
.send()?;

let status = resp.status();
if status.is_success() {
return Ok(resp.json::<App>()?);
}
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(ApiError::Unauthenticated);
}

let status_u16 = status.as_u16();
let body = match http::read_capped(resp) {
Ok(b) => b,
Err(e) => {
return Err(ApiError::Unexpected {
status: status_u16,
body: format!("(read body failed: {e})"),
});
}
};
if let Ok(env) = serde_json::from_slice::<ErrorEnvelope>(&body) {
return Err(ApiError::Server {
status: status_u16,
code: env.error.code,
message: env.error.message,
});
}
Err(ApiError::Unexpected {
status: status_u16,
body: String::from_utf8_lossy(&body).into_owned(),
})
}

/// 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
Expand Down Expand Up @@ -546,6 +611,64 @@ mod tests {
}
}

#[test]
fn create_app_success() {
let mut server = Server::new();
let _m = server
.mock("POST", "/v1/apps")
.match_header("authorization", "Bearer AT")
.with_status(201)
.with_body(
json!({
"id": "a-1",
"name": "My App",
"slug": "my-app",
"owner_id": "u-1",
"created_at": "2026-05-28T00:00:00Z"
})
.to_string(),
)
.create();

let a = create_app(
&test_client(),
&server.url(),
"AT",
&CreateAppInput {
name: "My App",
slug: "my-app",
},
)
.expect("ok");
assert_eq!(a.slug, "my-app");
assert_eq!(a.id, "a-1");
}

#[test]
fn create_app_409_surfaces_code() {
let mut server = Server::new();
let _m = server
.mock("POST", "/v1/apps")
.with_status(409)
.with_body(r#"{"error":{"code":"slug_taken","message":"app slug already taken"}}"#)
.create();

let err = create_app(
&test_client(),
&server.url(),
"AT",
&CreateAppInput {
name: "My App",
slug: "my-app",
},
)
.unwrap_err();
match err {
ApiError::Server { code, .. } => assert_eq!(code, "slug_taken"),
other => panic!("expected Server, got {other:?}"),
}
}

#[test]
fn create_module_4xx_without_envelope_is_unexpected() {
let mut server = Server::new();
Expand Down
196 changes: 196 additions & 0 deletions src/commands/app/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
//! `mirrorstack apps …` — application management.

use std::io::IsTerminal;
use std::time::Duration;

use anyhow::{Result, anyhow};
use clap::{Args, Subcommand};
use console::style;
use dialoguer::{Confirm, Input, theme::ColorfulTheme};
use indicatif::{ProgressBar, ProgressStyle};

use crate::api::{self, ApiError, CreateAppInput};
use crate::credentials;
use crate::http;

use super::{
DEFAULT_API_BASE, DEFAULT_APPS_API_BASE, DEFAULT_WEB_BASE, ENV_API_URL, ENV_APPS_API_URL,
ENV_WEB_URL, ok_mark, resolve_base, session_expired,
};

#[derive(Args)]
pub struct AppArgs {
#[command(subcommand)]
command: AppCommand,
}

#[derive(Subcommand)]
enum AppCommand {
/// Create a new application on the platform.
Create(CreateArgs),
}

#[derive(Args)]
struct CreateArgs {
/// Application name (human-readable).
#[arg(long)]
name: Option<String>,
/// URL slug. When omitted, derived from --name.
#[arg(long)]
slug: Option<String>,
/// Skip prompts.
#[arg(long, short)]
yes: bool,
}

pub fn run(args: AppArgs) -> Result<()> {
match args.command {
AppCommand::Create(c) => create(c),
}
}

fn create(args: CreateArgs) -> Result<()> {
let theme = ColorfulTheme::default();

let mut creds = credentials::load_or_login_hint()?;
let api_base = resolve_base(ENV_API_URL, DEFAULT_API_BASE);
let apps_base = resolve_base(ENV_APPS_API_URL, DEFAULT_APPS_API_BASE);
let web_base = resolve_base(ENV_WEB_URL, DEFAULT_WEB_BASE);
let client = http::client(Duration::from_secs(15))?;

let identity =
match credentials::with_refresh_retry(&mut creds, |tok| api::me(&client, &api_base, tok)) {
Ok(id) => id,
Err(ApiError::Unauthenticated) => return Err(session_expired()),
Err(e) => return Err(e.into()),
};
let Some(username) = identity.slug.as_deref().filter(|s| !s.is_empty()) else {
return Err(anyhow!(
"no username set. Visit {web_base}/me to claim one first."
));
};

let name = if let Some(n) = args
.name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
n.to_string()
} else if args.yes {
return Err(anyhow!("--yes requires --name"));
} else {
Input::<String>::with_theme(&theme)
.with_prompt("App name")
.interact_text()?
.trim()
.to_string()
};

let slug = if let Some(s) = args
.slug
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
s.to_string()
} else {
let suggested = derive_slug(&name);
if args.yes {
suggested
} else {
Input::<String>::with_theme(&theme)
.with_prompt("Slug")
.default(suggested)
.interact_text()?
.trim()
.to_string()
}
};

if !args.yes {
eprintln!();
eprintln!(" {} {}", style("App:").dim(), style(&name).bold());
eprintln!(
" {} {}",
style("Slug:").dim(),
style(format!("@{username}/{slug}")).cyan().bold()
);
let confirmed = Confirm::with_theme(&theme)
.with_prompt("Create this app?")
.default(true)
.interact()?;
if !confirmed {
eprintln!("{}", style("aborted.").yellow());
return Ok(());
}
}

let result = with_spinner("Creating app…", || {
credentials::with_refresh_retry(&mut creds, |tok| {
api::create_app(
&client,
&apps_base,
tok,
&CreateAppInput {
name: &name,
slug: &slug,
},
)
})
});

match result {
Ok(app) => {
eprintln!(
"{} created {}",
ok_mark(),
style(format!("@{username}/{slug}")).cyan().bold()
);
eprintln!(" {} {}", style("id:").dim(), app.id);
Ok(())
}
Err(ApiError::Server { code, message, .. }) => Err(anyhow!("{code}: {message}")),
Err(ApiError::Unauthenticated) => Err(session_expired()),
Err(e) => Err(e.into()),
}
}

fn derive_slug(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut last_dash = true;
for c in name.chars() {
let lc = c.to_ascii_lowercase();
if lc.is_ascii_lowercase() || lc.is_ascii_digit() {
out.push(lc);
last_dash = false;
} else if !last_dash {
out.push('-');
last_dash = true;
}
}
while out.ends_with('-') {
out.pop();
}
out
}

fn with_spinner<T, F>(message: &str, f: F) -> T
where
F: FnOnce() -> T,
{
if !std::io::stderr().is_terminal() {
return f();
}
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::with_template("{spinner:.cyan} {msg}")
.unwrap()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
);
pb.enable_steady_tick(Duration::from_millis(80));
pb.set_message(message.to_string());
let result = f();
pb.finish_and_clear();
result
}
Loading
Loading