Rebrand: TeamClaw -> Clawmates (clawmates.work)

Full-depth rename per the approved plan; the 'claw' product vocabulary
(claws, /claws routes, clawId, Claw Chat) stays — it is now the brand.

- Display brand: Clawmates (manifest, titles, hero, login/rail logo
  'clawmates'); default host app.clawmates.work; registry
  ghcr.io/clawmates
- Crates tc-* -> cm-* (16 crates + all imports); binaries
  clawmates-server/broker/bundler; images clawmates/*; env prefix
  CLAWMATES_* (+ CM_TEST_DATABASE_URL / CM_LIVE_LLM); config
  clawmates.toml; helm chart deploy/helm/clawmates with clawmates-*
  resources; db names clawmates*; sockets /run/clawmates; cookie
  cm_session; kind cluster clawmates-test; seccomp node profile
  clawmates-agent-profile.json
- All 9 Playwright brand assertions updated in lockstep; historical
  spec document left untouched as the only remaining 'TeamClaw'
- Local env migrated: dev pg clawmates-dev-pg/clawmates_dev, shared
  test server clawmates-test-pg, kind cluster recreated with image +
  profile, compose images rebuilt under clawmates/*

Verified end to end: 161 Rust + 68 frontend tests, 29 Playwright
journeys, 4 live kind tests, helm/install/LOC/placeholder gates, and
the clean-room install rehearsal serving the clawmates login page from
a signed bundle of the rebuilt images.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 12:31:25 -05:00
co-authored by Claude Fable 5
parent 8046853feb
commit add4f79fed
209 changed files with 1429 additions and 1422 deletions
+110
View File
@@ -0,0 +1,110 @@
//! Session-JWT verification for hosted identity (Clerk, or any OIDC
//! issuer). The issuer's discovery document points at its JWKS; tokens
//! are RS256-verified against those keys with the issuer pinned. Keys are
//! cached and refreshed once on an unknown `kid` (Clerk rotates keys).
//!
//! Clerk specifics: a Clerk instance IS an OIDC issuer
//! (`https://<slug>.clerk.accounts.dev`) and its session tokens carry
//! `sub` (`user_…`). Email and org role are not in the default session
//! token — operators add them once in Clerk's dashboard (see
//! docs/clerk.md), which is what `ExternalClaims` reads.
use jsonwebtoken::jwk::JwkSet;
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use serde::Deserialize;
use tokio::sync::RwLock;
#[derive(Debug, thiserror::Error)]
pub enum JwtError {
#[error("issuer discovery failed: {0}")]
Discovery(String),
#[error("token rejected")]
Rejected,
}
/// Claims we consume from a verified session token.
#[derive(Debug, Clone, Deserialize)]
pub struct ExternalClaims {
pub sub: String,
#[serde(default)]
pub email: Option<String>,
/// Clerk org role (`org:admin` / `org:member`) via the documented
/// session-token customization; generic OIDC issuers may put any
/// string here.
#[serde(default)]
pub role: Option<String>,
}
pub struct JwtVerifier {
issuer: String,
jwks_uri: String,
keys: RwLock<JwkSet>,
}
#[derive(Deserialize)]
struct Discovery {
issuer: String,
jwks_uri: String,
}
impl JwtVerifier {
/// Resolves the issuer's JWKS via OIDC discovery and loads the
/// initial key set.
pub async fn discover(issuer_url: &str) -> Result<JwtVerifier, JwtError> {
let doc: Discovery = reqwest::get(format!(
"{}/.well-known/openid-configuration",
issuer_url.trim_end_matches('/')
))
.await
.map_err(|e| JwtError::Discovery(e.to_string()))?
.json()
.await
.map_err(|e| JwtError::Discovery(e.to_string()))?;
let keys = fetch_jwks(&doc.jwks_uri).await?;
Ok(JwtVerifier {
issuer: doc.issuer,
jwks_uri: doc.jwks_uri,
keys: RwLock::new(keys),
})
}
/// Verifies signature, expiry, and issuer; returns the claims.
pub async fn verify(&self, token: &str) -> Result<ExternalClaims, JwtError> {
let header = decode_header(token).map_err(|_| JwtError::Rejected)?;
let kid = header.kid.ok_or(JwtError::Rejected)?;
let mut decoding_key = self.key_for(&kid).await;
if decoding_key.is_none() {
// Unknown kid: the issuer may have rotated keys; refresh once.
let fresh = fetch_jwks(&self.jwks_uri).await?;
*self.keys.write().await = fresh;
decoding_key = self.key_for(&kid).await;
}
let decoding_key = decoding_key.ok_or(JwtError::Rejected)?;
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[&self.issuer]);
// Clerk session tokens live ~60s; the crate's default 60s leeway
// would double their effective life. 5s absorbs clock skew only.
validation.leeway = 5;
validation.validate_aud = false; // Clerk session tokens carry azp, not aud.
decode::<ExternalClaims>(token, &decoding_key, &validation)
.map(|data| data.claims)
.map_err(|_| JwtError::Rejected)
}
async fn key_for(&self, kid: &str) -> Option<DecodingKey> {
let keys = self.keys.read().await;
keys.find(kid)
.and_then(|jwk| DecodingKey::from_jwk(jwk).ok())
}
}
async fn fetch_jwks(jwks_uri: &str) -> Result<JwkSet, JwtError> {
reqwest::get(jwks_uri)
.await
.map_err(|e| JwtError::Discovery(e.to_string()))?
.json()
.await
.map_err(|e| JwtError::Discovery(e.to_string()))
}