Migration 0034: two tables. repo_connections carries the workspace's per-provider config (owner, base_url, label, last_synced_at, last_sync_error) and points at an app_connections row for the PAT. repos is the per-connection cache with (connection_id, external_id) unique so upsert is idempotent across re-syncs. Cascading deletes clean up cleanly on connection removal. cm-secrets grows a FetchAuthorized op — GET with the stored PAT injected as bearer, returns status + JSON body without ever exposing the credential to cm-api. This is the least-privilege door for read-only provider APIs (list repos), distinct from the InvokeHttp path that still requires a single-use approval grant for outbound writes. cm-api::routes::repos wires: - POST /api/repos/connections (broker store_secret + insert both rows + initial sync + mark_synced) - GET /api/repos/connections - DELETE /api/repos/connections/:id - POST /api/repos/connections/:id/sync - GET /api/repos (500 cap, newest provider_updated first) - GET /api/repos/:id (full detail incl. clone_url + html_url) GitHub provider inline for v1 — paginated pull of /orgs/:owner/repos (when owner set) or /user/repos (when absent), 100/page, capped at 20 pages (~2k repos) to keep first-sync latency bounded. Non-2xx surface back to the caller as sync_error; parse failures are best-effort per repo (skipped, logged, don't abort the batch). Gitea + GitLab providers land in a follow-up — mostly URL swap + response-shape adapter.
119 lines
3.7 KiB
Rust
119 lines
3.7 KiB
Rust
use std::path::Path;
|
|
|
|
use cm_domain::WorkspaceId;
|
|
use tokio::net::UnixStream;
|
|
use uuid::Uuid;
|
|
|
|
use crate::protocol::{read_frame, write_frame, Request, Response};
|
|
use crate::BrokerError;
|
|
|
|
/// Client side of the broker protocol, used by the server process only.
|
|
pub struct BrokerClient {
|
|
stream: UnixStream,
|
|
}
|
|
|
|
impl BrokerClient {
|
|
pub async fn connect(socket_path: &Path) -> Result<BrokerClient, BrokerError> {
|
|
let stream = UnixStream::connect(socket_path)
|
|
.await
|
|
.map_err(|e| BrokerError::Io(e.to_string()))?;
|
|
Ok(BrokerClient { stream })
|
|
}
|
|
|
|
async fn round_trip(&mut self, request: Request) -> Result<Response, BrokerError> {
|
|
write_frame(&mut self.stream, &request).await?;
|
|
let response: Response = read_frame(&mut self.stream).await?;
|
|
response.into_result()
|
|
}
|
|
|
|
pub async fn store_secret(
|
|
&mut self,
|
|
workspace_id: WorkspaceId,
|
|
kind: &str,
|
|
plaintext: &str,
|
|
) -> Result<Uuid, BrokerError> {
|
|
match self
|
|
.round_trip(Request::StoreSecret {
|
|
workspace_id: workspace_id.as_uuid(),
|
|
kind: kind.to_owned(),
|
|
plaintext: plaintext.to_owned(),
|
|
})
|
|
.await?
|
|
{
|
|
Response::SecretStored { secret_id } => Ok(secret_id),
|
|
other => Err(BrokerError::Io(format!("unexpected response: {other:?}"))),
|
|
}
|
|
}
|
|
|
|
pub async fn secret_kind(&mut self, secret_id: Uuid) -> Result<String, BrokerError> {
|
|
match self.round_trip(Request::SecretKind { secret_id }).await? {
|
|
Response::SecretKind { kind } => Ok(kind),
|
|
other => Err(BrokerError::Io(format!("unexpected response: {other:?}"))),
|
|
}
|
|
}
|
|
|
|
/// Asks the broker to verify a Slack signature; the signing secret
|
|
/// never crosses the socket.
|
|
pub async fn verify_slack_signature(
|
|
&mut self,
|
|
secret_id: Uuid,
|
|
timestamp: &str,
|
|
body: &str,
|
|
signature: &str,
|
|
) -> Result<bool, BrokerError> {
|
|
match self
|
|
.round_trip(Request::VerifySlackSignature {
|
|
secret_id,
|
|
timestamp: timestamp.to_owned(),
|
|
body: body.to_owned(),
|
|
signature: signature.to_owned(),
|
|
})
|
|
.await?
|
|
{
|
|
Response::Verified { valid } => Ok(valid),
|
|
other => Err(BrokerError::Io(format!("unexpected response: {other:?}"))),
|
|
}
|
|
}
|
|
|
|
pub async fn invoke_http(
|
|
&mut self,
|
|
approval_id: Uuid,
|
|
secret_id: Uuid,
|
|
url: &str,
|
|
body: serde_json::Value,
|
|
) -> Result<u16, BrokerError> {
|
|
match self
|
|
.round_trip(Request::InvokeHttp {
|
|
approval_id,
|
|
secret_id,
|
|
url: url.to_owned(),
|
|
body,
|
|
})
|
|
.await?
|
|
{
|
|
Response::HttpDone { status } => Ok(status),
|
|
other => Err(BrokerError::Io(format!("unexpected response: {other:?}"))),
|
|
}
|
|
}
|
|
|
|
/// Read-only GET with the stored PAT injected as a bearer token. The
|
|
/// credential never leaves the broker; the caller only sees the response
|
|
/// status + parsed JSON body. Used by the repo-provider sync path.
|
|
pub async fn fetch_authorized(
|
|
&mut self,
|
|
secret_id: Uuid,
|
|
url: &str,
|
|
) -> Result<(u16, serde_json::Value), BrokerError> {
|
|
match self
|
|
.round_trip(Request::FetchAuthorized {
|
|
secret_id,
|
|
url: url.to_owned(),
|
|
})
|
|
.await?
|
|
{
|
|
Response::HttpJson { status, body } => Ok((status, body)),
|
|
other => Err(BrokerError::Io(format!("unexpected response: {other:?}"))),
|
|
}
|
|
}
|
|
}
|