clone

one developer profile for links and private tcp services, reached peer to peer over iroh with no open ports

feat: add scoped service access broker

Dami committed 80aef41
Add endpoint-bound requester and broker CLI workflows, server-enforced enrollment scopes, signed approval plans, and revocable short-lived grants. Package the portable CLI skill with a stateless MCP 2026-07-28 adapter and document the harness-neutral trust model.
commit
80aef41fbf6741e3f3d19b372e7e898011bc70bc
parent
c887797af395ff8634f9985dcd5586ca3eac6a80
author
Dami <dami.etoile@gmail.com>
20 files changed, +2088 -50
MCargo.lock1+
MREADME.md42++-
Mcrates/devsite-cli/Cargo.toml1+
Mcrates/devsite-cli/src/client.rs14+
Mcrates/devsite-cli/src/main.rs508+++++++++++++++++++++++++++++-
Mcrates/devsite-cli/tests/json.rs43+++
Mcrates/devsite-client/src/lib.rs15+
Acrates/devsite-proto/src/access_plan.rs120+++++++
Mcrates/devsite-proto/src/lib.rs38++
Mcrates/devsite-server/src/api.rs452++++++++++++++++++++++++++-
Mcrates/devsite-server/src/db.rs240+++++++++++++-
Mcrates/devsite-server/src/issuer.rs9+
Adocs/access-broker.md109++++++
Mdocs/json-output.md25+
Aplugins/devsite-access/.codex-plugin/plugin.json23+
Aplugins/devsite-access/.mcp.json10+
Aplugins/devsite-access/scripts/devsite-mcp.mjs336++++++++++++++++++++
Aplugins/devsite-access/skills/devsite-cli/SKILL.md90+++++
Mskills/devsite-cli/SKILL.md46+++-
Mweb/app.js16+-
M Cargo.lock +1 -0
M README.md +39 -3
@@ -158,6 +158,41 @@ agent skill that teaches these intent-based workflows through the CLI's JSON con
158158 at [`skills/devsite-cli/SKILL.md`](skills/devsite-cli/SKILL.md); it has no dependency on a159159 particular model or agent harness.160160 161+## Brokered agent access162+163+A sandboxed worker can request temporary service access without receiving a logged-in164+machine's credential or endpoint key:165+166+```bash167+# Requester: keep requester.key private; send only request.json to the broker.168+devsite access request postgres --request request.json --key requester.key --json169+170+# Broker: requires a machine ticket enrolled with service_grants:issue.171+devsite access resolve postgres --json172+devsite access grant --request request.json --plan --json173+devsite access grant --request request.json --approved-plan dsp_... --json174+175+# Requester: use the server and dss_ grant returned by the broker.176+devsite --server https://dev.site access connect dss_... --key requester.key --json177+```178+179+The request signs the service keyword, requester endpoint, request id, and expiry. The180+broker resolves that intent only among services its account may access, signs the chosen181+resource and expiry, and receives a single-use, short-lived grant bound to the requester's182+endpoint. Requests expire within 10 minutes; grants within 15 minutes. Replaying an issued183+request id is rejected. Apply requires the server-signed `dsp_…` token returned by the184+exact reviewed plan, so changing the request, resource, endpoint, or expiry after approval185+is rejected. The dashboard exposes broker authority as an explicit186+`service_grants:issue` scope on the single-use machine enrollment ticket, and revoking that187+machine credential removes the grant sessions it issued.188+189+The optional [`plugins/devsite-access`](plugins/devsite-access) bundle packages the same190+harness-neutral skill with a local MCP stdio adapter. The adapter implements MCP191+`2026-07-28` discovery and request-scoped metadata: there is no `initialize` exchange,192+negotiated session, or call-order state. Any compatible MCP client can use the adapter; the193+plugin manifest is only a packaging layer. See [brokered access](docs/access-broker.md) for194+the trust model and integration contract.195+161196 Homebrew installs can supervise it at login on macOS or Linux:162197 163198 ```bash
@@ -170,9 +205,10 @@ Native Linux packages may install `packaging/systemd/devsite.service` as a user
170205 systemctl --user enable --now devsite.service171206 ```172207 173-The endpoint identity persists in the devsite config directory, with its Ed25519 public key174-written to `devsite-endpoint.pub`. The daemon registers that public endpoint id with the control175-plane once at startup, publishes its address through Iroh, and reloads service targets and208+The endpoint identity persists in the devsite config directory as209+`devsite-endpoint.key`, with its Ed25519 public key written to210+`devsite-endpoint.pub`. The daemon registers that public endpoint id with the control plane211+once at startup, publishes its address through Iroh, and reloads service targets and176212 authorizations every two seconds. Adding or removing a service does not require restarting177213 it.178214
M crates/devsite-cli/Cargo.toml +1 -0
M crates/devsite-cli/src/client.rs +14 -0
M crates/devsite-cli/src/main.rs +497 -11
@@ -10,6 +10,7 @@ use devsite_daemon::Daemon;
1010 use devsite_proto::{AccountId, ResourceId, SignedCapability};1111 use serde::{Deserialize, Serialize};1212 use std::collections::HashSet;13+use std::fs::OpenOptions;1314 use std::io::IsTerminal;1415 use std::net::{IpAddr, Ipv4Addr, SocketAddr};1516 use std::sync::Arc;
@@ -60,6 +61,9 @@ enum Command {
6061 /// Host local TCP services.6162 #[command(subcommand)]6263 Service(ServiceCommand),64+ /// Request and broker short-lived endpoint-bound service access.65+ #[command(subcommand)]66+ Access(AccessCommand),6367 /// Forward a local TCP port to a service you may access.6468 Connect {6569 /// Short-lived connection ticket minted on dev.site.
@@ -149,6 +153,54 @@ enum ServiceCommand {
149153 },150154 }151155 156+#[derive(Subcommand)]157+enum AccessCommand {158+ /// Create a signed request without contacting the control plane.159+ Request {160+ /// Human service keyword for a trusted broker to resolve.161+ service: String,162+ /// Public JSON request to hand to the broker.163+ #[arg(long, value_name = "FILE")]164+ request: std::path::PathBuf,165+ /// Private endpoint key retained by the requesting sandbox.166+ #[arg(long, value_name = "FILE")]167+ key: std::path::PathBuf,168+ /// Seconds before the request itself expires (maximum 600).169+ #[arg(long, default_value_t = 300)]170+ ttl: u64,171+ },172+ /// Find services this broker credential may delegate.173+ Resolve { keyword: String },174+ /// Validate or issue a grant for a signed request.175+ Grant {176+ /// Signed request JSON received from the sandboxed agent.177+ #[arg(long, value_name = "FILE")]178+ request: std::path::PathBuf,179+ /// Canonical resource id. Omit only when the keyword has one unambiguous match.180+ #[arg(long)]181+ resource: Option<String>,182+ /// Grant lifetime in seconds (maximum 900).183+ #[arg(long, default_value_t = 900)]184+ ttl: u64,185+ /// Validate and show the exact endpoint-bound grant without issuing it.186+ #[arg(long, visible_alias = "dry-run")]187+ plan: bool,188+ /// Server-signed token returned by an approved plan.189+ #[arg(long, requires = "request")]190+ approved_plan: Option<String>,191+ },192+ /// Forward a local TCP port using an endpoint-bound broker grant.193+ Connect {194+ /// Session grant returned by the broker.195+ grant: String,196+ /// Private endpoint key created with `access request`.197+ #[arg(long, value_name = "FILE")]198+ key: std::path::PathBuf,199+ #[arg(long, default_value = "127.0.0.1:0")]200+ listen: SocketAddr,201+ },202+}203+152204 #[derive(Subcommand)]153205 enum DaemonCommand {154206 /// Run the daemon in the foreground.
@@ -184,6 +236,11 @@ impl Command {
184236 Self::Resources(ResourcesCommand::List) => "resources.list",185237 Self::Service(ServiceCommand::Host { .. }) => "service.host",186238 Self::Service(ServiceCommand::Remove { .. }) => "service.remove",239+ Self::Access(AccessCommand::Request { .. }) => "access.request",240+ Self::Access(AccessCommand::Resolve { .. }) => "access.resolve",241+ Self::Access(AccessCommand::Grant { plan: true, .. }) => "access.grant.plan",242+ Self::Access(AccessCommand::Grant { plan: false, .. }) => "access.grant",243+ Self::Access(AccessCommand::Connect { .. }) => "access.connect",187244 Self::Connect { .. } => "connect",188245 Self::Theme(ThemeCommand::Show) => "theme.show",189246 Self::Theme(ThemeCommand::Set { .. }) => "theme.set",
@@ -223,6 +280,7 @@ fn help_target(args: &[std::ffi::OsString]) -> Option<String> {
223280 | "link"224281 | "resources"225282 | "service"283+ | "access"226284 | "connect"227285 | "theme"228286 | "status"
@@ -237,6 +295,7 @@ fn help_target(args: &[std::ffi::OsString]) -> Option<String> {
237295 ("link", Some("set" | "remove"))238296 | ("resources", Some("list"))239297 | ("service", Some("host" | "remove"))298+ | ("access", Some("request" | "resolve" | "grant" | "connect"))240299 | ("theme", Some("show" | "set" | "clear" | "properties"))241300 | ("daemon", Some("run" | "status"))242301 );
@@ -340,6 +399,52 @@ struct EnrollMachineResponse {
340399 endpoint_id: String,341400 }342401 402+#[derive(Debug, Deserialize, Serialize)]403+struct ServiceGrantRequest {404+ schema_version: u32,405+ request_id: String,406+ service: String,407+ requester_endpoint_id: String,408+ expires_at: u64,409+ proof: String,410+}411+412+#[derive(Deserialize, Serialize)]413+struct AccessibleService {414+ resource_id: String,415+ name: String,416+ owner_handle: Option<String>,417+ visibility: String,418+ exact_name_match: bool,419+}420+421+#[derive(Deserialize)]422+struct AccessibleServicesResponse {423+ keyword: String,424+ services: Vec<AccessibleService>,425+}426+427+#[derive(Deserialize, Serialize)]428+struct ServiceGrantResponse {429+ grant: String,430+ request_id: String,431+ resource_id: String,432+ name: String,433+ owner_id: String,434+ owner_handle: Option<String>,435+ requester_endpoint_id: String,436+ expires_at: u64,437+}438+439+#[derive(Deserialize)]440+struct TunnelSessionInfo {441+ resource_id: String,442+ name: String,443+ requester_endpoint_id: String,444+ expires_at: u64,445+ brokered: bool,446+}447+343448 #[derive(Deserialize)]344449 struct AuthorizationListing {345450 authorizations: Vec<ServiceAuthorization>,
@@ -540,6 +645,36 @@ async fn run(cli: Cli, output: Output) -> Result<()> {
540645 Command::Service(ServiceCommand::Remove { name, plan }) => {541646 remove_service(&paths, &name, plan, output).await542647 }648+ Command::Access(AccessCommand::Request {649+ service,650+ request,651+ key,652+ ttl,653+ }) => create_access_request(&service, &request, &key, ttl, output),654+ Command::Access(AccessCommand::Resolve { keyword }) => {655+ resolve_access(&paths, &keyword, output).await656+ }657+ Command::Access(AccessCommand::Grant {658+ request,659+ resource,660+ ttl,661+ plan,662+ approved_plan,663+ }) => {664+ grant_access(665+ &paths,666+ &request,667+ resource.as_deref(),668+ ttl,669+ plan,670+ approved_plan.as_deref(),671+ output,672+ )673+ .await674+ }675+ Command::Access(AccessCommand::Connect { grant, key, listen }) => {676+ connect_grant(&cli.server, &grant, &key, listen, output).await677+ }543678 Command::Connect { ticket, listen } => connect(&cli.server, &ticket, listen, output).await,544679 Command::Theme(command) => theme(&paths, &cli.server, command, output).await,545680 Command::Status => status(&paths, output),
@@ -1041,6 +1176,358 @@ async fn connect(server: &str, ticket: &str, listen: SocketAddr, output: Output)
10411176 .await10421177 .context("redeeming connection ticket")?;10431178 let api = Arc::new(ControlPlane::new(server, Some(redeemed.session_token)));1179+ run_tunnel(1180+ "connect",1181+ server,1182+ client,1183+ api,1184+ &redeemed.name,1185+ &redeemed.resource_id,1186+ redeemed.expires_at,1187+ listen,1188+ output,1189+ )1190+ .await1191+}1192+1193+fn create_access_request(1194+ service: &str,1195+ request_path: &std::path::Path,1196+ key_path: &std::path::Path,1197+ ttl: u64,1198+ output: Output,1199+) -> Result<()> {1200+ let service = service.trim();1201+ if service.is_empty() || service.chars().count() > 80 {1202+ bail!("service keyword must be 1 to 80 characters");1203+ }1204+ if ttl == 0 || ttl > 10 * 60 {1205+ bail!("request TTL must be between 1 and 600 seconds");1206+ }1207+ if request_path == key_path {1208+ bail!("--request and --key must name different files");1209+ }1210+1211+ let secret = iroh::SecretKey::generate();1212+ let endpoint = secret.public();1213+ let mut nonce = [0u8; 24];1214+ getrandom::fill(&mut nonce)1215+ .map_err(|err| anyhow::anyhow!("generating service grant request id: {err}"))?;1216+ let request_id = format!("agr_{}", data_encoding::BASE64URL_NOPAD.encode(&nonce));1217+ let expires_at = unix_now()?.saturating_add(ttl);1218+ let message = devsite_proto::service_grant_request_message(1219+ &request_id,1220+ service,1221+ endpoint.as_bytes(),1222+ expires_at,1223+ );1224+ let proof = data_encoding::BASE64URL_NOPAD.encode(&secret.sign(&message).to_bytes());1225+ let request = ServiceGrantRequest {1226+ schema_version: 1,1227+ request_id,1228+ service: service.to_string(),1229+ requester_endpoint_id: endpoint.to_string(),1230+ expires_at,1231+ proof,1232+ };1233+1234+ write_new_private(key_path, &secret.to_bytes())?;1235+ let request_json = serde_json::to_vec_pretty(&request)?;1236+ if let Err(err) = write_new_private(request_path, &request_json) {1237+ std::fs::remove_file(key_path).ok();1238+ return Err(err);1239+ }1240+1241+ if output.json {1242+ output.success(1243+ "access.request",1244+ serde_json::json!({1245+ "request": request,1246+ "request_path": request_path,1247+ "key_path": key_path,1248+ "handoff": "share_request_only",1249+ }),1250+ );1251+ } else {1252+ println!("created signed request for {service}");1253+ println!(1254+ " request {} (share with the broker)",1255+ request_path.display()1256+ );1257+ println!(1258+ " key {} (keep inside the requester)",1259+ key_path.display()1260+ );1261+ println!(" endpoint {endpoint}");1262+ println!(" expires {expires_at}");1263+ }1264+ Ok(())1265+}1266+1267+async fn resolve_access(paths: &Paths, keyword: &str, output: Output) -> Result<()> {1268+ let listing = resolve_access_listing(paths, keyword).await?;1269+ if output.json {1270+ output.success("access.resolve", serde_json::to_value(&listing.services)?);1271+ } else if listing.services.is_empty() {1272+ println!("no accessible services match {:?}", listing.keyword);1273+ } else {1274+ for service in listing.services {1275+ let owner = service1276+ .owner_handle1277+ .map(|handle| format!(" from @{handle}"))1278+ .unwrap_or_default();1279+ println!("{} {}{}", service.resource_id, service.name, owner);1280+ }1281+ }1282+ Ok(())1283+}1284+1285+async fn resolve_access_listing(1286+ paths: &Paths,1287+ keyword: &str,1288+) -> Result<AccessibleServicesResponse> {1289+ let config = load_authenticated(paths)?;1290+ let server = enrolled_server(&config)?;1291+ let api = ControlPlane::new(server, config.machine_credential.clone());1292+ api.get_query("/api/access/services", &[("keyword", keyword)])1293+ .await1294+ .context("resolving accessible services")1295+}1296+1297+async fn grant_access(1298+ paths: &Paths,1299+ request_path: &std::path::Path,1300+ resource: Option<&str>,1301+ ttl: u64,1302+ plan: bool,1303+ approved_plan: Option<&str>,1304+ output: Output,1305+) -> Result<()> {1306+ if ttl == 0 || ttl > 15 * 60 {1307+ bail!("grant TTL must be between 1 and 900 seconds");1308+ }1309+ if plan && approved_plan.is_some() {1310+ bail!("--approved-plan cannot be combined with --plan");1311+ }1312+ if !plan && approved_plan.is_none() {1313+ bail!("apply requires --approved-plan from the exact reviewed plan");1314+ }1315+ let approved_claims = approved_plan1316+ .map(|token| {1317+ devsite_proto::access_plan::SignedServiceGrantPlan::from_token(token)1318+ .and_then(|plan| plan.unverified_claims())1319+ .map_err(|_| anyhow::anyhow!("--approved-plan is malformed"))1320+ })1321+ .transpose()?;1322+ let request: ServiceGrantRequest = serde_json::from_slice(1323+ &std::fs::read(request_path)1324+ .with_context(|| format!("reading {}", request_path.display()))?,1325+ )1326+ .with_context(|| format!("parsing {}", request_path.display()))?;1327+ if approved_claims.as_ref().is_some_and(|claims| {1328+ claims.schema_version != request.schema_version1329+ || claims.request_id != request.request_id1330+ || claims.service != request.service1331+ || claims.requester_endpoint_id != request.requester_endpoint_id1332+ || claims.request_expires_at != request.expires_at1333+ }) {1334+ bail!("--approved-plan does not match the signed request file");1335+ }1336+ let listing = resolve_access_listing(paths, &request.service).await?;1337+ let resource_id = match (resource, &approved_claims) {1338+ (Some(resource), Some(claims)) if resource != claims.resource_id => {1339+ bail!("--resource does not match --approved-plan")1340+ }1341+ (_, Some(claims)) => {1342+ if !listing1343+ .services1344+ .iter()1345+ .any(|item| item.resource_id == claims.resource_id)1346+ {1347+ bail!("the approved service is no longer accessible to this broker");1348+ }1349+ claims.resource_id.clone()1350+ }1351+ (Some(resource), None) => {1352+ if !listing1353+ .services1354+ .iter()1355+ .any(|item| item.resource_id == resource)1356+ {1357+ bail!(1358+ "resource {resource} is not among the services matching {:?}",1359+ request.service1360+ );1361+ }1362+ resource.to_string()1363+ }1364+ (None, None) => select_access_service(&request.service, &listing.services)?1365+ .resource_id1366+ .clone(),1367+ };1368+ let expires_at = match &approved_claims {1369+ Some(claims) => claims.grant_expires_at,1370+ None => unix_now()?.saturating_add(ttl),1371+ };1372+ let requester: iroh::EndpointId = request1373+ .requester_endpoint_id1374+ .parse()1375+ .context("request contains an invalid endpoint id")?;1376+ let broker = paths.load_or_create_identity()?;1377+ let message = devsite_proto::service_grant_issue_message(1378+ &request.request_id,1379+ &resource_id,1380+ requester.as_bytes(),1381+ expires_at,1382+ );1383+ let broker_proof = data_encoding::BASE64URL_NOPAD.encode(&broker.sign(&message).to_bytes());1384+ let body = serde_json::json!({1385+ "request": request,1386+ "resource_id": resource_id,1387+ "expires_at": expires_at,1388+ "broker_proof": broker_proof,1389+ "approved_plan": approved_plan,1390+ });1391+ let config = load_authenticated(paths)?;1392+ let server = enrolled_server(&config)?;1393+ let api = ControlPlane::new(server, config.machine_credential.clone());1394+ if plan {1395+ let result: serde_json::Value = api1396+ .post("/api/access/grants/plan", &body)1397+ .await1398+ .context("planning endpoint-bound service grant")?;1399+ if output.json {1400+ output.success("access.grant.plan", result);1401+ } else {1402+ println!("{}", serde_json::to_string_pretty(&result)?);1403+ }1404+ } else {1405+ let grant: ServiceGrantResponse = api1406+ .post("/api/access/grants", &body)1407+ .await1408+ .context("issuing endpoint-bound service grant")?;1409+ if output.json {1410+ let mut result = serde_json::to_value(&grant)?;1411+ if let serde_json::Value::Object(fields) = &mut result {1412+ fields.insert("server".to_string(), serde_json::json!(server));1413+ }1414+ output.success("access.grant", result);1415+ } else {1416+ println!("issued {} access to {}", grant.request_id, grant.name);1417+ if let Some(owner) = &grant.owner_handle {1418+ println!(" owner @{owner}");1419+ } else {1420+ println!(" owner {}", grant.owner_id);1421+ }1422+ println!(" endpoint {}", grant.requester_endpoint_id);1423+ println!(" expires {}", grant.expires_at);1424+ println!(" grant {}", grant.grant);1425+ }1426+ }1427+ Ok(())1428+}1429+1430+fn select_access_service<'a>(1431+ keyword: &str,1432+ services: &'a [AccessibleService],1433+) -> Result<&'a AccessibleService> {1434+ let exact = services1435+ .iter()1436+ .filter(|service| service.exact_name_match)1437+ .collect::<Vec<_>>();1438+ match (exact.as_slice(), services) {1439+ ([service], _) => Ok(*service),1440+ ([], [service]) => Ok(service),1441+ ([], []) => bail!("no accessible service matches {keyword:?}"),1442+ _ => bail!(1443+ "service keyword {keyword:?} is ambiguous; run `devsite access resolve {keyword}` and pass --resource"1444+ ),1445+ }1446+}1447+1448+async fn connect_grant(1449+ server: &str,1450+ grant: &str,1451+ key_path: &std::path::Path,1452+ listen: SocketAddr,1453+ output: Output,1454+) -> Result<()> {1455+ if !grant.starts_with("dss_") {1456+ bail!("broker grant must start with dss_");1457+ }1458+ let raw = std::fs::read(key_path).with_context(|| format!("reading {}", key_path.display()))?;1459+ let bytes: [u8; 32] = raw1460+ .as_slice()1461+ .try_into()1462+ .map_err(|_| anyhow::anyhow!("{} is not a 32 byte endpoint key", key_path.display()))?;1463+ let client =1464+ Arc::new(ClientEndpoint::create_with_secret(iroh::SecretKey::from_bytes(&bytes)).await?);1465+ let api = Arc::new(ControlPlane::new(server, Some(grant.to_string())));1466+ let session: TunnelSessionInfo = api1467+ .get("/api/tunnel/session")1468+ .await1469+ .context("validating broker grant")?;1470+ if session.requester_endpoint_id != client.endpoint_id().to_string() || !session.brokered {1471+ bail!("the grant is not bound to the supplied requester endpoint key");1472+ }1473+ run_tunnel(1474+ "access.connect",1475+ server,1476+ client,1477+ api,1478+ &session.name,1479+ &session.resource_id,1480+ session.expires_at,1481+ listen,1482+ output,1483+ )1484+ .await1485+}1486+1487+fn unix_now() -> Result<u64> {1488+ Ok(std::time::SystemTime::now()1489+ .duration_since(std::time::UNIX_EPOCH)1490+ .context("system clock is before unix epoch")?1491+ .as_secs())1492+}1493+1494+fn write_new_private(path: &std::path::Path, contents: &[u8]) -> Result<()> {1495+ if let Some(parent) = path1496+ .parent()1497+ .filter(|parent| !parent.as_os_str().is_empty())1498+ {1499+ std::fs::create_dir_all(parent)?;1500+ }1501+ let mut options = OpenOptions::new();1502+ options.write(true).create_new(true);1503+ #[cfg(unix)]1504+ {1505+ use std::os::unix::fs::OpenOptionsExt;1506+ options.mode(0o600);1507+ }1508+ let mut file = options1509+ .open(path)1510+ .with_context(|| format!("creating {} without overwriting it", path.display()))?;1511+ use std::io::Write;1512+ file.write_all(contents)?;1513+ Ok(())1514+}1515+1516+#[allow(clippy::too_many_arguments)]1517+async fn run_tunnel(1518+ command: &str,1519+ server: &str,1520+ client: Arc<ClientEndpoint>,1521+ api: Arc<ControlPlane>,1522+ name: &str,1523+ resource_id: &str,1524+ expires_at: u64,1525+ listen: SocketAddr,1526+ output: Output,1527+) -> Result<()> {1528+ if !listen.ip().is_loopback() {1529+ bail!("--listen must be a loopback address; refusing to publish the tunnel on the LAN");1530+ }10441531 let listener = TcpListener::bind(listen)10451532 .await10461533 .with_context(|| format!("binding local listener {listen}"))?;
@@ -1048,23 +1535,22 @@ async fn connect(server: &str, ticket: &str, listen: SocketAddr, output: Output)
10481535 10491536 if output.json {10501537 output.event(1051- "connect",1538+ command,10521539 "listening",10531540 serde_json::json!({1054- "name": redeemed.name,1055- "resource_id": redeemed.resource_id,1056- "resource_url": format!("{server}/s/{}", redeemed.resource_id),1541+ "name": name,1542+ "resource_id": resource_id,1543+ "resource_url": format!("{server}/s/{resource_id}"),10571544 "listen": bound.to_string(),1058- "expires_at": redeemed.expires_at,1545+ "expires_at": expires_at,10591546 }),10601547 );10611548 } else {1062- println!(1063- "connected to {} ({server}/s/{})",1064- redeemed.name, redeemed.resource_id1065- );1549+ println!("connected to {} ({server}/s/{})", name, resource_id);10661550 println!(" listening on {bound}");1067- println!(" session expires at unix time {}", redeemed.expires_at);1551+ if expires_at > 0 {1552+ println!(" session expires at unix time {expires_at}");1553+ }10681554 println!(" press Ctrl-C to stop");10691555 }10701556
@@ -1073,7 +1559,7 @@ async fn connect(server: &str, ticket: &str, listen: SocketAddr, output: Output)
10731559 accepted = listener.accept() => accepted.context("accepting local connection")?,10741560 _ = tokio::signal::ctrl_c() => {10751561 if output.json {1076- output.event("connect", "shutdown", serde_json::json!({}));1562+ output.event(command, "shutdown", serde_json::json!({}));10771563 } else {10781564 println!("\nshutting down");10791565 }
M crates/devsite-cli/tests/json.rs +43 -0
@@ -136,3 +136,46 @@ fn resources_and_plan_flags_are_discoverable_in_structured_help() {
136136 assert!(text.contains("--plan"));137137 assert!(text.contains("--dry-run"));138138 }139+140+#[test]141+fn access_request_writes_a_shareable_request_and_separate_private_key() {142+ let suffix = NEXT_HOME.fetch_add(1, Ordering::Relaxed);143+ let root = std::env::temp_dir().join(format!(144+ "devsite-access-request-test-{}-{suffix}",145+ std::process::id()146+ ));147+ std::fs::create_dir_all(&root).unwrap();148+ let request = root.join("request.json");149+ let key = root.join("requester.key");150+ let output = Command::new(env!("CARGO_BIN_EXE_devsite"))151+ .args([152+ "--json",153+ "access",154+ "request",155+ "postgres",156+ "--request",157+ request.to_str().unwrap(),158+ "--key",159+ key.to_str().unwrap(),160+ ])161+ .env("DEVSITE_HOME", root.join("home"))162+ .output()163+ .unwrap();164+ assert!(165+ output.status.success(),166+ "{}",167+ String::from_utf8_lossy(&output.stderr)168+ );169+ let value = json(&output);170+ assert_eq!(value["command"], "access.request");171+ assert_eq!(value["result"]["request"]["service"], "postgres");172+ assert!(value["result"]["request"]["request_id"]173+ .as_str()174+ .unwrap()175+ .starts_with("agr_"));176+ assert_eq!(std::fs::read(&key).unwrap().len(), 32);177+ let stored: serde_json::Value =178+ serde_json::from_slice(&std::fs::read(&request).unwrap()).unwrap();179+ assert_eq!(stored, value["result"]["request"]);180+ std::fs::remove_dir_all(root).unwrap();181+}
M crates/devsite-client/src/lib.rs +15 -0
A crates/devsite-proto/src/access_plan.rs +120 -0
@@ -0,0 +1,120 @@
1+//! Server-signed approval tokens for endpoint-bound service grants.2+3+use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};4+use serde::{Deserialize, Serialize};5+6+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]7+pub struct ServiceGrantPlanClaims {8+ pub schema_version: u32,9+ pub issuer_credential_id: String,10+ pub request_id: String,11+ pub service: String,12+ pub resource_id: String,13+ pub requester_endpoint_id: String,14+ pub request_expires_at: u64,15+ pub grant_expires_at: u64,16+}17+18+#[derive(Debug, Clone, Serialize, Deserialize)]19+pub struct SignedServiceGrantPlan {20+ claims: Vec<u8>,21+ signature: Vec<u8>,22+}23+24+#[derive(Debug, thiserror::Error, PartialEq, Eq)]25+pub enum ServiceGrantPlanError {26+ #[error("service grant plan signature does not verify")]27+ BadSignature,28+ #[error("service grant plan is malformed")]29+ Malformed,30+}31+32+impl SignedServiceGrantPlan {33+ pub fn sign(34+ claims: &ServiceGrantPlanClaims,35+ key: &SigningKey,36+ ) -> Result<Self, ServiceGrantPlanError> {37+ let mut bytes = b"dev.site approved service grant plan v1\0".to_vec();38+ bytes.extend(postcard::to_allocvec(claims).map_err(|_| ServiceGrantPlanError::Malformed)?);39+ Ok(Self {40+ signature: key.sign(&bytes).to_bytes().to_vec(),41+ claims: bytes,42+ })43+ }44+45+ pub fn verify(46+ &self,47+ key: &VerifyingKey,48+ ) -> Result<ServiceGrantPlanClaims, ServiceGrantPlanError> {49+ let signature: [u8; 64] = self50+ .signature51+ .as_slice()52+ .try_into()53+ .map_err(|_| ServiceGrantPlanError::Malformed)?;54+ key.verify(&self.claims, &Signature::from_bytes(&signature))55+ .map_err(|_| ServiceGrantPlanError::BadSignature)?;56+ let payload = self57+ .claims58+ .strip_prefix(b"dev.site approved service grant plan v1\0")59+ .ok_or(ServiceGrantPlanError::Malformed)?;60+ postcard::from_bytes(payload).map_err(|_| ServiceGrantPlanError::Malformed)61+ }62+63+ pub fn to_token(&self) -> String {64+ let bytes = postcard::to_allocvec(self).expect("a signed service grant plan serializes");65+ format!("dsp_{}", data_encoding::BASE64URL_NOPAD.encode(&bytes))66+ }67+68+ pub fn from_token(token: &str) -> Result<Self, ServiceGrantPlanError> {69+ let encoded = token70+ .strip_prefix("dsp_")71+ .ok_or(ServiceGrantPlanError::Malformed)?;72+ let bytes = data_encoding::BASE64URL_NOPAD73+ .decode(encoded.as_bytes())74+ .map_err(|_| ServiceGrantPlanError::Malformed)?;75+ postcard::from_bytes(&bytes).map_err(|_| ServiceGrantPlanError::Malformed)76+ }77+78+ /// Decode untrusted claims for clients that need to reconstruct the exact79+ /// request. The server must still verify the signature before authorizing it.80+ pub fn unverified_claims(&self) -> Result<ServiceGrantPlanClaims, ServiceGrantPlanError> {81+ let payload = self82+ .claims83+ .strip_prefix(b"dev.site approved service grant plan v1\0")84+ .ok_or(ServiceGrantPlanError::Malformed)?;85+ postcard::from_bytes(payload).map_err(|_| ServiceGrantPlanError::Malformed)86+ }87+}88+89+#[cfg(test)]90+mod tests {91+ use super::*;92+93+ fn claims() -> ServiceGrantPlanClaims {94+ ServiceGrantPlanClaims {95+ schema_version: 1,96+ issuer_credential_id: "machine_one".into(),97+ request_id: "agr_one".into(),98+ service: "postgres".into(),99+ resource_id: "res_one".into(),100+ requester_endpoint_id: "endpoint".into(),101+ request_expires_at: 200,102+ grant_expires_at: 300,103+ }104+ }105+106+ #[test]107+ fn signed_plan_token_round_trips_and_rejects_another_issuer() {108+ let issuer = SigningKey::from_bytes(&[1; 32]);109+ let other = SigningKey::from_bytes(&[2; 32]);110+ let token = SignedServiceGrantPlan::sign(&claims(), &issuer)111+ .unwrap()112+ .to_token();113+ let restored = SignedServiceGrantPlan::from_token(&token).unwrap();114+ assert_eq!(restored.verify(&issuer.verifying_key()).unwrap(), claims());115+ assert_eq!(116+ restored.verify(&other.verifying_key()),117+ Err(ServiceGrantPlanError::BadSignature)118+ );119+ }120+}
M crates/devsite-proto/src/lib.rs +38 -0
@@ -1,6 +1,7 @@
11 //! Types shared by every dev.site component: the CLI, the daemon, the control plane, and22 //! native clients. No I/O lives here.33 4+pub mod access_plan;45 pub mod capability;56 pub mod id;67 pub mod wire;
@@ -17,5 +18,42 @@ pub fn machine_endpoint_proof_message(endpoint: &[u8; 32]) -> Vec<u8> {
1718 message1819 }1920 21+/// Canonical statement signed by a short-lived client endpoint when asking a22+/// trusted machine to broker service access on its behalf.23+pub fn service_grant_request_message(24+ request_id: &str,25+ service: &str,26+ requester_endpoint: &[u8; 32],27+ expires_at: u64,28+) -> Vec<u8> {29+ let mut message = b"dev.site service grant request v1\0".to_vec();30+ push_field(&mut message, request_id.as_bytes());31+ push_field(&mut message, service.as_bytes());32+ push_field(&mut message, requester_endpoint);33+ message.extend_from_slice(&expires_at.to_be_bytes());34+ message35+}36+37+/// Canonical statement signed by the enrolled broker endpoint when issuing an38+/// endpoint-bound session for one resolved resource.39+pub fn service_grant_issue_message(40+ request_id: &str,41+ resource_id: &str,42+ requester_endpoint: &[u8; 32],43+ expires_at: u64,44+) -> Vec<u8> {45+ let mut message = b"dev.site service grant issue v1\0".to_vec();46+ push_field(&mut message, request_id.as_bytes());47+ push_field(&mut message, resource_id.as_bytes());48+ push_field(&mut message, requester_endpoint);49+ message.extend_from_slice(&expires_at.to_be_bytes());50+ message51+}52+53+fn push_field(message: &mut Vec<u8>, field: &[u8]) {54+ message.extend_from_slice(&(field.len() as u64).to_be_bytes());55+ message.extend_from_slice(field);56+}57+2058 /// ALPN for the authorized bidirectional TCP stream protocol.2159 pub const ALPN: &[u8] = b"devsite/tcp/1";
M crates/devsite-server/src/api.rs +443 -9
@@ -6,11 +6,12 @@ use std::str::FromStr;
66 use std::sync::{Arc, Mutex};77 use std::time::{SystemTime, UNIX_EPOCH};88 9-use axum::extract::{Path, State};9+use axum::extract::{Path, Query, State};1010 use axum::http::{header, HeaderMap, StatusCode};1111 use axum::response::{IntoResponse, Response};1212 use axum::routing::{delete, get, post, put};1313 use axum::{Json, Router};14+use devsite_proto::access_plan::ServiceGrantPlanClaims;1415 use devsite_proto::capability::KeyBytes;1516 use devsite_proto::{AccountId, ResourceId};1617 use serde::{Deserialize, Serialize};
@@ -104,6 +105,9 @@ impl ApiError {
104105 fn unauthorized() -> Self {105106 Self(StatusCode::UNAUTHORIZED, "sign in first".into())106107 }108+ fn forbidden(message: impl Into<String>) -> Self {109+ Self(StatusCode::FORBIDDEN, message.into())110+ }107111 fn too_many_requests() -> Self {108112 Self(109113 StatusCode::TOO_MANY_REQUESTS,
@@ -314,6 +318,33 @@ pub struct ProfileSettingsRequest {
314318 #[derive(Deserialize)]315319 pub struct CreateMachineCredentialRequest {316320 pub name: String,321+ #[serde(default)]322+ pub scopes: Vec<String>,323+}324+325+#[derive(Debug, Deserialize, Serialize)]326+pub struct ServiceGrantRequestEnvelope {327+ pub schema_version: u32,328+ pub request_id: String,329+ pub service: String,330+ pub requester_endpoint_id: String,331+ pub expires_at: u64,332+ pub proof: String,333+}334+335+#[derive(Debug, Deserialize)]336+pub struct ServiceGrantIssueRequest {337+ pub request: ServiceGrantRequestEnvelope,338+ pub resource_id: String,339+ pub expires_at: u64,340+ pub broker_proof: String,341+ #[serde(default)]342+ pub approved_plan: Option<String>,343+}344+345+#[derive(Debug, Deserialize)]346+pub struct ServiceSearchQuery {347+ pub keyword: String,317348 }318349 319350 #[derive(Serialize)]
@@ -426,8 +457,14 @@ pub fn router(state: Shared) -> Router {
426457 .route("/api/profile/{handle}", get(profile))427458 .route("/api/services/{id}/ticket", post(create_connection_ticket))428459 .route("/api/tickets/redeem", post(redeem_connection_ticket))429- .route("/api/tunnel/session", delete(delete_tunnel_session))460+ .route(461+ "/api/tunnel/session",462+ get(tunnel_session_info).delete(delete_tunnel_session),463+ )430464 .route("/api/tunnel/capability", post(tunnel_capability))465+ .route("/api/access/services", get(accessible_services))466+ .route("/api/access/grants/plan", post(plan_service_grant))467+ .route("/api/access/grants", post(issue_service_grant))431468 .with_state(state)432469 }433470
@@ -1203,20 +1240,25 @@ async fn enroll_machine(
12031240 fn verify_endpoint_proof(endpoint_id: &str, proof: &str) -> ApiResult<()> {12041241 let endpoint = parse_endpoint_id(endpoint_id)12051242 .ok_or_else(|| ApiError::bad_request("endpoint_id is not a 32 byte Ed25519 key"))?;1243+ verify_endpoint_signature(1244+ &endpoint,1245+ proof,1246+ &devsite_proto::machine_endpoint_proof_message(&endpoint),1247+ )1248+}1249+1250+fn verify_endpoint_signature(endpoint: &KeyBytes, proof: &str, message: &[u8]) -> ApiResult<()> {12061251 let signature = data_encoding::BASE64URL_NOPAD12071252 .decode(proof.as_bytes())12081253 .map_err(|_| ApiError::bad_request("endpoint proof is not base64url"))?;12091254 let signature: [u8; 64] = signature12101255 .try_into()12111256 .map_err(|_| ApiError::bad_request("endpoint proof is not a 64 byte signature"))?;1212- let key = ed25519_dalek::VerifyingKey::from_bytes(&endpoint)1257+ let key = ed25519_dalek::VerifyingKey::from_bytes(endpoint)12131258 .map_err(|_| ApiError::bad_request("endpoint_id is not a valid Ed25519 key"))?;12141259 use ed25519_dalek::Verifier;1215- key.verify(1216- &devsite_proto::machine_endpoint_proof_message(&endpoint),1217- &ed25519_dalek::Signature::from_bytes(&signature),1218- )1219- .map_err(|_| ApiError::bad_request("endpoint proof did not verify"))1260+ key.verify(message, &ed25519_dalek::Signature::from_bytes(&signature))1261+ .map_err(|_| ApiError::bad_request("endpoint proof did not verify"))12201262 }12211263 12221264 // -- dashboard ---------------------------------------------------------------
@@ -1250,6 +1292,7 @@ async fn set_profile_settings(
12501292 12511293 const MAX_MACHINE_CREDENTIALS: usize = 10;12521294 const MAX_MACHINE_NAME: usize = 60;1295+const SERVICE_GRANT_SCOPE: &str = "service_grants:issue";12531296 12541297 fn validate_machine_name(name: &str) -> ApiResult<&str> {12551298 if name.trim() != name || name.is_empty() {
@@ -1285,6 +1328,7 @@ async fn list_machine_credentials(
12851328 "last_used_at": credential.last_used_at,12861329 "endpoint_id": credential.endpoint_id,12871330 "enrolled_at": credential.enrolled_at,1331+ "scopes": credential.scopes,12881332 })).collect::<Vec<_>>()12891333 })))12901334 }
@@ -1297,6 +1341,14 @@ async fn create_machine_credential(
12971341 let account = require_browser_account(&state, &headers)?;12981342 check_rate(&state, account, RateClass::Credential)?;12991343 let name = validate_machine_name(&body.name)?;1344+ let mut scopes = body.scopes;1345+ scopes.sort();1346+ scopes.dedup();1347+ if scopes.iter().any(|scope| scope != SERVICE_GRANT_SCOPE) {1348+ return Err(ApiError::bad_request(format!(1349+ "the only supported machine scope is `{SERVICE_GRANT_SCOPE}`"1350+ )));1351+ }13001352 let ticket = auth::generate_machine_ticket();13011353 let id = auth::generate_machine_credential_id();13021354 let now = now_secs();
@@ -1311,7 +1363,7 @@ async fn create_machine_credential(
13111363 "an account may have at most {MAX_MACHINE_CREDENTIALS} active machine credentials"13121364 )));13131365 }1314- db.create_machine_credential(&id, account, name, &auth::hash_token(&ticket), now)1366+ db.create_machine_credential(&id, account, name, &auth::hash_token(&ticket), &scopes, now)13151367 .map_err(ApiError::internal)?;13161368 Ok((13171369 StatusCode::CREATED,
@@ -1321,6 +1373,7 @@ async fn create_machine_credential(
13211373 "name": name,13221374 "created_at": now,13231375 "last_used_at": null,1376+ "scopes": scopes,13241377 },13251378 "ticket": ticket,13261379 })),
@@ -1496,9 +1549,275 @@ fn to_entry(resource: &crate::db::Resource, owner_handle: Option<String>) -> Pro
14961549 14971550 const CONNECTION_TICKET_LIFETIME_SECS: u64 = 2 * 60;14981551 const TUNNEL_SESSION_LIFETIME_SECS: u64 = 8 * 60 * 60;1552+const SERVICE_GRANT_REQUEST_LIFETIME_SECS: u64 = 10 * 60;1553+const SERVICE_GRANT_MAX_LIFETIME_SECS: u64 = 15 * 60;1554+const MAX_ACTIVE_BROKER_GRANTS: usize = 32;14991555 const CONNECTION_TICKET_PREFIX: &str = "dst_";15001556 const TUNNEL_SESSION_PREFIX: &str = "dss_";15011557 1558+async fn accessible_services(1559+ State(state): State<Shared>,1560+ headers: HeaderMap,1561+ Query(query): Query<ServiceSearchQuery>,1562+) -> ApiResult<impl IntoResponse> {1563+ let machine = require_machine(&state, &headers)?;1564+ require_service_grant_scope(&machine)?;1565+ let keyword = query.keyword.trim();1566+ if keyword.is_empty() || keyword.chars().count() > MAX_RESOURCE_NAME {1567+ return Err(ApiError::bad_request("keyword must be 1 to 80 characters"));1568+ }1569+ let needle = keyword.to_lowercase();1570+ let db = state.db.lock().unwrap();1571+ let services = db1572+ .accessible_services(machine.account.id)1573+ .map_err(ApiError::internal)?1574+ .into_iter()1575+ .filter(|(resource, owner)| {1576+ resource.name.to_lowercase().contains(&needle)1577+ || owner1578+ .as_deref()1579+ .is_some_and(|handle| handle.to_lowercase().contains(&needle))1580+ })1581+ .map(|(resource, owner_handle)| {1582+ serde_json::json!({1583+ "resource_id": resource.id.to_string(),1584+ "name": resource.name,1585+ "owner_handle": owner_handle,1586+ "visibility": resource.visibility.as_str(),1587+ "exact_name_match": resource.name.eq_ignore_ascii_case(keyword),1588+ })1589+ })1590+ .collect::<Vec<_>>();1591+ Ok(Json(serde_json::json!({1592+ "keyword": keyword,1593+ "services": services,1594+ })))1595+}1596+1597+async fn plan_service_grant(1598+ State(state): State<Shared>,1599+ headers: HeaderMap,1600+ Json(body): Json<ServiceGrantIssueRequest>,1601+) -> ApiResult<impl IntoResponse> {1602+ let machine = require_machine(&state, &headers)?;1603+ require_service_grant_scope(&machine)?;1604+ let now = now_secs();1605+ let db = state.db.lock().unwrap();1606+ let (resource, owner_handle, claims) = validate_service_grant(&db, &machine, &body, now)?;1607+ let approved_plan = state1608+ .issuer1609+ .issue_access_plan(&claims)1610+ .map_err(ApiError::internal)?;1611+ Ok(Json(service_grant_plan(1612+ &machine,1613+ &body,1614+ &resource,1615+ owner_handle.as_deref(),1616+ &approved_plan,1617+ )))1618+}1619+1620+async fn issue_service_grant(1621+ State(state): State<Shared>,1622+ headers: HeaderMap,1623+ Json(body): Json<ServiceGrantIssueRequest>,1624+) -> ApiResult<impl IntoResponse> {1625+ let machine = require_machine(&state, &headers)?;1626+ require_service_grant_scope(&machine)?;1627+ check_rate(&state, machine.account.id, RateClass::Capability)?;1628+ let now = now_secs();1629+ let token = prefixed_token(TUNNEL_SESSION_PREFIX);1630+ let (resource, owner_handle) = {1631+ let db = state.db.lock().unwrap();1632+ let (resource, owner_handle, claims) = validate_service_grant(&db, &machine, &body, now)?;1633+ let approved_plan = body1634+ .approved_plan1635+ .as_deref()1636+ .ok_or_else(|| ApiError::bad_request("an approved plan token is required"))?;1637+ let approved_claims = state1638+ .issuer1639+ .verify_access_plan(approved_plan)1640+ .map_err(|_| ApiError::bad_request("approved plan token did not verify"))?;1641+ if approved_claims != claims {1642+ return Err(ApiError::bad_request(1643+ "approved plan does not match this grant request",1644+ ));1645+ }1646+ if db1647+ .active_broker_grant_count(&machine.credential_id, now)1648+ .map_err(ApiError::internal)?1649+ >= MAX_ACTIVE_BROKER_GRANTS1650+ {1651+ return Err(ApiError::too_many_requests());1652+ }1653+ if !db1654+ .create_broker_tunnel_session(1655+ &auth::hash_token(&token),1656+ machine.account.id,1657+ resource.id,1658+ &body.request.requester_endpoint_id,1659+ &machine.credential_id,1660+ &body.request.request_id,1661+ now,1662+ body.expires_at,1663+ )1664+ .map_err(ApiError::internal)?1665+ {1666+ return Err(ApiError::bad_request(1667+ "this service grant request was already used",1668+ ));1669+ }1670+ (resource, owner_handle)1671+ };1672+1673+ Ok((1674+ StatusCode::CREATED,1675+ Json(serde_json::json!({1676+ "grant": token,1677+ "request_id": body.request.request_id,1678+ "resource_id": resource.id.to_string(),1679+ "name": resource.name,1680+ "owner_id": resource.owner_id.to_string(),1681+ "owner_handle": owner_handle,1682+ "requester_endpoint_id": body.request.requester_endpoint_id,1683+ "expires_at": body.expires_at,1684+ })),1685+ ))1686+}1687+1688+fn require_service_grant_scope(machine: &MachineAuthentication) -> ApiResult<()> {1689+ if machine1690+ .scopes1691+ .iter()1692+ .any(|scope| scope == SERVICE_GRANT_SCOPE)1693+ {1694+ Ok(())1695+ } else {1696+ Err(ApiError::forbidden(format!(1697+ "this machine credential lacks `{SERVICE_GRANT_SCOPE}`"1698+ )))1699+ }1700+}1701+1702+fn validate_service_grant(1703+ db: &Db,1704+ machine: &MachineAuthentication,1705+ body: &ServiceGrantIssueRequest,1706+ now: u64,1707+) -> ApiResult<(crate::db::Resource, Option<String>, ServiceGrantPlanClaims)> {1708+ let request = &body.request;1709+ if request.schema_version != 1 {1710+ return Err(ApiError::bad_request(1711+ "unsupported service grant request version",1712+ ));1713+ }1714+ if !valid_grant_request_id(&request.request_id) {1715+ return Err(ApiError::bad_request("invalid service grant request id"));1716+ }1717+ if request.service.trim() != request.service1718+ || request.service.is_empty()1719+ || request.service.chars().count() > MAX_RESOURCE_NAME1720+ {1721+ return Err(ApiError::bad_request("invalid requested service keyword"));1722+ }1723+ if request.expires_at <= now1724+ || request.expires_at > now.saturating_add(SERVICE_GRANT_REQUEST_LIFETIME_SECS)1725+ {1726+ return Err(ApiError::bad_request(1727+ "service grant request is expired or too long-lived",1728+ ));1729+ }1730+ if body.expires_at <= now1731+ || body.expires_at > now.saturating_add(SERVICE_GRANT_MAX_LIFETIME_SECS)1732+ {1733+ return Err(ApiError::bad_request(1734+ "service grant lifetime must be at most 15 minutes",1735+ ));1736+ }1737+1738+ let requester = parse_endpoint_id(&request.requester_endpoint_id)1739+ .ok_or_else(|| ApiError::bad_request("requester endpoint id is invalid"))?;1740+ let request_message = devsite_proto::service_grant_request_message(1741+ &request.request_id,1742+ &request.service,1743+ &requester,1744+ request.expires_at,1745+ );1746+ verify_endpoint_signature(&requester, &request.proof, &request_message)?;1747+1748+ let resource_id = ResourceId::from_str(&body.resource_id).map_err(|_| ApiError::not_found())?;1749+ let needle = request.service.to_lowercase();1750+ let resource = db1751+ .accessible_services(machine.account.id)1752+ .map_err(ApiError::internal)?1753+ .into_iter()1754+ .find(|(resource, owner)| {1755+ resource.id == resource_id1756+ && (resource.name.to_lowercase().contains(&needle)1757+ || owner1758+ .as_deref()1759+ .is_some_and(|handle| handle.to_lowercase().contains(&needle)))1760+ })1761+ .ok_or_else(ApiError::not_found)?;1762+1763+ let broker = parse_endpoint_id(&machine.endpoint_id)1764+ .ok_or_else(|| ApiError::internal(anyhow::anyhow!("broker endpoint id is invalid")))?;1765+ let issue_message = devsite_proto::service_grant_issue_message(1766+ &request.request_id,1767+ &body.resource_id,1768+ &requester,1769+ body.expires_at,1770+ );1771+ verify_endpoint_signature(&broker, &body.broker_proof, &issue_message)?;1772+ let claims = ServiceGrantPlanClaims {1773+ schema_version: request.schema_version,1774+ issuer_credential_id: machine.credential_id.clone(),1775+ request_id: request.request_id.clone(),1776+ service: request.service.clone(),1777+ resource_id: body.resource_id.clone(),1778+ requester_endpoint_id: request.requester_endpoint_id.clone(),1779+ request_expires_at: request.expires_at,1780+ grant_expires_at: body.expires_at,1781+ };1782+ Ok((resource.0, resource.1, claims))1783+}1784+1785+fn service_grant_plan(1786+ machine: &MachineAuthentication,1787+ body: &ServiceGrantIssueRequest,1788+ resource: &crate::db::Resource,1789+ owner_handle: Option<&str>,1790+ approved_plan: &str,1791+) -> serde_json::Value {1792+ serde_json::json!({1793+ "action": "issue_service_grant",1794+ "request_id": body.request.request_id,1795+ "requested_service": body.request.service,1796+ "resource": {1797+ "resource_id": resource.id.to_string(),1798+ "name": resource.name,1799+ "owner_id": resource.owner_id.to_string(),1800+ "owner_handle": owner_handle,1801+ },1802+ "requester_endpoint_id": body.request.requester_endpoint_id,1803+ "issuer_credential_id": machine.credential_id,1804+ "expires_at": body.expires_at,1805+ "approved_plan": approved_plan,1806+ "effects": [1807+ "create_endpoint_bound_tunnel_session",1808+ "invalidate_session_when_broker_credential_is_revoked",1809+ ],1810+ })1811+}1812+1813+fn valid_grant_request_id(value: &str) -> bool {1814+ value.starts_with("agr_")1815+ && (12..=100).contains(&value.len())1816+ && value1817+ .bytes()1818+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))1819+}1820+15021821 /// Mint a short-lived, single-use bootstrap ticket from an authenticated browser.15031822 async fn create_connection_ticket(15041823 State(state): State<Shared>,
@@ -1682,6 +2001,46 @@ async fn tunnel_capability(
16822001 }))16832002 }16842003 2004+async fn tunnel_session_info(2005+ State(state): State<Shared>,2006+ headers: HeaderMap,2007+) -> ApiResult<impl IntoResponse> {2008+ let token = bearer_token(&headers).ok_or_else(invalid_tunnel_session)?;2009+ if !valid_prefixed_token(&token, TUNNEL_SESSION_PREFIX) {2010+ return Err(invalid_tunnel_session());2011+ }2012+ let now = now_secs();2013+ let db = state.db.lock().unwrap();2014+ let session = db2015+ .tunnel_session(&auth::hash_token(&token), now)2016+ .map_err(ApiError::internal)?2017+ .ok_or_else(invalid_tunnel_session)?;2018+ let resource = db2019+ .resource(session.resource_id)2020+ .map_err(ApiError::internal)?2021+ .ok_or_else(invalid_tunnel_session)?;2022+ let shared_with = db2023+ .shared_with(session.resource_id)2024+ .map_err(ApiError::internal)?;2025+ if resource.kind != ResourceKind::Service2026+ || !can_view(2027+ Some(session.viewer_id),2028+ resource.owner_id,2029+ resource.visibility,2030+ &shared_with,2031+ )2032+ {2033+ return Err(invalid_tunnel_session());2034+ }2035+ Ok(Json(serde_json::json!({2036+ "resource_id": resource.id.to_string(),2037+ "name": resource.name,2038+ "requester_endpoint_id": session.client_endpoint_id,2039+ "expires_at": session.expires_at,2040+ "brokered": session.issuer_credential_id.is_some(),2041+ })))2042+}2043+16852044 async fn delete_tunnel_session(16862045 State(state): State<Shared>,16872046 headers: HeaderMap,
@@ -1785,6 +2144,81 @@ mod tests {
17852144 assert!(verify_endpoint_proof(&endpoint_id, &forged).is_err());17862145 }17872146 2147+ #[test]2148+ fn broker_grants_require_both_endpoint_signatures_and_matching_service_intent() {2149+ use ed25519_dalek::{Signer, SigningKey};2150+2151+ let mut db = Db::open_in_memory().unwrap();2152+ let account = db.upsert_account("test", "broker", 0).unwrap();2153+ db.set_handle(account.id, "broker").unwrap();2154+ let resource = db2155+ .create_resource(2156+ account.id,2157+ "postgres-primary",2158+ ResourceKind::Service,2159+ Visibility::Private,2160+ None,2161+ None,2162+ 0,2163+ )2164+ .unwrap();2165+ let broker_key = SigningKey::from_bytes(&[11; 32]);2166+ let requester_key = SigningKey::from_bytes(&[12; 32]);2167+ let broker_endpoint = broker_key.verifying_key().to_bytes();2168+ let requester_endpoint = requester_key.verifying_key().to_bytes();2169+ let request_id = "agr_1234567890";2170+ let request_expires = 500;2171+ let grant_expires = 700;2172+ let request_message = devsite_proto::service_grant_request_message(2173+ request_id,2174+ "postgres",2175+ &requester_endpoint,2176+ request_expires,2177+ );2178+ let issue_message = devsite_proto::service_grant_issue_message(2179+ request_id,2180+ &resource.to_string(),2181+ &requester_endpoint,2182+ grant_expires,2183+ );2184+ let request = ServiceGrantRequestEnvelope {2185+ schema_version: 1,2186+ request_id: request_id.into(),2187+ service: "postgres".into(),2188+ requester_endpoint_id: data_encoding::HEXLOWER.encode(&requester_endpoint),2189+ expires_at: request_expires,2190+ proof: data_encoding::BASE64URL_NOPAD2191+ .encode(&requester_key.sign(&request_message).to_bytes()),2192+ };2193+ let machine = MachineAuthentication {2194+ account,2195+ credential_id: "machine_broker".into(),2196+ endpoint_id: data_encoding::HEXLOWER.encode(&broker_endpoint),2197+ scopes: vec![SERVICE_GRANT_SCOPE.into()],2198+ };2199+ let body = ServiceGrantIssueRequest {2200+ request,2201+ resource_id: resource.to_string(),2202+ expires_at: grant_expires,2203+ broker_proof: data_encoding::BASE64URL_NOPAD2204+ .encode(&broker_key.sign(&issue_message).to_bytes()),2205+ approved_plan: None,2206+ };2207+2208+ assert!(require_service_grant_scope(&machine).is_ok());2209+ let (validated, _, plan_claims) =2210+ validate_service_grant(&db, &machine, &body, 100).unwrap();2211+ assert_eq!(validated.id, resource);2212+ assert_eq!(plan_claims.issuer_credential_id, "machine_broker");2213+ assert_eq!(plan_claims.request_id, request_id);2214+ assert_eq!(plan_claims.resource_id, resource.to_string());2215+ assert_eq!(plan_claims.grant_expires_at, grant_expires);2216+2217+ let mut wrong_intent = body;2218+ wrong_intent.request.service = "redis".into();2219+ assert!(validate_service_grant(&db, &machine, &wrong_intent, 100).is_err());2220+ }2221+17882222 #[test]17892223 fn bootstrap_and_session_tokens_have_distinct_bounded_shapes() {17902224 let ticket = prefixed_token(CONNECTION_TICKET_PREFIX);
M crates/devsite-server/src/db.rs +216 -24
@@ -169,6 +169,16 @@ const MIGRATIONS: &[&str] = &[
169169 END,170170 id171171 FROM accounts;",172+ // Enrollment tickets carry generic machine scopes. A tunnel session issued173+ // by a broker records that credential and is removed automatically when the174+ // credential is revoked. Request ids are unique to make signed requests175+ // single-use even when they are replayed concurrently.176+ "ALTER TABLE machine_credentials ADD COLUMN scopes TEXT NOT NULL DEFAULT '[]';177+ ALTER TABLE tunnel_sessions ADD COLUMN issuer_credential_id TEXT178+ REFERENCES machine_credentials(id) ON DELETE CASCADE;179+ ALTER TABLE tunnel_sessions ADD COLUMN grant_request_id TEXT;180+ CREATE UNIQUE INDEX tunnel_sessions_grant_request181+ ON tunnel_sessions(grant_request_id) WHERE grant_request_id IS NOT NULL;",172182 ];173183 174184 fn migrate(conn: &Connection) -> Result<()> {
@@ -212,12 +222,15 @@ pub struct MachineCredential {
212222 pub last_used_at: Option<u64>,213223 pub endpoint_id: Option<String>,214224 pub enrolled_at: Option<u64>,225+ pub scopes: Vec<String>,215226 }216227 217228 #[derive(Debug, Clone)]218229 pub struct MachineAuthentication {219230 pub account: Account,231+ pub credential_id: String,220232 pub endpoint_id: String,233+ pub scopes: Vec<String>,221234 }222235 223236 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -265,6 +278,8 @@ pub struct TunnelSession {
265278 pub resource_id: ResourceId,266279 pub client_endpoint_id: String,267280 pub expires_at: u64,281+ pub issuer_credential_id: Option<String>,282+ pub grant_request_id: Option<String>,268283 }269284 270285 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -480,13 +495,22 @@ impl Db {
480495 account: AccountId,481496 name: &str,482497 token_hash: &str,498+ scopes: &[String],483499 now: u64,484500 ) -> Result<()> {501+ let scopes = serde_json::to_string(scopes)?;485502 self.conn.execute(486503 "INSERT INTO machine_credentials487- (id, account_id, name, token_hash, created_at, last_used_at)488- VALUES (?1, ?2, ?3, ?4, ?5, NULL)",489- params![id, account.to_string(), name, token_hash, now as i64],504+ (id, account_id, name, token_hash, created_at, last_used_at, scopes)505+ VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6)",506+ params![507+ id,508+ account.to_string(),509+ name,510+ token_hash,511+ now as i64,512+ scopes513+ ],490514 )?;491515 Ok(())492516 }
@@ -498,16 +522,16 @@ impl Db {
498522 token_hash: &str,499523 now: u64,500524 ) -> Result<Option<MachineAuthentication>> {501- let row: Option<(String, String)> = self525+ let row: Option<(String, String, String, String)> = self502526 .conn503527 .query_row(504- "SELECT account_id, endpoint_id FROM machine_credentials528+ "SELECT id, account_id, endpoint_id, scopes FROM machine_credentials505529 WHERE token_hash = ?1 AND endpoint_id IS NOT NULL",506530 params![token_hash],507- |row| Ok((row.get(0)?, row.get(1)?)),531+ |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),508532 )509533 .optional()?;510- let Some((account_id, endpoint_id)) = row else {534+ let Some((credential_id, account_id, endpoint_id, scopes)) = row else {511535 return Ok(None);512536 };513537
@@ -520,9 +544,13 @@ impl Db {
520544 let account = self521545 .account_by_id(AccountId::from_str(&account_id)?)?522546 .context("machine credential refers to a missing account")?;547+ let scopes =548+ serde_json::from_str(&scopes).context("machine credential has invalid scopes")?;523549 Ok(Some(MachineAuthentication {524550 account,551+ credential_id,525552 endpoint_id,553+ scopes,526554 }))527555 }528556
@@ -569,7 +597,7 @@ impl Db {
569597 570598 pub fn machine_credentials(&self, account: AccountId) -> Result<Vec<MachineCredential>> {571599 let mut stmt = self.conn.prepare(572- "SELECT id, name, created_at, last_used_at, endpoint_id, enrolled_at600+ "SELECT id, name, created_at, last_used_at, endpoint_id, enrolled_at, scopes573601 FROM machine_credentials WHERE account_id = ?1 ORDER BY created_at DESC",574602 )?;575603 let rows = stmt
@@ -581,6 +609,13 @@ impl Db {
581609 last_used_at: row.get::<_, Option<i64>>(3)?.map(|n| n as u64),582610 endpoint_id: row.get(4)?,583611 enrolled_at: row.get::<_, Option<i64>>(5)?.map(|n| n as u64),612+ scopes: serde_json::from_str(&row.get::<_, String>(6)?).map_err(|err| {613+ rusqlite::Error::FromSqlConversionFailure(614+ 6,615+ rusqlite::types::Type::Text,616+ Box::new(err),617+ )618+ })?,584619 })585620 })?586621 .collect::<rusqlite::Result<Vec<_>>>()?;
@@ -754,6 +789,31 @@ impl Db {
754789 rows.into_iter().collect()755790 }756791 792+ /// Services the account may currently reach, including its own services and793+ /// accepted shares. Pending invitations are deliberately excluded.794+ pub fn accessible_services(795+ &self,796+ viewer: AccountId,797+ ) -> Result<Vec<(Resource, Option<String>)>> {798+ let mut stmt = self.conn.prepare(799+ "SELECT r.id, r.owner_id, r.name, r.kind, r.visibility, r.url, r.folder,800+ CASE WHEN r.owner_id = ?1 THEN NULL ELSE a.handle END801+ FROM resources r802+ JOIN accounts a ON a.id = r.owner_id803+ LEFT JOIN shares s ON s.resource_id = r.id AND s.viewer_id = ?1804+ WHERE r.kind = 'service' AND (r.owner_id = ?1 OR s.viewer_id IS NOT NULL)805+ ORDER BY lower(r.name), lower(COALESCE(a.handle, ''))",806+ )?;807+ let rows = stmt808+ .query_map(params![viewer.to_string()], |row| {809+ Ok((row_to_resource(row)?, row.get::<_, Option<String>>(7)?))810+ })?811+ .collect::<rusqlite::Result<Vec<_>>>()?;812+ rows.into_iter()813+ .map(|(resource, owner_handle)| Ok((resource?, owner_handle)))814+ .collect()815+ }816+757817 /// Set exactly who is invited to a resource, replacing whoever was there.758818 ///759819 /// Replacing rather than adding is the whole point. `devsite service host --share
@@ -1072,24 +1132,84 @@ impl Db {
10721132 Ok(())10731133 }10741134 1135+ #[allow(clippy::too_many_arguments)]1136+ pub fn create_broker_tunnel_session(1137+ &self,1138+ token_hash: &str,1139+ viewer: AccountId,1140+ resource: ResourceId,1141+ client_endpoint_id: &str,1142+ issuer_credential_id: &str,1143+ grant_request_id: &str,1144+ now: u64,1145+ expires_at: u64,1146+ ) -> Result<bool> {1147+ self.conn.execute(1148+ "DELETE FROM tunnel_sessions WHERE expires_at <= ?1",1149+ params![now as i64],1150+ )?;1151+ Ok(self.conn.execute(1152+ "INSERT OR IGNORE INTO tunnel_sessions1153+ (token_hash, viewer_id, resource_id, client_endpoint_id, expires_at,1154+ issuer_credential_id, grant_request_id)1155+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",1156+ params![1157+ token_hash,1158+ viewer.to_string(),1159+ resource.to_string(),1160+ client_endpoint_id,1161+ expires_at as i64,1162+ issuer_credential_id,1163+ grant_request_id,1164+ ],1165+ )? > 0)1166+ }1167+1168+ pub fn active_broker_grant_count(&self, credential_id: &str, now: u64) -> Result<usize> {1169+ self.conn1170+ .query_row(1171+ "SELECT COUNT(*) FROM tunnel_sessions1172+ WHERE issuer_credential_id = ?1 AND expires_at > ?2",1173+ params![credential_id, now as i64],1174+ |row| row.get::<_, i64>(0),1175+ )1176+ .map(|count| count as usize)1177+ .map_err(Into::into)1178+ }1179+10751180 pub fn tunnel_session(&self, token_hash: &str, now: u64) -> Result<Option<TunnelSession>> {1076- let row: Option<(String, String, String, i64)> = self1181+ type TunnelSessionRow = (String, String, String, i64, Option<String>, Option<String>);1182+ let row: Option<TunnelSessionRow> = self10771183 .conn10781184 .query_row(1079- "SELECT viewer_id, resource_id, client_endpoint_id, expires_at1185+ "SELECT viewer_id, resource_id, client_endpoint_id, expires_at,1186+ issuer_credential_id, grant_request_id10801187 FROM tunnel_sessions WHERE token_hash = ?1 AND expires_at > ?2",10811188 params![token_hash, now as i64],1082- |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),1189+ |row| {1190+ Ok((1191+ row.get(0)?,1192+ row.get(1)?,1193+ row.get(2)?,1194+ row.get(3)?,1195+ row.get(4)?,1196+ row.get(5)?,1197+ ))1198+ },10831199 )10841200 .optional()?;1085- row.map(|(viewer, resource, client_endpoint_id, expires_at)| {1086- Ok(TunnelSession {1087- viewer_id: AccountId::from_str(&viewer)?,1088- resource_id: ResourceId::from_str(&resource)?,1089- client_endpoint_id,1090- expires_at: expires_at as u64,1091- })1092- })1201+ row.map(1202+ |(viewer, resource, client_endpoint_id, expires_at, issuer, request)| {1203+ Ok(TunnelSession {1204+ viewer_id: AccountId::from_str(&viewer)?,1205+ resource_id: ResourceId::from_str(&resource)?,1206+ client_endpoint_id,1207+ expires_at: expires_at as u64,1208+ issuer_credential_id: issuer,1209+ grant_request_id: request,1210+ })1211+ },1212+ )10931213 .transpose()10941214 }10951215
@@ -1245,6 +1365,8 @@ mod tests {
12451365 resource_id: service,12461366 client_endpoint_id: "client-key".into(),12471367 expires_at: 1000,1368+ issuer_credential_id: None,1369+ grant_request_id: None,12481370 })12491371 );12501372 assert!(db.delete_tunnel_session("session-hash").unwrap());
@@ -1286,11 +1408,32 @@ mod tests {
12861408 created_at INTEGER NOT NULL12871409 );12881410 INSERT INTO accounts VALUES ('account-shoo', 'pairwise-alice', 'alice', 0);1289- INSERT INTO accounts VALUES ('account-local', 'local:bob', 'bob', 0);",1411+ INSERT INTO accounts VALUES ('account-local', 'local:bob', 'bob', 0);1412+ CREATE TABLE machine_credentials (1413+ id TEXT PRIMARY KEY,1414+ account_id TEXT NOT NULL,1415+ name TEXT NOT NULL,1416+ token_hash TEXT NOT NULL UNIQUE,1417+ created_at INTEGER NOT NULL,1418+ last_used_at INTEGER,1419+ endpoint_id TEXT,1420+ enrolled_at INTEGER1421+ );1422+ CREATE TABLE tunnel_sessions (1423+ token_hash TEXT PRIMARY KEY,1424+ viewer_id TEXT NOT NULL,1425+ resource_id TEXT NOT NULL,1426+ client_endpoint_id TEXT NOT NULL,1427+ expires_at INTEGER NOT NULL1428+ );",12901429 )12911430 .unwrap();1431+ let identity_migration = MIGRATIONS1432+ .iter()1433+ .position(|migration| migration.contains("INSERT OR IGNORE INTO account_identities"))1434+ .unwrap();12921435 connection1293- .pragma_update(None, "user_version", (MIGRATIONS.len() - 1) as i64)1436+ .pragma_update(None, "user_version", identity_migration as i64)12941437 .unwrap();12951438 migrate(&connection).unwrap();12961439
@@ -1887,9 +2030,17 @@ mod tests {
18872030 18882031 #[test]18892032 fn machine_tickets_enroll_once_and_become_endpoint_bound_credentials() {1890- let (db, alice, _) = seeded();1891- db.create_machine_credential("machine_one", alice.id, "Laptop", "ticket-hash", 10)1892- .unwrap();2033+ let (mut db, alice, _) = seeded();2034+ let scopes = vec!["service_grants:issue".to_string()];2035+ db.create_machine_credential(2036+ "machine_one",2037+ alice.id,2038+ "Laptop",2039+ "ticket-hash",2040+ &scopes,2041+ 10,2042+ )2043+ .unwrap();18932044 18942045 assert_eq!(db.active_machine_credential_count(alice.id).unwrap(), 1);18952046 assert!(db
@@ -1925,15 +2076,55 @@ mod tests {
19252076 .unwrap()19262077 .unwrap();19272078 assert_eq!(authenticated.account.id, alice.id);2079+ assert_eq!(authenticated.credential_id, "machine_one");19282080 assert_eq!(authenticated.endpoint_id, "endpoint");2081+ assert_eq!(authenticated.scopes, scopes);19292082 let credentials = db.machine_credentials(alice.id).unwrap();19302083 assert_eq!(credentials.len(), 1);19312084 assert_eq!(credentials[0].name, "Laptop");19322085 assert_eq!(credentials[0].endpoint_id.as_deref(), Some("endpoint"));19332086 assert_eq!(credentials[0].enrolled_at, Some(20));19342087 assert_eq!(credentials[0].last_used_at, Some(20));2088+ assert_eq!(credentials[0].scopes, scopes);19352089 db.register_daemon(alice.id, "endpoint").unwrap();19362090 2091+ let service = db2092+ .create_resource(2093+ alice.id,2094+ "postgres",2095+ ResourceKind::Service,2096+ Visibility::Private,2097+ None,2098+ None,2099+ 20,2100+ )2101+ .unwrap();2102+ assert!(db2103+ .create_broker_tunnel_session(2104+ "session-hash",2105+ alice.id,2106+ service,2107+ "requester-endpoint",2108+ "machine_one",2109+ "agr_one",2110+ 20,2111+ 100,2112+ )2113+ .unwrap());2114+ assert!(!db2115+ .create_broker_tunnel_session(2116+ "different-session-hash",2117+ alice.id,2118+ service,2119+ "requester-endpoint",2120+ "machine_one",2121+ "agr_one",2122+ 20,2123+ 100,2124+ )2125+ .unwrap());2126+ assert_eq!(db.active_broker_grant_count("machine_one", 30).unwrap(), 1);2127+19372128 assert!(db19382129 .revoke_machine_credential(alice.id, "machine_one", 30)19392130 .unwrap());
@@ -1944,6 +2135,7 @@ mod tests {
19442135 .is_none());19452136 assert_eq!(db.active_machine_credential_count(alice.id).unwrap(), 0);19462137 assert!(db.machine_credentials(alice.id).unwrap().is_empty());2138+ assert!(db.tunnel_session("session-hash", 40).unwrap().is_none());19472139 }19482140 19492141 #[test]
M crates/devsite-server/src/issuer.rs +9 -0
A docs/access-broker.md +109 -0
@@ -0,0 +1,109 @@
1+# Brokered endpoint access2+3+Brokered access lets a sandboxed worker obtain a narrowly bounded service grant without4+giving that worker a person's browser session, a machine credential, or the broker5+machine's endpoint key.6+7+## Actors and authority8+9+- The **requester** owns a fresh, temporary Ed25519 endpoint key and creates a signed service10+ request. Its public request is safe to hand to a broker; its private key is not.11+- The **broker** is an ordinary enrolled machine whose single-use enrollment ticket carried12+ the `service_grants:issue` scope. The server stores that scope on the resulting revocable13+ machine credential.14+- The **control plane** verifies both signatures, enforces the broker scope and account15+ permissions, rejects replays, and issues an endpoint-bound tunnel session.16+- The **service daemon** still verifies the resulting one-use transport capability before17+ connecting to its local loopback target.18+19+Broker scope is delegated at enrollment instead of inferred from possession of any machine20+credential. The dashboard must explicitly include it when minting the registration ticket.21+Revoking the resulting machine credential removes grant sessions issued by that broker.22+23+## Signed handoff24+25+The requester runs:26+27+```sh28+devsite --json access request postgres \29+ --request /private/request.json \30+ --key /private/requester.key31+```32+33+The request signature covers a domain separator, schema version, request id, service34+keyword, requester endpoint id, and expiry. The command creates new files rather than35+overwriting paths and keeps both private by default. Only the request JSON crosses the36+sandbox boundary.37+38+The broker first resolves and plans:39+40+```sh41+devsite --json access resolve postgres42+devsite --json access grant --request /inbox/request.json --plan43+```44+45+Resolution searches only owned services and accepted service shares visible to the broker.46+An exact unique name is selected automatically. Ambiguous matches require an explicit47+`--resource res_…`. The plan verifies the requester signature and reports the exact48+resource, owner, requester endpoint, request id, and expiry without issuing a grant.49+50+After user or policy approval, the broker applies the same intent:51+52+```sh53+devsite --json access grant --request /inbox/request.json \54+ --approved-plan dsp_…55+```56+57+The broker's persistent `devsite-endpoint.key` signs the request id, selected resource,58+requester endpoint, and grant expiry. The server also verifies that the signed request's59+keyword still resolves to that resource. It rejects expired requests, grants longer than 1560+minutes, duplicate request ids, credentials without broker scope, and resources outside the61+broker account's current access. The `dsp_…` token is signed by the control plane over the62+broker credential, request id and keyword, resource, endpoint, request expiry, and grant63+expiry. Apply requires it, preventing a request file or selected target from being swapped64+after approval.65+66+The broker returns only `result.grant` and `result.server`. The requester then runs:67+68+```sh69+devsite --server SERVER --json access connect GRANT \70+ --key /private/requester.key71+```72+73+The connector recreates the requested endpoint from that private key, validates that the74+grant is broker-issued and bound to the same endpoint, and emits resident NDJSON lifecycle75+events. It refuses non-loopback listeners.76+77+## Expiry and revocation78+79+Requests expire within 10 minutes and request ids are single-use at issuance. Grants expire80+within 15 minutes. A broker credential has a bounded number of simultaneous active grant81+sessions. Credential revocation deletes those sessions and prevents further capabilities82+from being minted. A one-use transport capability already minted immediately before83+revocation remains limited by its own short expiry and nonce.84+85+## Harness-neutral integration86+87+The canonical interface is the CLI JSON contract and the portable88+[`devsite-cli` skill](../skills/devsite-cli/SKILL.md). A harness can execute those commands89+directly, expose them through its own tool API, or use the bundled local MCP adapter.90+91+The adapter follows MCP `2026-07-28`:92+93+- clients use `server/discover`, not `initialize`;94+- every request carries95+ `_meta.io.modelcontextprotocol/protocolVersion` and96+ `_meta.io.modelcontextprotocol/clientCapabilities`;97+- each response is complete and self-describing; and98+- the adapter stores no negotiated client, authorization, initialization, or call-order99+ state.100+101+The long-lived stdio process is only a transport. Killing and restarting it does not lose a102+logical session because there is no logical session to lose. The MCP plugin manifest is a103+convenience package for clients that understand that format, not a requirement on the model104+or harness.105+106+See the current official MCP [discovery](https://modelcontextprotocol.io/specification/draft/server/discover),107+[tools](https://modelcontextprotocol.io/specification/draft/server/tools), and108+[stdio transport](https://modelcontextprotocol.io/specification/draft/basic/transports/stdio)109+specifications.
M docs/json-output.md +25 -0
@@ -52,3 +52,28 @@ a command failure. The envelope remains `ok: true` when the report was produced;
5252 file permissions, server/API compatibility, the pinned signing key, credential binding,5353 daemon registration, and remote/local resource drift. It does not open connections to5454 hosted services.55+56+## Brokered access57+58+`devsite access request SERVICE --request FILE --key FILE --json` creates a public signed59+request and a separate private requester endpoint key. Its result contains60+`request`, `request_path`, `key_path`, and `handoff: "share_request_only"`. The request61+object has schema version, request id, signed service keyword, requester endpoint id, expiry,62+and proof. The two paths must differ and must not exist.63+64+`devsite access resolve KEYWORD --json` requires a machine credential enrolled with the65+`service_grants:issue` scope. It returns matching accessible services with resource id,66+name, optional owner handle, and exact-name-match state.67+68+`devsite access grant --request FILE --plan --json` validates and resolves the signed69+request without issuing a grant. If resolution is ambiguous, supply an approved70+`--resource res_…`. The plan returns a server-signed `approved_plan` token covering the71+exact broker credential, request, resource, endpoint, and expiry. Apply with72+`--approved-plan dsp_…`; changed inputs are rejected. A successful apply returns the request73+id, resource id, name and owner, requester endpoint id, expiry, short-lived `dss_…` grant,74+and `server`. Treat the grant as a secret and return only the grant and server to the75+requester.76+77+`devsite access connect GRANT --key FILE --json` is resident NDJSON. It validates that the78+grant is broker-issued and bound to the supplied key, then emits the same `listening`,79+`connection`, error, and shutdown lifecycle records as ordinary `connect`.
A plugins/devsite-access/.codex-plugin/plugin.json +23 -0
A plugins/devsite-access/.mcp.json +10 -0
A plugins/devsite-access/scripts/devsite-mcp.mjs +336 -0
@@ -0,0 +1,336 @@
1+#!/usr/bin/env node2+3+import { spawn } from 'node:child_process';4+import { existsSync, readFileSync } from 'node:fs';5+import { dirname, resolve } from 'node:path';6+import { fileURLToPath } from 'node:url';7+import readline from 'node:readline';8+9+const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');10+const repositorySkillPath = resolve(root, '../../skills/devsite-cli/SKILL.md');11+const skillPath = existsSync(repositorySkillPath)12+ ? repositorySkillPath13+ : resolve(root, 'skills/devsite-cli/SKILL.md');14+const binary = process.env.DEVSITE_BIN || 'devsite';15+const protocolVersion = '2026-07-28';16+const serverInfo = { name: 'devsite', version: '0.1.0' };17+const serverMeta = { 'io.modelcontextprotocol/serverInfo': serverInfo };18+const inFlight = new Map();19+const cancelled = new Set();20+21+const tools = [22+ {23+ name: 'devsite_cli',24+ description: 'Run one finite devsite CLI command in JSON mode. Resident connect/daemon commands are refused; supervise those directly in the harness.',25+ inputSchema: {26+ type: 'object',27+ properties: {28+ args: { type: 'array', items: { type: 'string' }, description: 'Arguments after devsite, without --json.' },29+ },30+ required: ['args'],31+ additionalProperties: false,32+ },33+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },34+ },35+ {36+ name: 'devsite_access_request',37+ description: 'Create a signed, short-lived service request and a separate private requester endpoint key. Share only the request file.',38+ inputSchema: {39+ type: 'object',40+ properties: {41+ service: { type: 'string' },42+ request_path: { type: 'string' },43+ key_path: { type: 'string' },44+ ttl: { type: 'integer', minimum: 1, maximum: 600, default: 300 },45+ },46+ required: ['service', 'request_path', 'key_path'],47+ additionalProperties: false,48+ },49+ annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: false },50+ },51+ {52+ name: 'devsite_access_resolve',53+ description: 'Resolve a service keyword against services this scoped broker credential may delegate.',54+ inputSchema: {55+ type: 'object',56+ properties: { keyword: { type: 'string' } },57+ required: ['keyword'],58+ additionalProperties: false,59+ },60+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },61+ },62+ {63+ name: 'devsite_access_grant',64+ description: 'Plan or issue a short-lived endpoint-bound service grant. Set plan=true unless the exact grant is already authorized.',65+ inputSchema: {66+ type: 'object',67+ properties: {68+ request_path: { type: 'string' },69+ resource_id: { type: 'string' },70+ ttl: { type: 'integer', minimum: 1, maximum: 900, default: 900 },71+ plan: { type: 'boolean', default: true },72+ approved_plan: { type: 'string', description: 'Server-signed dsp_ token returned by the exact reviewed plan; required when plan=false.' },73+ },74+ required: ['request_path'],75+ additionalProperties: false,76+ },77+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },78+ },79+];80+81+function commandWords(args) {82+ const words = [];83+ for (let index = 0; index < args.length; index += 1) {84+ const word = args[index];85+ if (word === '--json') continue;86+ if (word === '--server') {87+ index += 1;88+ continue;89+ }90+ if (word.startsWith('--server=')) continue;91+ words.push(word);92+ }93+ return words;94+}95+96+async function runDevsite(args, requestId) {97+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) {98+ throw new Error('args must be an array of strings');99+ }100+ const words = args.filter((arg) => arg !== '--json');101+ const command = commandWords(words);102+ if (103+ command[0] === 'connect'104+ || (command[0] === 'daemon' && command[1] === 'run')105+ || (command[0] === 'access' && command[1] === 'connect')106+ ) {107+ throw new Error('resident commands must be launched under the harness process supervisor');108+ }109+ if (command[0] === 'access' && command[1] === 'grant') {110+ throw new Error('use devsite_access_grant so planning and approved-plan enforcement cannot be bypassed');111+ }112+ const result = await new Promise((resolveResult, rejectResult) => {113+ const child = spawn(binary, ['--json', ...words], {114+ env: process.env,115+ shell: false,116+ stdio: ['ignore', 'pipe', 'pipe'],117+ });118+ let stdout = '';119+ let stderr = '';120+ const limit = 4 * 1024 * 1024;121+ const timeout = setTimeout(() => child.kill(), 30000);122+ inFlight.set(requestId, child);123+ child.stdout.setEncoding('utf8');124+ child.stderr.setEncoding('utf8');125+ child.stdout.on('data', (chunk) => {126+ stdout += chunk;127+ if (stdout.length > limit) child.kill();128+ });129+ child.stderr.on('data', (chunk) => {130+ stderr += chunk;131+ if (stderr.length > limit) child.kill();132+ });133+ child.on('error', rejectResult);134+ child.on('close', (status, signal) => {135+ clearTimeout(timeout);136+ inFlight.delete(requestId);137+ if (cancelled.has(requestId)) {138+ rejectResult(new Error('request cancelled'));139+ } else if (signal) {140+ rejectResult(new Error(`devsite terminated by ${signal}`));141+ } else {142+ resolveResult({ stdout, stderr, status });143+ }144+ });145+ });146+ const stdout = result.stdout.trim();147+ if (!stdout) throw new Error(result.stderr.trim() || `devsite exited ${result.status}`);148+ let parsed;149+ try {150+ parsed = JSON.parse(stdout);151+ } catch {152+ throw new Error(`devsite returned non-JSON output: ${stdout.slice(0, 500)}`);153+ }154+ return parsed;155+}156+157+async function callTool(name, input = {}, requestId) {158+ switch (name) {159+ case 'devsite_cli':160+ return runDevsite(input.args, requestId);161+ case 'devsite_access_request':162+ return runDevsite([163+ 'access', 'request', input.service,164+ '--request', input.request_path,165+ '--key', input.key_path,166+ '--ttl', String(input.ttl ?? 300),167+ ], requestId);168+ case 'devsite_access_resolve':169+ return runDevsite(['access', 'resolve', input.keyword], requestId);170+ case 'devsite_access_grant': {171+ const args = [172+ 'access', 'grant', '--request', input.request_path,173+ '--ttl', String(input.ttl ?? 900),174+ ];175+ if (input.resource_id) args.push('--resource', input.resource_id);176+ if (input.plan !== false) {177+ args.push('--plan');178+ } else if (input.approved_plan) {179+ args.push('--approved-plan', input.approved_plan);180+ } else {181+ throw new Error('approved_plan is required when plan=false');182+ }183+ return runDevsite(args, requestId);184+ }185+ default:186+ throw new Error(`unknown tool ${name}`);187+ }188+}189+190+function result(id, value) {191+ process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result: value })}\n`);192+}193+194+function rpcError(id, code, message, data) {195+ process.stdout.write(`${JSON.stringify({196+ jsonrpc: '2.0',197+ ...(typeof id === 'string' || typeof id === 'number' ? { id } : {}),198+ error: { code, message, ...(data === undefined ? {} : { data }) },199+ })}\n`);200+}201+202+function requestProtocol(message) {203+ return message.params?._meta?.['io.modelcontextprotocol/protocolVersion'];204+}205+206+function validateModernRequest(message) {207+ const requested = requestProtocol(message);208+ if (typeof requested !== 'string') {209+ rpcError(message.id, -32602, 'Missing per-request protocol version');210+ return false;211+ }212+ if (requested !== protocolVersion) {213+ rpcError(message.id, -32022, 'Unsupported protocol version', {214+ supported: [protocolVersion],215+ requested: requested ?? null,216+ });217+ return false;218+ }219+ const capabilities = message.params?._meta?.['io.modelcontextprotocol/clientCapabilities'];220+ if (!capabilities || typeof capabilities !== 'object' || Array.isArray(capabilities)) {221+ rpcError(message.id, -32602, 'Missing per-request client capabilities');222+ return false;223+ }224+ return true;225+}226+227+function complete(value) {228+ return { resultType: 'complete', _meta: serverMeta, ...value };229+}230+231+async function handle(message) {232+ if (!message || typeof message !== 'object' || Array.isArray(message)) {233+ rpcError(undefined, -32600, 'Invalid Request');234+ return;235+ }236+ const { id, method, params = {} } = message;237+ if (id === undefined) {238+ if (method === 'notifications/cancelled') {239+ const requestId = params.requestId;240+ const child = inFlight.get(requestId);241+ if (child) {242+ cancelled.add(requestId);243+ child.kill();244+ }245+ }246+ return;247+ }248+ if (249+ message.jsonrpc !== '2.0'250+ || (typeof id !== 'string' && typeof id !== 'number')251+ || typeof method !== 'string'252+ || !params253+ || typeof params !== 'object'254+ || Array.isArray(params)255+ ) {256+ rpcError(id, -32600, 'Invalid Request');257+ return;258+ }259+ if (!validateModernRequest(message)) return;260+ try {261+ switch (method) {262+ case 'server/discover':263+ result(id, complete({264+ supportedVersions: [protocolVersion],265+ capabilities: { tools: {}, resources: {} },266+ serverInfo,267+ instructions: 'Use the bundled devsite skill. Plan grant issuance before applying it, and never share requester endpoint keys.',268+ ttlMs: 300000,269+ cacheScope: 'public',270+ }));271+ break;272+ case 'tools/list':273+ result(id, complete({ tools, ttlMs: 300000, cacheScope: 'public' }));274+ break;275+ case 'tools/call': {276+ if (!tools.some((tool) => tool.name === params.name)) {277+ rpcError(id, -32602, `Unknown tool: ${params.name}`);278+ break;279+ }280+ try {281+ const value = await callTool(params.name, params.arguments, id);282+ if (cancelled.delete(id)) break;283+ result(id, complete({284+ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],285+ structuredContent: value,286+ isError: value?.ok === false,287+ }));288+ } catch (err) {289+ if (cancelled.delete(id)) break;290+ result(id, complete({291+ content: [{ type: 'text', text: err instanceof Error ? err.message : String(err) }],292+ isError: true,293+ }));294+ }295+ break;296+ }297+ case 'resources/list':298+ result(id, complete({ resources: [299+ { uri: 'devsite://skill', name: 'devsite CLI skill', mimeType: 'text/markdown' },300+ { uri: 'devsite://help', name: 'installed devsite CLI help', mimeType: 'application/json' },301+ ], ttlMs: 300000, cacheScope: 'public' }));302+ break;303+ case 'resources/read':304+ if (params.uri === 'devsite://skill') {305+ result(id, complete({306+ contents: [{ uri: params.uri, mimeType: 'text/markdown', text: readFileSync(skillPath, 'utf8') }],307+ ttlMs: 300000,308+ cacheScope: 'public',309+ }));310+ } else if (params.uri === 'devsite://help') {311+ result(id, complete({312+ contents: [{ uri: params.uri, mimeType: 'application/json', text: JSON.stringify(await runDevsite(['--help'], id), null, 2) }],313+ ttlMs: 60000,314+ cacheScope: 'private',315+ }));316+ } else {317+ rpcError(id, -32602, `Unknown resource: ${params.uri}`);318+ }319+ break;320+ default:321+ rpcError(id, -32601, `Method not found: ${method}`);322+ }323+ } catch (err) {324+ rpcError(id, -32603, err instanceof Error ? err.message : String(err));325+ }326+}327+328+const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });329+for await (const line of lines) {330+ if (!line.trim()) continue;331+ try {332+ void handle(JSON.parse(line));333+ } catch (err) {334+ rpcError(undefined, -32700, err instanceof Error ? err.message : String(err));335+ }336+}
A plugins/devsite-access/skills/devsite-cli/SKILL.md +90 -0
@@ -0,0 +1,90 @@
1+---2+name: devsite-cli3+description: Operate and explain the devsite command-line interface. Use for publishing profile links, hosting or connecting to private TCP services, requesting or brokering endpoint-bound service access, inspecting resources, checking setup, managing the foreground daemon, or troubleshooting devsite CLI behavior.4+---5+6+# devsite CLI7+8+Use the installed CLI as the source of truth. This skill is portable: use any shell/process and JSON parser supplied by the current harness. Do not require a specific model vendor, agent framework, callback, path convention, or stateful model session.9+10+## JSON contract11+12+- Prefer `devsite --json …` for automation. A finite command writes exactly one JSON object to stdout; check `schema_version`, then `ok` before using `result`.13+- On failure, use `error.message`, `error.causes`, and `error.suggestions` instead of guessing.14+- Inspect syntax with `devsite <command> --json --help`. Structured help returns `command: "help"` and `result.text`.15+- JSON mode is non-interactive. Never echo machine credentials, tickets, endpoint keys, or issued `dss_…` grants in logs or summaries.16+17+## Begin with state18+19+- `devsite --json status` reports local configuration, enrollment, endpoint public identity, daemon liveness, and locally configured services.20+- `devsite --json resources list` inventories owned remote resources with local hosting and share state. Use it before changing an ambiguous target.21+- `devsite --json doctor` diagnoses identity, permissions, authentication, compatibility, daemon registration, and resource drift. Inspect `result.healthy`, then prioritize `result.actions`; an action with `requires_user_input: true` needs user authority.22+23+## Plan mutations24+25+Use `--plan` before upserts, removals, or broker grant issuance. `--dry-run` is an alias.26+27+```sh28+devsite --json link set --name docs --url https://example.com/docs --public --plan29+devsite --json service host 5432 --name postgres --share @alice --plan30+devsite --json service remove postgres --plan31+```32+33+Explain the exact target, visibility, recipients, destination, and effects. Apply the same command without `--plan` only after that exact plan is authorized. Refresh the plan when relevant state may have changed.34+35+## Common workflows36+37+| Intent | Safe workflow |38+| --- | --- |39+| Publish a link | Plan `link set --name NAME --url HTTPS_URL`; add `--public` or repeatable `--share @HANDLE` only as intended, then apply. |40+| Host loopback TCP | Plan `service host PORT --name NAME`; add shares only as intended, apply, then keep `daemon run` alive. |41+| Remove a resource | Inspect `resources list`, plan `link remove NAME` or `service remove NAME`, explain effects, then apply. |42+| Connect normally | Use the approved, short-lived single-use ticket with `connect TICKET`; choose only a loopback `--listen` address. |43+| Request sandbox access | Create a signed request and separate endpoint key with `access request`; send only the request JSON to an authorized broker. |44+| Broker sandbox access | Resolve the signed keyword, plan the endpoint-bound grant, obtain policy or user approval, then issue it with a scoped broker credential. |45+| Diagnose | Run `doctor`; if hosting is configured but stopped, supervise `daemon run` as a resident process. |46+47+Folders are presentation, not authorization. Public entries are public; shared entries require acceptance. Services are protocol-neutral TCP byte streams to the owner's loopback target, not HTTP reverse proxies.48+49+## Broker endpoint-bound access50+51+Requester:52+53+```sh54+devsite --json access request postgres \55+ --request /private/request.json \56+ --key /private/requester.key57+```58+59+- Create both paths in a requester-private directory. The command refuses collisions and writes private files.60+- Send only `request.json` to the broker. Never send `requester.key`.61+- The signature binds the service keyword, requester endpoint, request id, and expiry. Requests expire within 10 minutes and issuance consumes each request id once.62+63+Broker:64+65+```sh66+devsite --json access resolve postgres67+devsite --json access grant --request /inbox/request.json --plan68+devsite --json access grant --request /inbox/request.json \69+ --approved-plan dsp_…70+```71+72+- These commands require a machine credential enrolled with the `service_grants:issue` scope. Being logged in alone does not grant broker authority.73+- Resolution contains only services the broker account owns or has an accepted share for. If several candidates match, present them and pass the authorized `--resource res_…`; never guess an ambiguous match.74+- Show the exact service/resource, owner, requester endpoint, request id, and expiry to the user or policy engine. Issue only with that plan's server-signed `approved_plan` token; changed inputs will be rejected.75+- Grants last at most 15 minutes and are bound to the requester's endpoint. Return only the JSON result's `grant` and `server`; never return broker credentials or endpoint keys.76+77+Requester:78+79+```sh80+devsite --server https://dev.site --json access connect dss_… \81+ --key /private/requester.key82+```83+84+`access connect` is resident NDJSON. It proves possession of the requester key, validates the endpoint binding, and opens only a loopback listener. Keep requester and broker roles distinct even if one harness can execute both.85+86+## Resident commands87+88+`connect`, `access connect`, and `daemon run` emit one JSON lifecycle event per line. Keep the process alive, parse NDJSON, and report readiness only after `listening` or `online`. Continue surfacing later error and shutdown events.89+90+Never claim that a service is reachable solely because its resource exists: the host daemon must be online, the share must be accepted, and the ticket or grant must be valid.
M skills/devsite-cli/SKILL.md +45 -1
@@ -1,6 +1,6 @@
11 ---22 name: devsite-cli3-description: Operate and explain the devsite command-line interface. Use when a user asks to publish or change a dev.site profile link, share or stop sharing a local TCP service, connect to a service shared with them, inspect hosted resources, check devsite setup, manage the foreground daemon, customize profile presentation, or troubleshoot devsite CLI behavior. Also use for intent such as “share localhost,” “expose my local database,” “publish my docs,” or “what can dev.site do?”3+description: Operate and explain the devsite command-line interface. Use when a user asks to publish or change a dev.site profile link, share or stop sharing a local TCP service, connect to a service shared with them, request or broker endpoint-bound service access, inspect hosted resources, check devsite setup, manage the foreground daemon, customize profile presentation, or troubleshoot devsite CLI behavior. Also use for intent such as “share localhost,” “expose my local database,” “request database access,” “publish my docs,” or “what can dev.site do?”44 ---55 66 # devsite CLI
@@ -14,6 +14,7 @@ Use the installed CLI as the source of truth. This skill is portable: use any sh
1414 - Inspect command syntax with `devsite <command> --json --help` whenever arguments or available subcommands are uncertain. Structured help succeeds with `command: "help"` and `result.text`.1515 - Treat stdout as the machine-readable channel. Do not attempt to parse human diagnostics from stderr as JSON.1616 - JSON mode is non-interactive. Supply values such as an enrollment or connection ticket only when the user provides them; do not echo tickets, machine credentials, or endpoint private keys in reports.17+- Treat an issued `dss_…` grant as a short-lived secret. Never put a requester key, machine credential, connection ticket, or issued grant in logs, chat summaries, or files shared with another actor.1718 1819 ## Begin with state1920
@@ -45,12 +46,55 @@ devsite --json service remove postgres --plan
4546 | Stop serving a service | Inspect `resources list`, plan `service remove NAME`, explain remote-access and local-mapping effects, then apply after confirmation. |4647 | Remove a link | Inspect `resources list`, plan `link remove NAME`, explain access/profile effects, then apply after confirmation. |4748 | Reach a shared service | Obtain a user-provided, short-lived single-use connection ticket from the approved profile entry. Run `connect TICKET` and wait for its ready event; use `--listen 127.0.0.1:PORT` only for a deliberate loopback port. |49+| Request sandboxed access | Create a signed request and separate endpoint key with `access request`; send only the request JSON to an authorized broker. Keep the key in the requester sandbox and use it with `access connect` after receiving the grant. |50+| Broker service access | Resolve the signed keyword, plan the exact endpoint-bound grant, obtain the required policy or user approval, then issue it with a machine credential enrolled for `service_grants:issue`. Return only the grant and its server to the requester. |4851 | Enroll this machine | Obtain a user-provided single-use machine ticket and run `login TICKET`. This creates or uses the local endpoint identity and stores a revocable machine credential. |4952 | Diagnose availability | Run `doctor`; use its checks and proposed actions. For a configured but stopped host, start `daemon run` under the harness’s normal long-running-process supervision. |5053 | Change profile presentation | Discover exact syntax with `theme --json --help`; use `theme properties` before producing a theme declaration file, then `theme set FILE` or `theme clear` as authorized. |5154 5255 Folders are display grouping, not authorization boundaries. A public profile entry is public; shared entries require the invitee to accept the share. The control plane manages identity and permissions, while service traffic is a peer-to-peer TCP connection to the owner’s local loopback target.5356 57+## Broker endpoint-bound access58+59+Use this workflow when a sandboxed or otherwise untrusted worker needs temporary access without receiving the broker machine's identity or credential.60+61+Requester:62+63+```sh64+devsite --json access request postgres \65+ --request /private/request.json \66+ --key /private/requester.key67+```68+69+- Create both paths in a requester-private directory. The command refuses existing files and writes them with private permissions.70+- Send only `request.json` to the broker. Its signature binds the service keyword, requester endpoint, request id, and expiry. Never send `requester.key`.71+- A request expires within 10 minutes. Issuance consumes its request id once, so retry by creating a new request rather than weakening replay checks.72+73+Broker:74+75+```sh76+devsite --json access resolve postgres77+devsite --json access grant --request /inbox/request.json --plan78+devsite --json access grant --request /inbox/request.json \79+ --approved-plan dsp_…80+```81+82+- Broker commands require a machine credential whose enrollment ticket included the `service_grants:issue` scope. This is explicit delegated authority, not a capability inferred from being logged in.83+- Resolution is limited to services the broker account owns or has an accepted share for. If multiple services match, present the candidates and pass the approved `--resource res_…`; never choose an ambiguous fuzzy match silently.84+- Always plan first. Show the signed request identity, exact service/resource, owner, requester endpoint, and expiry to the user or policy engine that controls issuance. Apply only with that plan's server-signed `approved_plan` token; it pins those fields so a replaced request file or changed target is rejected.85+- Grants last at most 15 minutes and are bound to the requester endpoint. Give the requester only the `dss_…` grant and `server` from the JSON result. Do not give it the broker credential, broker endpoint key, or requester's copied key.86+87+Requester connection:88+89+```sh90+devsite --server https://dev.site --json access connect dss_… \91+ --key /private/requester.key92+```93+94+`access connect` is resident NDJSON like ordinary `connect`. It proves possession of the requester key, verifies the grant is broker-issued and bound to that endpoint, and then exposes only a loopback listener. A broker credential can be revoked independently; revocation deletes its unredeemed grant sessions, while any already minted one-use transport capability remains bounded by its existing short expiry.95+96+Keep the requester and broker roles distinct even if one harness can execute both. The skill is a protocol and CLI workflow: any model, shell runner, policy engine, or MCP client can implement the handoff. Do not depend on a named agent framework, vendor callback, or stateful model session.97+5498 ## Manage resident commands5599 56100 `devsite --json connect …` and `devsite --json daemon run` are resident processes. Their stdout is NDJSON: parse one JSON object per line and preserve process lifetime. `connect` is ready on its `listening` event; `daemon run` is ready on its `online` event. Continue to surface later error or `shutdown` events. Do not treat the initial process spawn as success and do not block forever without reporting observed state.
M web/app.js +14 -2
@@ -690,10 +690,13 @@ function incomingShareControl(share) {
690690 691691 function credentialControl(credential) {692692 const item = document.createElement('li');693+ const scopes = credential.scopes?.length694+ ? ` · ${credential.scopes.map((scope) => esc(scope)).join(', ')}`695+ : '';693696 item.innerHTML = `694697 <div>695698 <strong>${esc(credential.name)}</strong>696- <small>last used ${esc(formatTime(credential.last_used_at))}</small>699+ <small>last used ${esc(formatTime(credential.last_used_at))}${scopes}</small>697700 </div>`;698701 const button = document.createElement('button');699702 button.className = 'secondary outline';
@@ -790,6 +793,12 @@ async function showDashboard(newCredential = null) {
790793 placeholder="This MacBook" aria-label="Machine name" required>791794 <button type="submit">Create</button>792795 </fieldset>796+ <label>797+ <input id="credential-service-grants" type="checkbox">798+ Allow this machine to issue endpoint-bound service grants799+ </label>800+ <small>This authority is server-enforced and can be removed by revoking the801+ machine credential.</small>793802 <small id="credential-note"></small>794803 </form>795804 <div id="new-machine-token"></div>
@@ -869,7 +878,10 @@ async function showDashboard(newCredential = null) {
869878 try {870879 const created = await api('/api/machine-credentials', {871880 method: 'POST',872- body: JSON.stringify({ name: $('credential-name').value }),881+ body: JSON.stringify({882+ name: $('credential-name').value,883+ scopes: $('credential-service-grants').checked ? ['service_grants:issue'] : [],884+ }),873885 });874886 await showDashboard({ name: created.credential.name, ticket: created.ticket });875887 } catch (err) {