16
crates/forage-core/Cargo.toml
Normal file
16
crates/forage-core/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "forage-core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
rand.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
112
crates/forage-core/src/auth/mod.rs
Normal file
112
crates/forage-core/src/auth/mod.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
mod validation;
|
||||
|
||||
pub use validation::{validate_email, validate_password, validate_username};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Tokens returned by forest-server after login/register.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthTokens {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub expires_in_seconds: i64,
|
||||
}
|
||||
|
||||
/// Minimal user info from forest-server.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub emails: Vec<UserEmail>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserEmail {
|
||||
pub email: String,
|
||||
pub verified: bool,
|
||||
}
|
||||
|
||||
/// A personal access token (metadata only, no raw key).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersonalAccessToken {
|
||||
pub token_id: String,
|
||||
pub name: String,
|
||||
pub scopes: Vec<String>,
|
||||
pub created_at: Option<String>,
|
||||
pub last_used: Option<String>,
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of creating a PAT - includes the raw key shown once.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreatedToken {
|
||||
pub token: PersonalAccessToken,
|
||||
pub raw_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
#[error("invalid credentials")]
|
||||
InvalidCredentials,
|
||||
|
||||
#[error("already exists: {0}")]
|
||||
AlreadyExists(String),
|
||||
|
||||
#[error("not authenticated")]
|
||||
NotAuthenticated,
|
||||
|
||||
#[error("token expired")]
|
||||
TokenExpired,
|
||||
|
||||
#[error("forest-server unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Trait for communicating with forest-server's UsersService.
|
||||
/// Object-safe via async_trait so we can use `Arc<dyn ForestAuth>`.
|
||||
#[async_trait::async_trait]
|
||||
pub trait ForestAuth: Send + Sync {
|
||||
async fn register(
|
||||
&self,
|
||||
username: &str,
|
||||
email: &str,
|
||||
password: &str,
|
||||
) -> Result<AuthTokens, AuthError>;
|
||||
|
||||
async fn login(
|
||||
&self,
|
||||
identifier: &str,
|
||||
password: &str,
|
||||
) -> Result<AuthTokens, AuthError>;
|
||||
|
||||
async fn refresh_token(
|
||||
&self,
|
||||
refresh_token: &str,
|
||||
) -> Result<AuthTokens, AuthError>;
|
||||
|
||||
async fn logout(&self, refresh_token: &str) -> Result<(), AuthError>;
|
||||
|
||||
async fn get_user(&self, access_token: &str) -> Result<User, AuthError>;
|
||||
|
||||
async fn list_tokens(
|
||||
&self,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<PersonalAccessToken>, AuthError>;
|
||||
|
||||
async fn create_token(
|
||||
&self,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
) -> Result<CreatedToken, AuthError>;
|
||||
|
||||
async fn delete_token(
|
||||
&self,
|
||||
access_token: &str,
|
||||
token_id: &str,
|
||||
) -> Result<(), AuthError>;
|
||||
}
|
||||
120
crates/forage-core/src/auth/validation.rs
Normal file
120
crates/forage-core/src/auth/validation.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct ValidationError(pub String);
|
||||
|
||||
pub fn validate_email(email: &str) -> Result<(), ValidationError> {
|
||||
if email.is_empty() {
|
||||
return Err(ValidationError("Email is required".into()));
|
||||
}
|
||||
if !email.contains('@') || !email.contains('.') {
|
||||
return Err(ValidationError("Invalid email format".into()));
|
||||
}
|
||||
if email.len() > 254 {
|
||||
return Err(ValidationError("Email too long".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_password(password: &str) -> Result<(), ValidationError> {
|
||||
if password.is_empty() {
|
||||
return Err(ValidationError("Password is required".into()));
|
||||
}
|
||||
if password.len() < 12 {
|
||||
return Err(ValidationError(
|
||||
"Password must be at least 12 characters".into(),
|
||||
));
|
||||
}
|
||||
if password.len() > 1024 {
|
||||
return Err(ValidationError("Password too long".into()));
|
||||
}
|
||||
if !password.chars().any(|c| c.is_uppercase()) {
|
||||
return Err(ValidationError(
|
||||
"Password must contain at least one uppercase letter".into(),
|
||||
));
|
||||
}
|
||||
if !password.chars().any(|c| c.is_lowercase()) {
|
||||
return Err(ValidationError(
|
||||
"Password must contain at least one lowercase letter".into(),
|
||||
));
|
||||
}
|
||||
if !password.chars().any(|c| c.is_ascii_digit()) {
|
||||
return Err(ValidationError(
|
||||
"Password must contain at least one digit".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_username(username: &str) -> Result<(), ValidationError> {
|
||||
if username.is_empty() {
|
||||
return Err(ValidationError("Username is required".into()));
|
||||
}
|
||||
if username.len() < 3 {
|
||||
return Err(ValidationError(
|
||||
"Username must be at least 3 characters".into(),
|
||||
));
|
||||
}
|
||||
if username.len() > 64 {
|
||||
return Err(ValidationError("Username too long".into()));
|
||||
}
|
||||
if !username
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return Err(ValidationError(
|
||||
"Username can only contain letters, numbers, hyphens, and underscores".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn valid_email() {
|
||||
assert!(validate_email("user@example.com").is_ok());
|
||||
assert!(validate_email("a@b.c").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_email() {
|
||||
assert!(validate_email("").is_err());
|
||||
assert!(validate_email("noat").is_err());
|
||||
assert!(validate_email("no@dot").is_err());
|
||||
assert!(validate_email(&format!("{}@b.c", "a".repeat(251))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_password() {
|
||||
assert!(validate_password("SecurePass123").is_ok());
|
||||
assert!(validate_password("MyLongPassphrase1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_password() {
|
||||
assert!(validate_password("").is_err());
|
||||
assert!(validate_password("short").is_err());
|
||||
assert!(validate_password("12345678901").is_err()); // 11 chars
|
||||
assert!(validate_password(&"a".repeat(1025)).is_err());
|
||||
assert!(validate_password("alllowercase1").is_err()); // no uppercase
|
||||
assert!(validate_password("ALLUPPERCASE1").is_err()); // no lowercase
|
||||
assert!(validate_password("NoDigitsHere!").is_err()); // no digit
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_username() {
|
||||
assert!(validate_username("alice").is_ok());
|
||||
assert!(validate_username("bob-123").is_ok());
|
||||
assert!(validate_username("foo_bar").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_username() {
|
||||
assert!(validate_username("").is_err());
|
||||
assert!(validate_username("ab").is_err());
|
||||
assert!(validate_username("has spaces").is_err());
|
||||
assert!(validate_username("has@symbol").is_err());
|
||||
assert!(validate_username(&"a".repeat(65)).is_err());
|
||||
}
|
||||
}
|
||||
1
crates/forage-core/src/billing/mod.rs
Normal file
1
crates/forage-core/src/billing/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
// Billing and pricing logic - usage tracking, plan management.
|
||||
1
crates/forage-core/src/deployments/mod.rs
Normal file
1
crates/forage-core/src/deployments/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
// Deployment orchestration logic - managing deployment lifecycle.
|
||||
6
crates/forage-core/src/lib.rs
Normal file
6
crates/forage-core/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod auth;
|
||||
pub mod session;
|
||||
pub mod platform;
|
||||
pub mod registry;
|
||||
pub mod deployments;
|
||||
pub mod billing;
|
||||
101
crates/forage-core/src/platform/mod.rs
Normal file
101
crates/forage-core/src/platform/mod.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Validate that a slug (org name, project name) is safe for use in URLs and templates.
|
||||
/// Allows lowercase alphanumeric, hyphens, max 64 chars. Must not be empty.
|
||||
pub fn validate_slug(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.len() <= 64
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
&& !s.starts_with('-')
|
||||
&& !s.ends_with('-')
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Organisation {
|
||||
pub organisation_id: String,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Artifact {
|
||||
pub artifact_id: String,
|
||||
pub slug: String,
|
||||
pub context: ArtifactContext,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ArtifactContext {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum PlatformError {
|
||||
#[error("not authenticated")]
|
||||
NotAuthenticated,
|
||||
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("service unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Trait for platform data from forest-server (organisations, projects, artifacts).
|
||||
/// Separate from `ForestAuth` which handles identity.
|
||||
#[async_trait::async_trait]
|
||||
pub trait ForestPlatform: Send + Sync {
|
||||
async fn list_my_organisations(
|
||||
&self,
|
||||
access_token: &str,
|
||||
) -> Result<Vec<Organisation>, PlatformError>;
|
||||
|
||||
async fn list_projects(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation: &str,
|
||||
) -> Result<Vec<String>, PlatformError>;
|
||||
|
||||
async fn list_artifacts(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation: &str,
|
||||
project: &str,
|
||||
) -> Result<Vec<Artifact>, PlatformError>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn valid_slugs() {
|
||||
assert!(validate_slug("my-org"));
|
||||
assert!(validate_slug("a"));
|
||||
assert!(validate_slug("abc123"));
|
||||
assert!(validate_slug("my-cool-project-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_slugs() {
|
||||
assert!(!validate_slug(""));
|
||||
assert!(!validate_slug("-starts-with-dash"));
|
||||
assert!(!validate_slug("ends-with-dash-"));
|
||||
assert!(!validate_slug("UPPERCASE"));
|
||||
assert!(!validate_slug("has spaces"));
|
||||
assert!(!validate_slug("has_underscores"));
|
||||
assert!(!validate_slug("has.dots"));
|
||||
assert!(!validate_slug(&"a".repeat(65)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_length_slug_is_valid() {
|
||||
assert!(validate_slug(&"a".repeat(64)));
|
||||
}
|
||||
}
|
||||
1
crates/forage-core/src/registry/mod.rs
Normal file
1
crates/forage-core/src/registry/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
// Component registry logic - discovering, resolving, and managing forest components.
|
||||
260
crates/forage-core/src/session/mod.rs
Normal file
260
crates/forage-core/src/session/mod.rs
Normal file
@@ -0,0 +1,260 @@
|
||||
mod store;
|
||||
|
||||
pub use store::InMemorySessionStore;
|
||||
|
||||
use crate::auth::UserEmail;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Opaque session identifier. 32 bytes of cryptographic randomness, base64url-encoded.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct SessionId(String);
|
||||
|
||||
impl SessionId {
|
||||
pub fn generate() -> Self {
|
||||
use rand::Rng;
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rng().fill(&mut bytes);
|
||||
Self(base64url_encode(&bytes))
|
||||
}
|
||||
|
||||
/// Construct from a raw cookie value. No validation - it's just a lookup key.
|
||||
pub fn from_raw(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SessionId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn base64url_encode(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write;
|
||||
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
let mut out = String::with_capacity((bytes.len() * 4).div_ceil(3));
|
||||
for chunk in bytes.chunks(3) {
|
||||
let n = match chunk.len() {
|
||||
3 => (chunk[0] as u32) << 16 | (chunk[1] as u32) << 8 | chunk[2] as u32,
|
||||
2 => (chunk[0] as u32) << 16 | (chunk[1] as u32) << 8,
|
||||
1 => (chunk[0] as u32) << 16,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let _ = out.write_char(CHARS[((n >> 18) & 0x3F) as usize] as char);
|
||||
let _ = out.write_char(CHARS[((n >> 12) & 0x3F) as usize] as char);
|
||||
if chunk.len() > 1 {
|
||||
let _ = out.write_char(CHARS[((n >> 6) & 0x3F) as usize] as char);
|
||||
}
|
||||
if chunk.len() > 2 {
|
||||
let _ = out.write_char(CHARS[(n & 0x3F) as usize] as char);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Cached user info stored in the session to avoid repeated gRPC calls.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CachedUser {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub emails: Vec<UserEmail>,
|
||||
#[serde(default)]
|
||||
pub orgs: Vec<CachedOrg>,
|
||||
}
|
||||
|
||||
/// Cached organisation membership.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CachedOrg {
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
/// Generate a CSRF token (16 bytes of randomness, base64url-encoded).
|
||||
pub fn generate_csrf_token() -> String {
|
||||
use rand::Rng;
|
||||
let mut bytes = [0u8; 16];
|
||||
rand::rng().fill(&mut bytes);
|
||||
base64url_encode(&bytes)
|
||||
}
|
||||
|
||||
/// Server-side session data. Never exposed to the browser.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionData {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub access_expires_at: DateTime<Utc>,
|
||||
pub user: Option<CachedUser>,
|
||||
pub csrf_token: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_seen_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl SessionData {
|
||||
/// Whether the access token is expired or will expire within the given margin.
|
||||
pub fn is_access_expired(&self, margin: chrono::Duration) -> bool {
|
||||
Utc::now() + margin >= self.access_expires_at
|
||||
}
|
||||
|
||||
/// Whether the access token needs refreshing (expired or within 60s of expiry).
|
||||
pub fn needs_refresh(&self) -> bool {
|
||||
self.is_access_expired(chrono::Duration::seconds(60))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SessionError {
|
||||
#[error("session store error: {0}")]
|
||||
Store(String),
|
||||
}
|
||||
|
||||
/// Trait for session persistence. Swappable between in-memory, Redis, Postgres.
|
||||
#[async_trait::async_trait]
|
||||
pub trait SessionStore: Send + Sync {
|
||||
async fn create(&self, data: SessionData) -> Result<SessionId, SessionError>;
|
||||
async fn get(&self, id: &SessionId) -> Result<Option<SessionData>, SessionError>;
|
||||
async fn update(&self, id: &SessionId, data: SessionData) -> Result<(), SessionError>;
|
||||
async fn delete(&self, id: &SessionId) -> Result<(), SessionError>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[test]
|
||||
fn session_id_generates_unique_ids() {
|
||||
let ids: HashSet<String> = (0..1000).map(|_| SessionId::generate().0).collect();
|
||||
assert_eq!(ids.len(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_id_is_base64url_safe() {
|
||||
for _ in 0..100 {
|
||||
let id = SessionId::generate();
|
||||
let s = id.as_str();
|
||||
assert!(
|
||||
s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
|
||||
"invalid chars in session id: {s}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_id_has_sufficient_length() {
|
||||
// 32 bytes -> ~43 base64url chars
|
||||
let id = SessionId::generate();
|
||||
assert!(id.as_str().len() >= 42, "session id too short: {}", id.as_str().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_data_not_expired() {
|
||||
let data = SessionData {
|
||||
access_token: "tok".into(),
|
||||
refresh_token: "ref".into(),
|
||||
csrf_token: "test-csrf".into(),
|
||||
access_expires_at: Utc::now() + chrono::Duration::hours(1),
|
||||
user: None,
|
||||
created_at: Utc::now(),
|
||||
last_seen_at: Utc::now(),
|
||||
};
|
||||
assert!(!data.is_access_expired(chrono::Duration::zero()));
|
||||
assert!(!data.needs_refresh());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_data_expired() {
|
||||
let data = SessionData {
|
||||
access_token: "tok".into(),
|
||||
refresh_token: "ref".into(),
|
||||
csrf_token: "test-csrf".into(),
|
||||
access_expires_at: Utc::now() - chrono::Duration::seconds(1),
|
||||
user: None,
|
||||
created_at: Utc::now(),
|
||||
last_seen_at: Utc::now(),
|
||||
};
|
||||
assert!(data.is_access_expired(chrono::Duration::zero()));
|
||||
assert!(data.needs_refresh());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_data_needs_refresh_within_margin() {
|
||||
let data = SessionData {
|
||||
access_token: "tok".into(),
|
||||
refresh_token: "ref".into(),
|
||||
csrf_token: "test-csrf".into(),
|
||||
access_expires_at: Utc::now() + chrono::Duration::seconds(30),
|
||||
user: None,
|
||||
created_at: Utc::now(),
|
||||
last_seen_at: Utc::now(),
|
||||
};
|
||||
// Not expired yet, but within 60s margin
|
||||
assert!(!data.is_access_expired(chrono::Duration::zero()));
|
||||
assert!(data.needs_refresh());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn in_memory_store_create_and_get() {
|
||||
let store = InMemorySessionStore::new();
|
||||
let data = make_session_data();
|
||||
let id = store.create(data.clone()).await.unwrap();
|
||||
let retrieved = store.get(&id).await.unwrap().expect("session should exist");
|
||||
assert_eq!(retrieved.access_token, data.access_token);
|
||||
assert_eq!(retrieved.refresh_token, data.refresh_token);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn in_memory_store_get_nonexistent_returns_none() {
|
||||
let store = InMemorySessionStore::new();
|
||||
let id = SessionId::generate();
|
||||
assert!(store.get(&id).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn in_memory_store_update() {
|
||||
let store = InMemorySessionStore::new();
|
||||
let data = make_session_data();
|
||||
let id = store.create(data).await.unwrap();
|
||||
|
||||
let mut updated = make_session_data();
|
||||
updated.access_token = "new-access".into();
|
||||
store.update(&id, updated).await.unwrap();
|
||||
|
||||
let retrieved = store.get(&id).await.unwrap().unwrap();
|
||||
assert_eq!(retrieved.access_token, "new-access");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn in_memory_store_delete() {
|
||||
let store = InMemorySessionStore::new();
|
||||
let data = make_session_data();
|
||||
let id = store.create(data).await.unwrap();
|
||||
store.delete(&id).await.unwrap();
|
||||
assert!(store.get(&id).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn in_memory_store_delete_nonexistent_is_ok() {
|
||||
let store = InMemorySessionStore::new();
|
||||
let id = SessionId::generate();
|
||||
// Should not error
|
||||
store.delete(&id).await.unwrap();
|
||||
}
|
||||
|
||||
fn make_session_data() -> SessionData {
|
||||
SessionData {
|
||||
access_token: "test-access".into(),
|
||||
refresh_token: "test-refresh".into(),
|
||||
csrf_token: "test-csrf".into(),
|
||||
access_expires_at: Utc::now() + chrono::Duration::hours(1),
|
||||
user: None,
|
||||
created_at: Utc::now(),
|
||||
last_seen_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
66
crates/forage-core/src/session/store.rs
Normal file
66
crates/forage-core/src/session/store.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
use super::{SessionData, SessionError, SessionId, SessionStore};
|
||||
|
||||
/// In-memory session store. Suitable for single-instance deployments.
|
||||
/// Sessions are lost on server restart.
|
||||
pub struct InMemorySessionStore {
|
||||
sessions: RwLock<HashMap<SessionId, SessionData>>,
|
||||
max_inactive: Duration,
|
||||
}
|
||||
|
||||
impl Default for InMemorySessionStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl InMemorySessionStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: RwLock::new(HashMap::new()),
|
||||
max_inactive: Duration::days(30),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove sessions inactive for longer than `max_inactive`.
|
||||
pub fn reap_expired(&self) {
|
||||
let cutoff = Utc::now() - self.max_inactive;
|
||||
let mut sessions = self.sessions.write().unwrap();
|
||||
sessions.retain(|_, data| data.last_seen_at > cutoff);
|
||||
}
|
||||
|
||||
pub fn session_count(&self) -> usize {
|
||||
self.sessions.read().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SessionStore for InMemorySessionStore {
|
||||
async fn create(&self, data: SessionData) -> Result<SessionId, SessionError> {
|
||||
let id = SessionId::generate();
|
||||
let mut sessions = self.sessions.write().unwrap();
|
||||
sessions.insert(id.clone(), data);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn get(&self, id: &SessionId) -> Result<Option<SessionData>, SessionError> {
|
||||
let sessions = self.sessions.read().unwrap();
|
||||
Ok(sessions.get(id).cloned())
|
||||
}
|
||||
|
||||
async fn update(&self, id: &SessionId, data: SessionData) -> Result<(), SessionError> {
|
||||
let mut sessions = self.sessions.write().unwrap();
|
||||
sessions.insert(id.clone(), data);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &SessionId) -> Result<(), SessionError> {
|
||||
let mut sessions = self.sessions.write().unwrap();
|
||||
sessions.remove(id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
15
crates/forage-db/Cargo.toml
Normal file
15
crates/forage-db/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "forage-db"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
forage-core = { path = "../forage-core" }
|
||||
sqlx.workspace = true
|
||||
thiserror.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
tracing.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
async-trait.workspace = true
|
||||
9
crates/forage-db/src/lib.rs
Normal file
9
crates/forage-db/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod sessions;
|
||||
|
||||
pub use sessions::PgSessionStore;
|
||||
pub use sqlx::PgPool;
|
||||
|
||||
/// Run all pending migrations.
|
||||
pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::migrate::MigrateError> {
|
||||
sqlx::migrate!("src/migrations").run(pool).await
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT NOT NULL,
|
||||
access_expires_at TIMESTAMPTZ NOT NULL,
|
||||
user_id TEXT,
|
||||
username TEXT,
|
||||
user_emails JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sessions_last_seen ON sessions (last_seen_at);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sessions ADD COLUMN csrf_token TEXT NOT NULL DEFAULT '';
|
||||
163
crates/forage-db/src/sessions.rs
Normal file
163
crates/forage-db/src/sessions.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use forage_core::auth::UserEmail;
|
||||
use forage_core::session::{CachedUser, SessionData, SessionError, SessionId, SessionStore};
|
||||
use sqlx::PgPool;
|
||||
|
||||
/// PostgreSQL-backed session store for horizontal scaling.
|
||||
pub struct PgSessionStore {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgSessionStore {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// Remove sessions inactive for longer than `max_inactive_days`.
|
||||
pub async fn reap_expired(&self, max_inactive_days: i64) -> Result<u64, SessionError> {
|
||||
let cutoff = Utc::now() - chrono::Duration::days(max_inactive_days);
|
||||
let result = sqlx::query("DELETE FROM sessions WHERE last_seen_at < $1")
|
||||
.bind(cutoff)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| SessionError::Store(e.to_string()))?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SessionStore for PgSessionStore {
|
||||
async fn create(&self, data: SessionData) -> Result<SessionId, SessionError> {
|
||||
let id = SessionId::generate();
|
||||
let (user_id, username, emails_json) = match &data.user {
|
||||
Some(u) => (
|
||||
Some(u.user_id.clone()),
|
||||
Some(u.username.clone()),
|
||||
Some(
|
||||
serde_json::to_value(&u.emails)
|
||||
.map_err(|e| SessionError::Store(e.to_string()))?,
|
||||
),
|
||||
),
|
||||
None => (None, None, None),
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO sessions (session_id, access_token, refresh_token, access_expires_at, user_id, username, user_emails, csrf_token, created_at, last_seen_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||
)
|
||||
.bind(id.as_str())
|
||||
.bind(&data.access_token)
|
||||
.bind(&data.refresh_token)
|
||||
.bind(data.access_expires_at)
|
||||
.bind(&user_id)
|
||||
.bind(&username)
|
||||
.bind(&emails_json)
|
||||
.bind(&data.csrf_token)
|
||||
.bind(data.created_at)
|
||||
.bind(data.last_seen_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| SessionError::Store(e.to_string()))?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn get(&self, id: &SessionId) -> Result<Option<SessionData>, SessionError> {
|
||||
let row: Option<SessionRow> = sqlx::query_as(
|
||||
"SELECT access_token, refresh_token, access_expires_at, user_id, username, user_emails, csrf_token, created_at, last_seen_at
|
||||
FROM sessions WHERE session_id = $1",
|
||||
)
|
||||
.bind(id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| SessionError::Store(e.to_string()))?;
|
||||
|
||||
Ok(row.map(|r| r.into_session_data()))
|
||||
}
|
||||
|
||||
async fn update(&self, id: &SessionId, data: SessionData) -> Result<(), SessionError> {
|
||||
let (user_id, username, emails_json) = match &data.user {
|
||||
Some(u) => (
|
||||
Some(u.user_id.clone()),
|
||||
Some(u.username.clone()),
|
||||
Some(
|
||||
serde_json::to_value(&u.emails)
|
||||
.map_err(|e| SessionError::Store(e.to_string()))?,
|
||||
),
|
||||
),
|
||||
None => (None, None, None),
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE sessions SET access_token = $1, refresh_token = $2, access_expires_at = $3, user_id = $4, username = $5, user_emails = $6, csrf_token = $7, last_seen_at = $8
|
||||
WHERE session_id = $9",
|
||||
)
|
||||
.bind(&data.access_token)
|
||||
.bind(&data.refresh_token)
|
||||
.bind(data.access_expires_at)
|
||||
.bind(&user_id)
|
||||
.bind(&username)
|
||||
.bind(&emails_json)
|
||||
.bind(&data.csrf_token)
|
||||
.bind(data.last_seen_at)
|
||||
.bind(id.as_str())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| SessionError::Store(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &SessionId) -> Result<(), SessionError> {
|
||||
sqlx::query("DELETE FROM sessions WHERE session_id = $1")
|
||||
.bind(id.as_str())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| SessionError::Store(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SessionRow {
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
access_expires_at: DateTime<Utc>,
|
||||
user_id: Option<String>,
|
||||
username: Option<String>,
|
||||
user_emails: Option<serde_json::Value>,
|
||||
csrf_token: String,
|
||||
created_at: DateTime<Utc>,
|
||||
last_seen_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl SessionRow {
|
||||
fn into_session_data(self) -> SessionData {
|
||||
let user = match (self.user_id, self.username) {
|
||||
(Some(user_id), Some(username)) => {
|
||||
let emails: Vec<UserEmail> = self
|
||||
.user_emails
|
||||
.and_then(|v| serde_json::from_value(v).ok())
|
||||
.unwrap_or_default();
|
||||
Some(CachedUser {
|
||||
user_id,
|
||||
username,
|
||||
emails,
|
||||
orgs: vec![],
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
SessionData {
|
||||
access_token: self.access_token,
|
||||
refresh_token: self.refresh_token,
|
||||
access_expires_at: self.access_expires_at,
|
||||
user,
|
||||
csrf_token: self.csrf_token,
|
||||
created_at: self.created_at,
|
||||
last_seen_at: self.last_seen_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
15
crates/forage-grpc/Cargo.toml
Normal file
15
crates/forage-grpc/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "forage-grpc"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
default = ["client"]
|
||||
client = []
|
||||
server = []
|
||||
|
||||
[dependencies]
|
||||
prost.workspace = true
|
||||
prost-types.workspace = true
|
||||
tonic.workspace = true
|
||||
tonic-prost.workspace = true
|
||||
821
crates/forage-grpc/src/grpc/forest/v1/forest.v1.rs
Normal file
821
crates/forage-grpc/src/grpc/forest/v1/forest.v1.rs
Normal file
@@ -0,0 +1,821 @@
|
||||
// @generated
|
||||
// This file is @generated by prost-build.
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct Organisation {
|
||||
#[prost(string, tag="1")]
|
||||
pub organisation_id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub name: ::prost::alloc::string::String,
|
||||
#[prost(message, optional, tag="3")]
|
||||
pub created_at: ::core::option::Option<::prost_types::Timestamp>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct CreateOrganisationRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub name: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct CreateOrganisationResponse {
|
||||
#[prost(string, tag="1")]
|
||||
pub organisation_id: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct GetOrganisationRequest {
|
||||
#[prost(oneof="get_organisation_request::Identifier", tags="1, 2")]
|
||||
pub identifier: ::core::option::Option<get_organisation_request::Identifier>,
|
||||
}
|
||||
/// Nested message and enum types in `GetOrganisationRequest`.
|
||||
pub mod get_organisation_request {
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)]
|
||||
pub enum Identifier {
|
||||
#[prost(string, tag="1")]
|
||||
OrganisationId(::prost::alloc::string::String),
|
||||
#[prost(string, tag="2")]
|
||||
Name(::prost::alloc::string::String),
|
||||
}
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct GetOrganisationResponse {
|
||||
#[prost(message, optional, tag="1")]
|
||||
pub organisation: ::core::option::Option<Organisation>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct SearchOrganisationsRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub query: ::prost::alloc::string::String,
|
||||
#[prost(int32, tag="2")]
|
||||
pub page_size: i32,
|
||||
#[prost(string, tag="3")]
|
||||
pub page_token: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct SearchOrganisationsResponse {
|
||||
#[prost(message, repeated, tag="1")]
|
||||
pub organisations: ::prost::alloc::vec::Vec<Organisation>,
|
||||
#[prost(string, tag="2")]
|
||||
pub next_page_token: ::prost::alloc::string::String,
|
||||
#[prost(int32, tag="3")]
|
||||
pub total_count: i32,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct ListMyOrganisationsRequest {
|
||||
/// Optional role filter (e.g. "admin"); empty means all roles
|
||||
#[prost(string, tag="1")]
|
||||
pub role: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct ListMyOrganisationsResponse {
|
||||
#[prost(message, repeated, tag="1")]
|
||||
pub organisations: ::prost::alloc::vec::Vec<Organisation>,
|
||||
/// The role the caller has in each organisation (parallel to organisations)
|
||||
#[prost(string, repeated, tag="2")]
|
||||
pub roles: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
|
||||
}
|
||||
// -- Members ------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct OrganisationMember {
|
||||
#[prost(string, tag="1")]
|
||||
pub user_id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub username: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="3")]
|
||||
pub role: ::prost::alloc::string::String,
|
||||
#[prost(message, optional, tag="4")]
|
||||
pub joined_at: ::core::option::Option<::prost_types::Timestamp>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct AddMemberRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub organisation_id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub user_id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="3")]
|
||||
pub role: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct AddMemberResponse {
|
||||
#[prost(message, optional, tag="1")]
|
||||
pub member: ::core::option::Option<OrganisationMember>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct RemoveMemberRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub organisation_id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub user_id: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct RemoveMemberResponse {
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct UpdateMemberRoleRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub organisation_id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub user_id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="3")]
|
||||
pub role: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct UpdateMemberRoleResponse {
|
||||
#[prost(message, optional, tag="1")]
|
||||
pub member: ::core::option::Option<OrganisationMember>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct ListMembersRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub organisation_id: ::prost::alloc::string::String,
|
||||
#[prost(int32, tag="2")]
|
||||
pub page_size: i32,
|
||||
#[prost(string, tag="3")]
|
||||
pub page_token: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct ListMembersResponse {
|
||||
#[prost(message, repeated, tag="1")]
|
||||
pub members: ::prost::alloc::vec::Vec<OrganisationMember>,
|
||||
#[prost(string, tag="2")]
|
||||
pub next_page_token: ::prost::alloc::string::String,
|
||||
#[prost(int32, tag="3")]
|
||||
pub total_count: i32,
|
||||
}
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct AnnotateReleaseRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub artifact_id: ::prost::alloc::string::String,
|
||||
#[prost(map="string, string", tag="2")]
|
||||
pub metadata: ::std::collections::HashMap<::prost::alloc::string::String, ::prost::alloc::string::String>,
|
||||
#[prost(message, optional, tag="3")]
|
||||
pub source: ::core::option::Option<Source>,
|
||||
#[prost(message, optional, tag="4")]
|
||||
pub context: ::core::option::Option<ArtifactContext>,
|
||||
#[prost(message, optional, tag="5")]
|
||||
pub project: ::core::option::Option<Project>,
|
||||
#[prost(message, optional, tag="6")]
|
||||
pub r#ref: ::core::option::Option<Ref>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct AnnotateReleaseResponse {
|
||||
#[prost(message, optional, tag="1")]
|
||||
pub artifact: ::core::option::Option<Artifact>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct GetArtifactBySlugRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub slug: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct GetArtifactBySlugResponse {
|
||||
#[prost(message, optional, tag="1")]
|
||||
pub artifact: ::core::option::Option<Artifact>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct GetArtifactsByProjectRequest {
|
||||
#[prost(message, optional, tag="1")]
|
||||
pub project: ::core::option::Option<Project>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct GetArtifactsByProjectResponse {
|
||||
#[prost(message, repeated, tag="1")]
|
||||
pub artifact: ::prost::alloc::vec::Vec<Artifact>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct ReleaseRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub artifact_id: ::prost::alloc::string::String,
|
||||
#[prost(string, repeated, tag="2")]
|
||||
pub destinations: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
|
||||
#[prost(string, repeated, tag="3")]
|
||||
pub environments: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct ReleaseResponse {
|
||||
/// List of release intents created (one per destination)
|
||||
#[prost(message, repeated, tag="1")]
|
||||
pub intents: ::prost::alloc::vec::Vec<ReleaseIntent>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct ReleaseIntent {
|
||||
#[prost(string, tag="1")]
|
||||
pub release_intent_id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub destination: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="3")]
|
||||
pub environment: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct WaitReleaseRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub release_intent_id: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct WaitReleaseEvent {
|
||||
#[prost(oneof="wait_release_event::Event", tags="1, 2")]
|
||||
pub event: ::core::option::Option<wait_release_event::Event>,
|
||||
}
|
||||
/// Nested message and enum types in `WaitReleaseEvent`.
|
||||
pub mod wait_release_event {
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)]
|
||||
pub enum Event {
|
||||
#[prost(message, tag="1")]
|
||||
StatusUpdate(super::ReleaseStatusUpdate),
|
||||
#[prost(message, tag="2")]
|
||||
LogLine(super::ReleaseLogLine),
|
||||
}
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct ReleaseStatusUpdate {
|
||||
#[prost(string, tag="1")]
|
||||
pub destination: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub status: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct ReleaseLogLine {
|
||||
#[prost(string, tag="1")]
|
||||
pub destination: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub line: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="3")]
|
||||
pub timestamp: ::prost::alloc::string::String,
|
||||
#[prost(enumeration="LogChannel", tag="4")]
|
||||
pub channel: i32,
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct GetOrganisationsRequest {
|
||||
}
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct GetOrganisationsResponse {
|
||||
#[prost(message, repeated, tag="1")]
|
||||
pub organisations: ::prost::alloc::vec::Vec<OrganisationRef>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct GetProjectsRequest {
|
||||
#[prost(oneof="get_projects_request::Query", tags="1")]
|
||||
pub query: ::core::option::Option<get_projects_request::Query>,
|
||||
}
|
||||
/// Nested message and enum types in `GetProjectsRequest`.
|
||||
pub mod get_projects_request {
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)]
|
||||
pub enum Query {
|
||||
#[prost(message, tag="1")]
|
||||
Organisation(super::OrganisationRef),
|
||||
}
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct GetProjectsResponse {
|
||||
#[prost(string, repeated, tag="1")]
|
||||
pub projects: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct Source {
|
||||
#[prost(string, optional, tag="1")]
|
||||
pub user: ::core::option::Option<::prost::alloc::string::String>,
|
||||
#[prost(string, optional, tag="2")]
|
||||
pub email: ::core::option::Option<::prost::alloc::string::String>,
|
||||
#[prost(string, optional, tag="3")]
|
||||
pub source_type: ::core::option::Option<::prost::alloc::string::String>,
|
||||
#[prost(string, optional, tag="4")]
|
||||
pub run_url: ::core::option::Option<::prost::alloc::string::String>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct ArtifactContext {
|
||||
#[prost(string, tag="1")]
|
||||
pub title: ::prost::alloc::string::String,
|
||||
#[prost(string, optional, tag="2")]
|
||||
pub description: ::core::option::Option<::prost::alloc::string::String>,
|
||||
#[prost(string, optional, tag="3")]
|
||||
pub web: ::core::option::Option<::prost::alloc::string::String>,
|
||||
#[prost(string, optional, tag="4")]
|
||||
pub pr: ::core::option::Option<::prost::alloc::string::String>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct Artifact {
|
||||
#[prost(string, tag="1")]
|
||||
pub id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub artifact_id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="3")]
|
||||
pub slug: ::prost::alloc::string::String,
|
||||
#[prost(map="string, string", tag="4")]
|
||||
pub metadata: ::std::collections::HashMap<::prost::alloc::string::String, ::prost::alloc::string::String>,
|
||||
#[prost(message, optional, tag="5")]
|
||||
pub source: ::core::option::Option<Source>,
|
||||
#[prost(message, optional, tag="6")]
|
||||
pub context: ::core::option::Option<ArtifactContext>,
|
||||
#[prost(message, optional, tag="7")]
|
||||
pub project: ::core::option::Option<Project>,
|
||||
#[prost(message, repeated, tag="8")]
|
||||
pub destinations: ::prost::alloc::vec::Vec<ArtifactDestination>,
|
||||
#[prost(string, tag="9")]
|
||||
pub created_at: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct ArtifactDestination {
|
||||
#[prost(string, tag="1")]
|
||||
pub name: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub environment: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="3")]
|
||||
pub type_organisation: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="4")]
|
||||
pub type_name: ::prost::alloc::string::String,
|
||||
#[prost(uint64, tag="5")]
|
||||
pub type_version: u64,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct Project {
|
||||
#[prost(string, tag="1")]
|
||||
pub organisation: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub project: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct Ref {
|
||||
#[prost(string, tag="1")]
|
||||
pub commit_sha: ::prost::alloc::string::String,
|
||||
#[prost(string, optional, tag="2")]
|
||||
pub branch: ::core::option::Option<::prost::alloc::string::String>,
|
||||
#[prost(string, optional, tag="3")]
|
||||
pub commit_message: ::core::option::Option<::prost::alloc::string::String>,
|
||||
#[prost(string, optional, tag="4")]
|
||||
pub version: ::core::option::Option<::prost::alloc::string::String>,
|
||||
#[prost(string, optional, tag="5")]
|
||||
pub repo_url: ::core::option::Option<::prost::alloc::string::String>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct OrganisationRef {
|
||||
#[prost(string, tag="1")]
|
||||
pub organisation: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
|
||||
#[repr(i32)]
|
||||
pub enum LogChannel {
|
||||
Unspecified = 0,
|
||||
Stdout = 1,
|
||||
Stderr = 2,
|
||||
}
|
||||
impl LogChannel {
|
||||
/// String value of the enum field names used in the ProtoBuf definition.
|
||||
///
|
||||
/// The values are not transformed in any way and thus are considered stable
|
||||
/// (if the ProtoBuf definition does not change) and safe for programmatic use.
|
||||
pub fn as_str_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Unspecified => "LOG_CHANNEL_UNSPECIFIED",
|
||||
Self::Stdout => "LOG_CHANNEL_STDOUT",
|
||||
Self::Stderr => "LOG_CHANNEL_STDERR",
|
||||
}
|
||||
}
|
||||
/// Creates an enum from field names used in the ProtoBuf definition.
|
||||
pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
|
||||
match value {
|
||||
"LOG_CHANNEL_UNSPECIFIED" => Some(Self::Unspecified),
|
||||
"LOG_CHANNEL_STDOUT" => Some(Self::Stdout),
|
||||
"LOG_CHANNEL_STDERR" => Some(Self::Stderr),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
// ─── Core types ──────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct User {
|
||||
/// UUID
|
||||
#[prost(string, tag="1")]
|
||||
pub user_id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub username: ::prost::alloc::string::String,
|
||||
#[prost(message, repeated, tag="3")]
|
||||
pub emails: ::prost::alloc::vec::Vec<UserEmail>,
|
||||
#[prost(message, repeated, tag="4")]
|
||||
pub oauth_connections: ::prost::alloc::vec::Vec<OAuthConnection>,
|
||||
#[prost(bool, tag="5")]
|
||||
pub mfa_enabled: bool,
|
||||
#[prost(message, optional, tag="6")]
|
||||
pub created_at: ::core::option::Option<::prost_types::Timestamp>,
|
||||
#[prost(message, optional, tag="7")]
|
||||
pub updated_at: ::core::option::Option<::prost_types::Timestamp>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct UserEmail {
|
||||
#[prost(string, tag="1")]
|
||||
pub email: ::prost::alloc::string::String,
|
||||
#[prost(bool, tag="2")]
|
||||
pub verified: bool,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct OAuthConnection {
|
||||
#[prost(enumeration="OAuthProvider", tag="1")]
|
||||
pub provider: i32,
|
||||
#[prost(string, tag="2")]
|
||||
pub provider_user_id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="3")]
|
||||
pub provider_email: ::prost::alloc::string::String,
|
||||
#[prost(message, optional, tag="4")]
|
||||
pub linked_at: ::core::option::Option<::prost_types::Timestamp>,
|
||||
}
|
||||
// ─── Authentication ──────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct RegisterRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub username: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub email: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="3")]
|
||||
pub password: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct RegisterResponse {
|
||||
#[prost(message, optional, tag="1")]
|
||||
pub user: ::core::option::Option<User>,
|
||||
#[prost(message, optional, tag="2")]
|
||||
pub tokens: ::core::option::Option<AuthTokens>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct LoginRequest {
|
||||
#[prost(string, tag="3")]
|
||||
pub password: ::prost::alloc::string::String,
|
||||
/// Login with either username or email
|
||||
#[prost(oneof="login_request::Identifier", tags="1, 2")]
|
||||
pub identifier: ::core::option::Option<login_request::Identifier>,
|
||||
}
|
||||
/// Nested message and enum types in `LoginRequest`.
|
||||
pub mod login_request {
|
||||
/// Login with either username or email
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)]
|
||||
pub enum Identifier {
|
||||
#[prost(string, tag="1")]
|
||||
Username(::prost::alloc::string::String),
|
||||
#[prost(string, tag="2")]
|
||||
Email(::prost::alloc::string::String),
|
||||
}
|
||||
}
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct LoginResponse {
|
||||
#[prost(message, optional, tag="1")]
|
||||
pub user: ::core::option::Option<User>,
|
||||
#[prost(message, optional, tag="2")]
|
||||
pub tokens: ::core::option::Option<AuthTokens>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct RefreshTokenRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub refresh_token: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct RefreshTokenResponse {
|
||||
#[prost(message, optional, tag="1")]
|
||||
pub tokens: ::core::option::Option<AuthTokens>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct LogoutRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub refresh_token: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct LogoutResponse {
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct AuthTokens {
|
||||
#[prost(string, tag="1")]
|
||||
pub access_token: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub refresh_token: ::prost::alloc::string::String,
|
||||
#[prost(int64, tag="3")]
|
||||
pub expires_in_seconds: i64,
|
||||
}
|
||||
// ─── Token introspection ─────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct TokenInfoRequest {
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct TokenInfoResponse {
|
||||
#[prost(string, tag="1")]
|
||||
pub user_id: ::prost::alloc::string::String,
|
||||
/// Unix timestamp (seconds)
|
||||
#[prost(int64, tag="2")]
|
||||
pub expires_at: i64,
|
||||
}
|
||||
// ─── User CRUD ───────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct GetUserRequest {
|
||||
#[prost(oneof="get_user_request::Identifier", tags="1, 2, 3")]
|
||||
pub identifier: ::core::option::Option<get_user_request::Identifier>,
|
||||
}
|
||||
/// Nested message and enum types in `GetUserRequest`.
|
||||
pub mod get_user_request {
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)]
|
||||
pub enum Identifier {
|
||||
#[prost(string, tag="1")]
|
||||
UserId(::prost::alloc::string::String),
|
||||
#[prost(string, tag="2")]
|
||||
Username(::prost::alloc::string::String),
|
||||
#[prost(string, tag="3")]
|
||||
Email(::prost::alloc::string::String),
|
||||
}
|
||||
}
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct GetUserResponse {
|
||||
#[prost(message, optional, tag="1")]
|
||||
pub user: ::core::option::Option<User>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct UpdateUserRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub user_id: ::prost::alloc::string::String,
|
||||
#[prost(string, optional, tag="2")]
|
||||
pub username: ::core::option::Option<::prost::alloc::string::String>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct UpdateUserResponse {
|
||||
#[prost(message, optional, tag="1")]
|
||||
pub user: ::core::option::Option<User>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct DeleteUserRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub user_id: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct DeleteUserResponse {
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct ListUsersRequest {
|
||||
#[prost(int32, tag="1")]
|
||||
pub page_size: i32,
|
||||
#[prost(string, tag="2")]
|
||||
pub page_token: ::prost::alloc::string::String,
|
||||
/// search across username, email
|
||||
#[prost(string, optional, tag="3")]
|
||||
pub search: ::core::option::Option<::prost::alloc::string::String>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct ListUsersResponse {
|
||||
#[prost(message, repeated, tag="1")]
|
||||
pub users: ::prost::alloc::vec::Vec<User>,
|
||||
#[prost(string, tag="2")]
|
||||
pub next_page_token: ::prost::alloc::string::String,
|
||||
#[prost(int32, tag="3")]
|
||||
pub total_count: i32,
|
||||
}
|
||||
// ─── Password management ─────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct ChangePasswordRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub user_id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub current_password: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="3")]
|
||||
pub new_password: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct ChangePasswordResponse {
|
||||
}
|
||||
// ─── Email management ────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct AddEmailRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub user_id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub email: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct AddEmailResponse {
|
||||
#[prost(message, optional, tag="1")]
|
||||
pub email: ::core::option::Option<UserEmail>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct VerifyEmailRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub user_id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub email: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct VerifyEmailResponse {
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct RemoveEmailRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub user_id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub email: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct RemoveEmailResponse {
|
||||
}
|
||||
// ─── OAuth / Social login ────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct OAuthLoginRequest {
|
||||
#[prost(enumeration="OAuthProvider", tag="1")]
|
||||
pub provider: i32,
|
||||
#[prost(string, tag="2")]
|
||||
pub authorization_code: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="3")]
|
||||
pub redirect_uri: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct OAuthLoginResponse {
|
||||
#[prost(message, optional, tag="1")]
|
||||
pub user: ::core::option::Option<User>,
|
||||
#[prost(message, optional, tag="2")]
|
||||
pub tokens: ::core::option::Option<AuthTokens>,
|
||||
#[prost(bool, tag="3")]
|
||||
pub is_new_user: bool,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct LinkOAuthProviderRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub user_id: ::prost::alloc::string::String,
|
||||
#[prost(enumeration="OAuthProvider", tag="2")]
|
||||
pub provider: i32,
|
||||
#[prost(string, tag="3")]
|
||||
pub authorization_code: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="4")]
|
||||
pub redirect_uri: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct LinkOAuthProviderResponse {
|
||||
#[prost(message, optional, tag="1")]
|
||||
pub connection: ::core::option::Option<OAuthConnection>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct UnlinkOAuthProviderRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub user_id: ::prost::alloc::string::String,
|
||||
#[prost(enumeration="OAuthProvider", tag="2")]
|
||||
pub provider: i32,
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct UnlinkOAuthProviderResponse {
|
||||
}
|
||||
// ─── Personal access tokens ──────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct PersonalAccessToken {
|
||||
/// UUID
|
||||
#[prost(string, tag="1")]
|
||||
pub token_id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub name: ::prost::alloc::string::String,
|
||||
#[prost(string, repeated, tag="3")]
|
||||
pub scopes: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
|
||||
#[prost(message, optional, tag="4")]
|
||||
pub expires_at: ::core::option::Option<::prost_types::Timestamp>,
|
||||
#[prost(message, optional, tag="5")]
|
||||
pub last_used: ::core::option::Option<::prost_types::Timestamp>,
|
||||
#[prost(message, optional, tag="6")]
|
||||
pub created_at: ::core::option::Option<::prost_types::Timestamp>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct CreatePersonalAccessTokenRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub user_id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag="2")]
|
||||
pub name: ::prost::alloc::string::String,
|
||||
#[prost(string, repeated, tag="3")]
|
||||
pub scopes: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
|
||||
/// Duration in seconds; 0 = no expiry
|
||||
#[prost(int64, tag="4")]
|
||||
pub expires_in_seconds: i64,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct CreatePersonalAccessTokenResponse {
|
||||
#[prost(message, optional, tag="1")]
|
||||
pub token: ::core::option::Option<PersonalAccessToken>,
|
||||
/// The raw token value, only returned on creation
|
||||
#[prost(string, tag="2")]
|
||||
pub raw_token: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct ListPersonalAccessTokensRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub user_id: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct ListPersonalAccessTokensResponse {
|
||||
#[prost(message, repeated, tag="1")]
|
||||
pub tokens: ::prost::alloc::vec::Vec<PersonalAccessToken>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct DeletePersonalAccessTokenRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub token_id: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct DeletePersonalAccessTokenResponse {
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct SetupMfaRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub user_id: ::prost::alloc::string::String,
|
||||
#[prost(enumeration="MfaType", tag="2")]
|
||||
pub mfa_type: i32,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct SetupMfaResponse {
|
||||
/// UUID
|
||||
#[prost(string, tag="1")]
|
||||
pub mfa_id: ::prost::alloc::string::String,
|
||||
/// TOTP provisioning URI (otpauth://...)
|
||||
#[prost(string, tag="2")]
|
||||
pub provisioning_uri: ::prost::alloc::string::String,
|
||||
/// Base32-encoded secret for manual entry
|
||||
#[prost(string, tag="3")]
|
||||
pub secret: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct VerifyMfaRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub mfa_id: ::prost::alloc::string::String,
|
||||
/// The TOTP code to verify setup
|
||||
#[prost(string, tag="2")]
|
||||
pub code: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct VerifyMfaResponse {
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct DisableMfaRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub user_id: ::prost::alloc::string::String,
|
||||
/// Current TOTP code to confirm disable
|
||||
#[prost(string, tag="2")]
|
||||
pub code: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct DisableMfaResponse {
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
|
||||
#[repr(i32)]
|
||||
pub enum OAuthProvider {
|
||||
OauthProviderUnspecified = 0,
|
||||
OauthProviderGithub = 1,
|
||||
OauthProviderGoogle = 2,
|
||||
OauthProviderGitlab = 3,
|
||||
OauthProviderMicrosoft = 4,
|
||||
}
|
||||
impl OAuthProvider {
|
||||
/// String value of the enum field names used in the ProtoBuf definition.
|
||||
///
|
||||
/// The values are not transformed in any way and thus are considered stable
|
||||
/// (if the ProtoBuf definition does not change) and safe for programmatic use.
|
||||
pub fn as_str_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::OauthProviderUnspecified => "OAUTH_PROVIDER_UNSPECIFIED",
|
||||
Self::OauthProviderGithub => "OAUTH_PROVIDER_GITHUB",
|
||||
Self::OauthProviderGoogle => "OAUTH_PROVIDER_GOOGLE",
|
||||
Self::OauthProviderGitlab => "OAUTH_PROVIDER_GITLAB",
|
||||
Self::OauthProviderMicrosoft => "OAUTH_PROVIDER_MICROSOFT",
|
||||
}
|
||||
}
|
||||
/// Creates an enum from field names used in the ProtoBuf definition.
|
||||
pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
|
||||
match value {
|
||||
"OAUTH_PROVIDER_UNSPECIFIED" => Some(Self::OauthProviderUnspecified),
|
||||
"OAUTH_PROVIDER_GITHUB" => Some(Self::OauthProviderGithub),
|
||||
"OAUTH_PROVIDER_GOOGLE" => Some(Self::OauthProviderGoogle),
|
||||
"OAUTH_PROVIDER_GITLAB" => Some(Self::OauthProviderGitlab),
|
||||
"OAUTH_PROVIDER_MICROSOFT" => Some(Self::OauthProviderMicrosoft),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
// ─── MFA ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
|
||||
#[repr(i32)]
|
||||
pub enum MfaType {
|
||||
Unspecified = 0,
|
||||
Totp = 1,
|
||||
}
|
||||
impl MfaType {
|
||||
/// String value of the enum field names used in the ProtoBuf definition.
|
||||
///
|
||||
/// The values are not transformed in any way and thus are considered stable
|
||||
/// (if the ProtoBuf definition does not change) and safe for programmatic use.
|
||||
pub fn as_str_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Unspecified => "MFA_TYPE_UNSPECIFIED",
|
||||
Self::Totp => "MFA_TYPE_TOTP",
|
||||
}
|
||||
}
|
||||
/// Creates an enum from field names used in the ProtoBuf definition.
|
||||
pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
|
||||
match value {
|
||||
"MFA_TYPE_UNSPECIFIED" => Some(Self::Unspecified),
|
||||
"MFA_TYPE_TOTP" => Some(Self::Totp),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
include!("forest.v1.tonic.rs");
|
||||
// @@protoc_insertion_point(module)
|
||||
3579
crates/forage-grpc/src/grpc/forest/v1/forest.v1.tonic.rs
Normal file
3579
crates/forage-grpc/src/grpc/forest/v1/forest.v1.tonic.rs
Normal file
File diff suppressed because it is too large
Load Diff
6
crates/forage-grpc/src/lib.rs
Normal file
6
crates/forage-grpc/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
#![allow(clippy::empty_docs)]
|
||||
|
||||
#[path = "./grpc/forest/v1/forest.v1.rs"]
|
||||
pub mod grpc;
|
||||
|
||||
pub use grpc::*;
|
||||
25
crates/forage-server/Cargo.toml
Normal file
25
crates/forage-server/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "forage-server"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
forage-core = { path = "../forage-core" }
|
||||
forage-db = { path = "../forage-db" }
|
||||
forage-grpc = { path = "../forage-grpc" }
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
async-trait.workspace = true
|
||||
axum.workspace = true
|
||||
axum-extra.workspace = true
|
||||
minijinja.workspace = true
|
||||
serde.workspace = true
|
||||
sqlx.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tonic.workspace = true
|
||||
tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
uuid.workspace = true
|
||||
164
crates/forage-server/src/auth.rs
Normal file
164
crates/forage-server/src/auth.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use axum_extra::extract::CookieJar;
|
||||
use axum_extra::extract::cookie::Cookie;
|
||||
|
||||
use forage_core::session::{CachedOrg, CachedUser, SessionId};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub const SESSION_COOKIE: &str = "forage_session";
|
||||
|
||||
/// Maximum access token lifetime: 24 hours.
|
||||
/// Defends against forest-server returning absolute timestamps instead of durations.
|
||||
const MAX_TOKEN_LIFETIME_SECS: i64 = 86400;
|
||||
|
||||
/// Cap expires_in_seconds to a sane maximum.
|
||||
pub fn cap_token_expiry(expires_in_seconds: i64) -> i64 {
|
||||
expires_in_seconds.min(MAX_TOKEN_LIFETIME_SECS)
|
||||
}
|
||||
|
||||
/// Active session data available to route handlers.
|
||||
pub struct Session {
|
||||
pub session_id: SessionId,
|
||||
pub access_token: String,
|
||||
pub user: CachedUser,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
/// Extractor that requires an active session. Redirects to /login if not authenticated.
|
||||
/// Handles transparent token refresh when access token is near expiry.
|
||||
impl FromRequestParts<AppState> for Session {
|
||||
type Rejection = axum::response::Redirect;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let jar = CookieJar::from_headers(&parts.headers);
|
||||
let session_id = jar
|
||||
.get(SESSION_COOKIE)
|
||||
.map(|c| SessionId::from_raw(c.value().to_string()))
|
||||
.ok_or(axum::response::Redirect::to("/login"))?;
|
||||
|
||||
let mut session_data = state
|
||||
.sessions
|
||||
.get(&session_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or(axum::response::Redirect::to("/login"))?;
|
||||
|
||||
// Transparent token refresh
|
||||
if session_data.needs_refresh() {
|
||||
match state
|
||||
.forest_client
|
||||
.refresh_token(&session_data.refresh_token)
|
||||
.await
|
||||
{
|
||||
Ok(tokens) => {
|
||||
session_data.access_token = tokens.access_token;
|
||||
session_data.refresh_token = tokens.refresh_token;
|
||||
session_data.access_expires_at =
|
||||
chrono::Utc::now() + chrono::Duration::seconds(cap_token_expiry(tokens.expires_in_seconds));
|
||||
session_data.last_seen_at = chrono::Utc::now();
|
||||
|
||||
// Refresh the user cache too
|
||||
if let Ok(user) = state
|
||||
.forest_client
|
||||
.get_user(&session_data.access_token)
|
||||
.await
|
||||
{
|
||||
let orgs = state
|
||||
.platform_client
|
||||
.list_my_organisations(&session_data.access_token)
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|o| CachedOrg {
|
||||
name: o.name,
|
||||
role: o.role,
|
||||
})
|
||||
.collect();
|
||||
session_data.user = Some(CachedUser {
|
||||
user_id: user.user_id.clone(),
|
||||
username: user.username.clone(),
|
||||
emails: user.emails,
|
||||
orgs,
|
||||
});
|
||||
}
|
||||
|
||||
let _ = state.sessions.update(&session_id, session_data.clone()).await;
|
||||
}
|
||||
Err(_) => {
|
||||
// Refresh token rejected - session is dead
|
||||
let _ = state.sessions.delete(&session_id).await;
|
||||
return Err(axum::response::Redirect::to("/login"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Throttle last_seen_at writes: only update if older than 5 minutes
|
||||
let now = chrono::Utc::now();
|
||||
if now - session_data.last_seen_at > chrono::Duration::minutes(5) {
|
||||
session_data.last_seen_at = now;
|
||||
let _ = state.sessions.update(&session_id, session_data.clone()).await;
|
||||
}
|
||||
}
|
||||
|
||||
let user = session_data
|
||||
.user
|
||||
.ok_or(axum::response::Redirect::to("/login"))?;
|
||||
|
||||
Ok(Session {
|
||||
session_id,
|
||||
access_token: session_data.access_token,
|
||||
user,
|
||||
csrf_token: session_data.csrf_token,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Extractor that optionally provides session info. Never rejects.
|
||||
/// Used for pages that behave differently when authenticated (e.g., login/signup redirect).
|
||||
pub struct MaybeSession {
|
||||
pub session: Option<Session>,
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for MaybeSession {
|
||||
type Rejection = std::convert::Infallible;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let session = Session::from_request_parts(parts, state).await.ok();
|
||||
Ok(MaybeSession { session })
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a Set-Cookie header for the session.
|
||||
pub fn session_cookie(session_id: &SessionId) -> CookieJar {
|
||||
let cookie = Cookie::build((SESSION_COOKIE, session_id.to_string()))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.secure(true)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Lax)
|
||||
.build();
|
||||
|
||||
CookieJar::new().add(cookie)
|
||||
}
|
||||
|
||||
/// Validate that a submitted CSRF token matches the session's token.
|
||||
pub fn validate_csrf(session: &Session, submitted: &str) -> bool {
|
||||
!session.csrf_token.is_empty() && session.csrf_token == submitted
|
||||
}
|
||||
|
||||
/// Build a Set-Cookie header that clears the session cookie.
|
||||
pub fn clear_session_cookie() -> CookieJar {
|
||||
let mut cookie = Cookie::from(SESSION_COOKIE);
|
||||
cookie.set_path("/");
|
||||
cookie.make_removal();
|
||||
|
||||
CookieJar::new().add(cookie)
|
||||
}
|
||||
497
crates/forage-server/src/forest_client.rs
Normal file
497
crates/forage-server/src/forest_client.rs
Normal file
@@ -0,0 +1,497 @@
|
||||
use forage_core::auth::{
|
||||
AuthError, AuthTokens, CreatedToken, ForestAuth, PersonalAccessToken, User, UserEmail,
|
||||
};
|
||||
use forage_core::platform::{
|
||||
Artifact, ArtifactContext, ForestPlatform, Organisation, PlatformError,
|
||||
};
|
||||
use forage_grpc::organisation_service_client::OrganisationServiceClient;
|
||||
use forage_grpc::release_service_client::ReleaseServiceClient;
|
||||
use forage_grpc::users_service_client::UsersServiceClient;
|
||||
use tonic::metadata::MetadataValue;
|
||||
use tonic::transport::Channel;
|
||||
use tonic::Request;
|
||||
|
||||
fn bearer_request<T>(access_token: &str, msg: T) -> Result<Request<T>, String> {
|
||||
let mut req = Request::new(msg);
|
||||
let bearer: MetadataValue<_> = format!("Bearer {access_token}")
|
||||
.parse()
|
||||
.map_err(|_| "invalid token format".to_string())?;
|
||||
req.metadata_mut().insert("authorization", bearer);
|
||||
Ok(req)
|
||||
}
|
||||
|
||||
/// Real gRPC client to forest-server's UsersService.
|
||||
#[derive(Clone)]
|
||||
pub struct GrpcForestClient {
|
||||
channel: Channel,
|
||||
}
|
||||
|
||||
impl GrpcForestClient {
|
||||
/// Create a client that connects lazily (for when server may not be available at startup).
|
||||
pub fn connect_lazy(endpoint: &str) -> anyhow::Result<Self> {
|
||||
let channel = Channel::from_shared(endpoint.to_string())?.connect_lazy();
|
||||
Ok(Self { channel })
|
||||
}
|
||||
|
||||
fn client(&self) -> UsersServiceClient<Channel> {
|
||||
UsersServiceClient::new(self.channel.clone())
|
||||
}
|
||||
|
||||
fn org_client(&self) -> OrganisationServiceClient<Channel> {
|
||||
OrganisationServiceClient::new(self.channel.clone())
|
||||
}
|
||||
|
||||
fn release_client(&self) -> ReleaseServiceClient<Channel> {
|
||||
ReleaseServiceClient::new(self.channel.clone())
|
||||
}
|
||||
|
||||
fn authed_request<T>(access_token: &str, msg: T) -> Result<Request<T>, AuthError> {
|
||||
bearer_request(access_token, msg).map_err(AuthError::Other)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_status(status: tonic::Status) -> AuthError {
|
||||
match status.code() {
|
||||
tonic::Code::Unauthenticated => AuthError::InvalidCredentials,
|
||||
tonic::Code::AlreadyExists => AuthError::AlreadyExists(status.message().into()),
|
||||
tonic::Code::PermissionDenied => AuthError::NotAuthenticated,
|
||||
tonic::Code::Unavailable => AuthError::Unavailable(status.message().into()),
|
||||
_ => AuthError::Other(status.message().into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_user(u: forage_grpc::User) -> User {
|
||||
User {
|
||||
user_id: u.user_id,
|
||||
username: u.username,
|
||||
emails: u
|
||||
.emails
|
||||
.into_iter()
|
||||
.map(|e| UserEmail {
|
||||
email: e.email,
|
||||
verified: e.verified,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_token(t: forage_grpc::PersonalAccessToken) -> PersonalAccessToken {
|
||||
PersonalAccessToken {
|
||||
token_id: t.token_id,
|
||||
name: t.name,
|
||||
scopes: t.scopes,
|
||||
created_at: t.created_at.map(|ts| ts.to_string()),
|
||||
last_used: t.last_used.map(|ts| ts.to_string()),
|
||||
expires_at: t.expires_at.map(|ts| ts.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ForestAuth for GrpcForestClient {
|
||||
async fn register(
|
||||
&self,
|
||||
username: &str,
|
||||
email: &str,
|
||||
password: &str,
|
||||
) -> Result<AuthTokens, AuthError> {
|
||||
let resp = self
|
||||
.client()
|
||||
.register(forage_grpc::RegisterRequest {
|
||||
username: username.into(),
|
||||
email: email.into(),
|
||||
password: password.into(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_status)?
|
||||
.into_inner();
|
||||
|
||||
let tokens = resp.tokens.ok_or(AuthError::Other("no tokens in response".into()))?;
|
||||
Ok(AuthTokens {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
expires_in_seconds: tokens.expires_in_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
async fn login(&self, identifier: &str, password: &str) -> Result<AuthTokens, AuthError> {
|
||||
let login_identifier = if identifier.contains('@') {
|
||||
forage_grpc::login_request::Identifier::Email(identifier.into())
|
||||
} else {
|
||||
forage_grpc::login_request::Identifier::Username(identifier.into())
|
||||
};
|
||||
|
||||
let resp = self
|
||||
.client()
|
||||
.login(forage_grpc::LoginRequest {
|
||||
identifier: Some(login_identifier),
|
||||
password: password.into(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_status)?
|
||||
.into_inner();
|
||||
|
||||
let tokens = resp.tokens.ok_or(AuthError::Other("no tokens in response".into()))?;
|
||||
Ok(AuthTokens {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
expires_in_seconds: tokens.expires_in_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
async fn refresh_token(&self, refresh_token: &str) -> Result<AuthTokens, AuthError> {
|
||||
let resp = self
|
||||
.client()
|
||||
.refresh_token(forage_grpc::RefreshTokenRequest {
|
||||
refresh_token: refresh_token.into(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_status)?
|
||||
.into_inner();
|
||||
|
||||
let tokens = resp
|
||||
.tokens
|
||||
.ok_or(AuthError::Other("no tokens in response".into()))?;
|
||||
Ok(AuthTokens {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
expires_in_seconds: tokens.expires_in_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
async fn logout(&self, refresh_token: &str) -> Result<(), AuthError> {
|
||||
self.client()
|
||||
.logout(forage_grpc::LogoutRequest {
|
||||
refresh_token: refresh_token.into(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_status)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_user(&self, access_token: &str) -> Result<User, AuthError> {
|
||||
let req = Self::authed_request(
|
||||
access_token,
|
||||
forage_grpc::TokenInfoRequest {},
|
||||
)?;
|
||||
|
||||
let info = self
|
||||
.client()
|
||||
.token_info(req)
|
||||
.await
|
||||
.map_err(map_status)?
|
||||
.into_inner();
|
||||
|
||||
let req = Self::authed_request(
|
||||
access_token,
|
||||
forage_grpc::GetUserRequest {
|
||||
identifier: Some(forage_grpc::get_user_request::Identifier::UserId(
|
||||
info.user_id,
|
||||
)),
|
||||
},
|
||||
)?;
|
||||
|
||||
let resp = self
|
||||
.client()
|
||||
.get_user(req)
|
||||
.await
|
||||
.map_err(map_status)?
|
||||
.into_inner();
|
||||
|
||||
let user = resp.user.ok_or(AuthError::Other("no user in response".into()))?;
|
||||
Ok(convert_user(user))
|
||||
}
|
||||
|
||||
async fn list_tokens(
|
||||
&self,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<PersonalAccessToken>, AuthError> {
|
||||
let req = Self::authed_request(
|
||||
access_token,
|
||||
forage_grpc::ListPersonalAccessTokensRequest {
|
||||
user_id: user_id.into(),
|
||||
},
|
||||
)?;
|
||||
|
||||
let resp = self
|
||||
.client()
|
||||
.list_personal_access_tokens(req)
|
||||
.await
|
||||
.map_err(map_status)?
|
||||
.into_inner();
|
||||
|
||||
Ok(resp.tokens.into_iter().map(convert_token).collect())
|
||||
}
|
||||
|
||||
async fn create_token(
|
||||
&self,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
) -> Result<CreatedToken, AuthError> {
|
||||
let req = Self::authed_request(
|
||||
access_token,
|
||||
forage_grpc::CreatePersonalAccessTokenRequest {
|
||||
user_id: user_id.into(),
|
||||
name: name.into(),
|
||||
scopes: vec![],
|
||||
expires_in_seconds: 0,
|
||||
},
|
||||
)?;
|
||||
|
||||
let resp = self
|
||||
.client()
|
||||
.create_personal_access_token(req)
|
||||
.await
|
||||
.map_err(map_status)?
|
||||
.into_inner();
|
||||
|
||||
let token = resp
|
||||
.token
|
||||
.ok_or(AuthError::Other("no token in response".into()))?;
|
||||
Ok(CreatedToken {
|
||||
token: convert_token(token),
|
||||
raw_token: resp.raw_token,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_token(
|
||||
&self,
|
||||
access_token: &str,
|
||||
token_id: &str,
|
||||
) -> Result<(), AuthError> {
|
||||
let req = Self::authed_request(
|
||||
access_token,
|
||||
forage_grpc::DeletePersonalAccessTokenRequest {
|
||||
token_id: token_id.into(),
|
||||
},
|
||||
)?;
|
||||
|
||||
self.client()
|
||||
.delete_personal_access_token(req)
|
||||
.await
|
||||
.map_err(map_status)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_organisations(
|
||||
organisations: Vec<forage_grpc::Organisation>,
|
||||
roles: Vec<String>,
|
||||
) -> Vec<Organisation> {
|
||||
organisations
|
||||
.into_iter()
|
||||
.zip(roles)
|
||||
.map(|(org, role)| Organisation {
|
||||
organisation_id: org.organisation_id,
|
||||
name: org.name,
|
||||
role,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn convert_artifact(a: forage_grpc::Artifact) -> Artifact {
|
||||
let ctx = a.context.unwrap_or_default();
|
||||
Artifact {
|
||||
artifact_id: a.artifact_id,
|
||||
slug: a.slug,
|
||||
context: ArtifactContext {
|
||||
title: ctx.title,
|
||||
description: if ctx.description.as_deref() == Some("") {
|
||||
None
|
||||
} else {
|
||||
ctx.description
|
||||
},
|
||||
},
|
||||
created_at: a.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_platform_status(status: tonic::Status) -> PlatformError {
|
||||
match status.code() {
|
||||
tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => {
|
||||
PlatformError::NotAuthenticated
|
||||
}
|
||||
tonic::Code::NotFound => PlatformError::NotFound(status.message().into()),
|
||||
tonic::Code::Unavailable => PlatformError::Unavailable(status.message().into()),
|
||||
_ => PlatformError::Other(status.message().into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_authed_request<T>(access_token: &str, msg: T) -> Result<Request<T>, PlatformError> {
|
||||
bearer_request(access_token, msg).map_err(PlatformError::Other)
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ForestPlatform for GrpcForestClient {
|
||||
async fn list_my_organisations(
|
||||
&self,
|
||||
access_token: &str,
|
||||
) -> Result<Vec<Organisation>, PlatformError> {
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::ListMyOrganisationsRequest { role: String::new() },
|
||||
)?;
|
||||
|
||||
let resp = self
|
||||
.org_client()
|
||||
.list_my_organisations(req)
|
||||
.await
|
||||
.map_err(map_platform_status)?
|
||||
.into_inner();
|
||||
|
||||
Ok(convert_organisations(resp.organisations, resp.roles))
|
||||
}
|
||||
|
||||
async fn list_projects(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation: &str,
|
||||
) -> Result<Vec<String>, PlatformError> {
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::GetProjectsRequest {
|
||||
query: Some(forage_grpc::get_projects_request::Query::Organisation(
|
||||
forage_grpc::OrganisationRef {
|
||||
organisation: organisation.into(),
|
||||
},
|
||||
)),
|
||||
},
|
||||
)?;
|
||||
|
||||
let resp = self
|
||||
.release_client()
|
||||
.get_projects(req)
|
||||
.await
|
||||
.map_err(map_platform_status)?
|
||||
.into_inner();
|
||||
|
||||
Ok(resp.projects)
|
||||
}
|
||||
|
||||
async fn list_artifacts(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation: &str,
|
||||
project: &str,
|
||||
) -> Result<Vec<Artifact>, PlatformError> {
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::GetArtifactsByProjectRequest {
|
||||
project: Some(forage_grpc::Project {
|
||||
organisation: organisation.into(),
|
||||
project: project.into(),
|
||||
}),
|
||||
},
|
||||
)?;
|
||||
|
||||
let resp = self
|
||||
.release_client()
|
||||
.get_artifacts_by_project(req)
|
||||
.await
|
||||
.map_err(map_platform_status)?
|
||||
.into_inner();
|
||||
|
||||
Ok(resp.artifact.into_iter().map(convert_artifact).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_org(id: &str, name: &str) -> forage_grpc::Organisation {
|
||||
forage_grpc::Organisation {
|
||||
organisation_id: id.into(),
|
||||
name: name.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_artifact(slug: &str, ctx: Option<forage_grpc::ArtifactContext>) -> forage_grpc::Artifact {
|
||||
forage_grpc::Artifact {
|
||||
artifact_id: "a1".into(),
|
||||
slug: slug.into(),
|
||||
context: ctx,
|
||||
created_at: "2026-01-01".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_organisations_pairs_orgs_with_roles() {
|
||||
let orgs = vec![make_org("o1", "alpha"), make_org("o2", "beta")];
|
||||
let roles = vec!["owner".into(), "member".into()];
|
||||
|
||||
let result = convert_organisations(orgs, roles);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0].name, "alpha");
|
||||
assert_eq!(result[0].role, "owner");
|
||||
assert_eq!(result[1].name, "beta");
|
||||
assert_eq!(result[1].role, "member");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_organisations_truncates_when_roles_shorter() {
|
||||
let orgs = vec![make_org("o1", "alpha"), make_org("o2", "beta")];
|
||||
let roles = vec!["owner".into()]; // only 1 role for 2 orgs
|
||||
|
||||
let result = convert_organisations(orgs, roles);
|
||||
// zip truncates to shorter iterator
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].name, "alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_organisations_empty() {
|
||||
let result = convert_organisations(vec![], vec![]);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_artifact_with_full_context() {
|
||||
let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext {
|
||||
title: "My API".into(),
|
||||
description: Some("A cool API".into()),
|
||||
..Default::default()
|
||||
}));
|
||||
|
||||
let result = convert_artifact(a);
|
||||
assert_eq!(result.slug, "my-api");
|
||||
assert_eq!(result.context.title, "My API");
|
||||
assert_eq!(result.context.description.as_deref(), Some("A cool API"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_artifact_empty_description_becomes_none() {
|
||||
let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext {
|
||||
title: "My API".into(),
|
||||
description: Some(String::new()),
|
||||
..Default::default()
|
||||
}));
|
||||
|
||||
let result = convert_artifact(a);
|
||||
assert!(result.context.description.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_artifact_missing_context_uses_defaults() {
|
||||
let a = make_artifact("my-api", None);
|
||||
|
||||
let result = convert_artifact(a);
|
||||
assert_eq!(result.context.title, "");
|
||||
assert!(result.context.description.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_artifact_none_description_stays_none() {
|
||||
let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext {
|
||||
title: "My API".into(),
|
||||
description: None,
|
||||
..Default::default()
|
||||
}));
|
||||
|
||||
let result = convert_artifact(a);
|
||||
assert!(result.context.description.is_none());
|
||||
}
|
||||
}
|
||||
1339
crates/forage-server/src/main.rs
Normal file
1339
crates/forage-server/src/main.rs
Normal file
File diff suppressed because it is too large
Load Diff
500
crates/forage-server/src/routes/auth.rs
Normal file
500
crates/forage-server/src/routes/auth.rs
Normal file
@@ -0,0 +1,500 @@
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Form, Router};
|
||||
use chrono::Utc;
|
||||
use minijinja::context;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::error_page;
|
||||
use crate::auth::{self, MaybeSession, Session};
|
||||
use crate::state::AppState;
|
||||
use forage_core::auth::{validate_email, validate_password, validate_username};
|
||||
use forage_core::session::{CachedOrg, CachedUser, SessionData, generate_csrf_token};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/signup", get(signup_page).post(signup_submit))
|
||||
.route("/login", get(login_page).post(login_submit))
|
||||
.route("/logout", post(logout_submit))
|
||||
.route("/dashboard", get(dashboard))
|
||||
.route(
|
||||
"/settings/tokens",
|
||||
get(tokens_page).post(create_token_submit),
|
||||
)
|
||||
.route("/settings/tokens/{id}/delete", post(delete_token_submit))
|
||||
}
|
||||
|
||||
// ─── Signup ─────────────────────────────────────────────────────────
|
||||
|
||||
async fn signup_page(
|
||||
State(state): State<AppState>,
|
||||
maybe: MaybeSession,
|
||||
) -> Result<Response, axum::http::StatusCode> {
|
||||
if maybe.session.is_some() {
|
||||
return Ok(Redirect::to("/dashboard").into_response());
|
||||
}
|
||||
|
||||
render_signup(&state, "", "", "", None)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SignupForm {
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
password_confirm: String,
|
||||
}
|
||||
|
||||
async fn signup_submit(
|
||||
State(state): State<AppState>,
|
||||
maybe: MaybeSession,
|
||||
Form(form): Form<SignupForm>,
|
||||
) -> Result<Response, axum::http::StatusCode> {
|
||||
if maybe.session.is_some() {
|
||||
return Ok(Redirect::to("/dashboard").into_response());
|
||||
}
|
||||
|
||||
// Validate
|
||||
if let Err(e) = validate_username(&form.username) {
|
||||
return render_signup(&state, &form.username, &form.email, "", Some(e.0));
|
||||
}
|
||||
if let Err(e) = validate_email(&form.email) {
|
||||
return render_signup(&state, &form.username, &form.email, "", Some(e.0));
|
||||
}
|
||||
if let Err(e) = validate_password(&form.password) {
|
||||
return render_signup(&state, &form.username, &form.email, "", Some(e.0));
|
||||
}
|
||||
if form.password != form.password_confirm {
|
||||
return render_signup(
|
||||
&state,
|
||||
&form.username,
|
||||
&form.email,
|
||||
"",
|
||||
Some("Passwords do not match".into()),
|
||||
);
|
||||
}
|
||||
|
||||
// Register via forest-server
|
||||
match state
|
||||
.forest_client
|
||||
.register(&form.username, &form.email, &form.password)
|
||||
.await
|
||||
{
|
||||
Ok(tokens) => {
|
||||
// Fetch user info for the session cache
|
||||
let mut user_cache = state
|
||||
.forest_client
|
||||
.get_user(&tokens.access_token)
|
||||
.await
|
||||
.ok()
|
||||
.map(|u| CachedUser {
|
||||
user_id: u.user_id,
|
||||
username: u.username,
|
||||
emails: u.emails,
|
||||
orgs: vec![],
|
||||
});
|
||||
|
||||
// Cache org memberships in the session
|
||||
if let Some(ref mut user) = user_cache
|
||||
&& let Ok(orgs) = state
|
||||
.platform_client
|
||||
.list_my_organisations(&tokens.access_token)
|
||||
.await
|
||||
{
|
||||
user.orgs = orgs
|
||||
.into_iter()
|
||||
.map(|o| CachedOrg {
|
||||
name: o.name,
|
||||
role: o.role,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let session_data = SessionData {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
access_expires_at: now + chrono::Duration::seconds(auth::cap_token_expiry(tokens.expires_in_seconds)),
|
||||
user: user_cache,
|
||||
csrf_token: generate_csrf_token(),
|
||||
created_at: now,
|
||||
last_seen_at: now,
|
||||
};
|
||||
|
||||
match state.sessions.create(session_data).await {
|
||||
Ok(session_id) => {
|
||||
let cookie = auth::session_cookie(&session_id);
|
||||
Ok((cookie, Redirect::to("/dashboard")).into_response())
|
||||
}
|
||||
Err(_) => render_signup(
|
||||
&state,
|
||||
&form.username,
|
||||
&form.email,
|
||||
"",
|
||||
Some("Internal error. Please try again.".into()),
|
||||
),
|
||||
}
|
||||
}
|
||||
Err(forage_core::auth::AuthError::AlreadyExists(_)) => render_signup(
|
||||
&state,
|
||||
&form.username,
|
||||
&form.email,
|
||||
"",
|
||||
Some("Username or email already registered".into()),
|
||||
),
|
||||
Err(forage_core::auth::AuthError::Unavailable(msg)) => {
|
||||
tracing::error!("forest-server unavailable: {msg}");
|
||||
render_signup(
|
||||
&state,
|
||||
&form.username,
|
||||
&form.email,
|
||||
"",
|
||||
Some("Service temporarily unavailable. Please try again.".into()),
|
||||
)
|
||||
}
|
||||
Err(e) => render_signup(
|
||||
&state,
|
||||
&form.username,
|
||||
&form.email,
|
||||
"",
|
||||
Some(e.to_string()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_signup(
|
||||
state: &AppState,
|
||||
username: &str,
|
||||
email: &str,
|
||||
_password: &str,
|
||||
error: Option<String>,
|
||||
) -> Result<Response, axum::http::StatusCode> {
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/signup.html.jinja",
|
||||
context! {
|
||||
title => "Sign Up - Forage",
|
||||
description => "Create your Forage account",
|
||||
username => username,
|
||||
email => email,
|
||||
error => error,
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
// ─── Login ──────────────────────────────────────────────────────────
|
||||
|
||||
async fn login_page(
|
||||
State(state): State<AppState>,
|
||||
maybe: MaybeSession,
|
||||
) -> Result<Response, axum::http::StatusCode> {
|
||||
if maybe.session.is_some() {
|
||||
return Ok(Redirect::to("/dashboard").into_response());
|
||||
}
|
||||
|
||||
render_login(&state, "", None)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginForm {
|
||||
identifier: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
async fn login_submit(
|
||||
State(state): State<AppState>,
|
||||
maybe: MaybeSession,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> Result<Response, axum::http::StatusCode> {
|
||||
if maybe.session.is_some() {
|
||||
return Ok(Redirect::to("/dashboard").into_response());
|
||||
}
|
||||
|
||||
if form.identifier.is_empty() || form.password.is_empty() {
|
||||
return render_login(
|
||||
&state,
|
||||
&form.identifier,
|
||||
Some("Email/username and password are required".into()),
|
||||
);
|
||||
}
|
||||
|
||||
match state
|
||||
.forest_client
|
||||
.login(&form.identifier, &form.password)
|
||||
.await
|
||||
{
|
||||
Ok(tokens) => {
|
||||
let mut user_cache = state
|
||||
.forest_client
|
||||
.get_user(&tokens.access_token)
|
||||
.await
|
||||
.ok()
|
||||
.map(|u| CachedUser {
|
||||
user_id: u.user_id,
|
||||
username: u.username,
|
||||
emails: u.emails,
|
||||
orgs: vec![],
|
||||
});
|
||||
|
||||
// Cache org memberships in the session
|
||||
if let Some(ref mut user) = user_cache
|
||||
&& let Ok(orgs) = state
|
||||
.platform_client
|
||||
.list_my_organisations(&tokens.access_token)
|
||||
.await
|
||||
{
|
||||
user.orgs = orgs
|
||||
.into_iter()
|
||||
.map(|o| CachedOrg {
|
||||
name: o.name,
|
||||
role: o.role,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let session_data = SessionData {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
access_expires_at: now + chrono::Duration::seconds(auth::cap_token_expiry(tokens.expires_in_seconds)),
|
||||
user: user_cache,
|
||||
csrf_token: generate_csrf_token(),
|
||||
created_at: now,
|
||||
last_seen_at: now,
|
||||
};
|
||||
|
||||
match state.sessions.create(session_data).await {
|
||||
Ok(session_id) => {
|
||||
let cookie = auth::session_cookie(&session_id);
|
||||
Ok((cookie, Redirect::to("/dashboard")).into_response())
|
||||
}
|
||||
Err(_) => render_login(
|
||||
&state,
|
||||
&form.identifier,
|
||||
Some("Internal error. Please try again.".into()),
|
||||
),
|
||||
}
|
||||
}
|
||||
Err(forage_core::auth::AuthError::InvalidCredentials) => render_login(
|
||||
&state,
|
||||
&form.identifier,
|
||||
Some("Invalid email/username or password".into()),
|
||||
),
|
||||
Err(forage_core::auth::AuthError::Unavailable(msg)) => {
|
||||
tracing::error!("forest-server unavailable: {msg}");
|
||||
render_login(
|
||||
&state,
|
||||
&form.identifier,
|
||||
Some("Service temporarily unavailable. Please try again.".into()),
|
||||
)
|
||||
}
|
||||
Err(e) => render_login(&state, &form.identifier, Some(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_login(
|
||||
state: &AppState,
|
||||
identifier: &str,
|
||||
error: Option<String>,
|
||||
) -> Result<Response, axum::http::StatusCode> {
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/login.html.jinja",
|
||||
context! {
|
||||
title => "Sign In - Forage",
|
||||
description => "Sign in to your Forage account",
|
||||
identifier => identifier,
|
||||
error => error,
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
// ─── Logout ─────────────────────────────────────────────────────────
|
||||
|
||||
async fn logout_submit(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Form(form): Form<CsrfForm>,
|
||||
) -> Result<impl IntoResponse, Response> {
|
||||
if !auth::validate_csrf(&session, &form._csrf) {
|
||||
return Err(error_page(&state, StatusCode::FORBIDDEN, "Invalid request", "CSRF validation failed. Please try again."));
|
||||
}
|
||||
// Best-effort logout on forest-server
|
||||
if let Ok(Some(data)) = state.sessions.get(&session.session_id).await {
|
||||
let _ = state.forest_client.logout(&data.refresh_token).await;
|
||||
}
|
||||
let _ = state.sessions.delete(&session.session_id).await;
|
||||
Ok((auth::clear_session_cookie(), Redirect::to("/")))
|
||||
}
|
||||
|
||||
// ─── Dashboard ──────────────────────────────────────────────────────
|
||||
|
||||
async fn dashboard(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
) -> Result<Response, Response> {
|
||||
// Use cached org memberships from the session
|
||||
let orgs = &session.user.orgs;
|
||||
|
||||
if let Some(first_org) = orgs.first() {
|
||||
return Ok(Redirect::to(&format!("/orgs/{}/projects", first_org.name)).into_response());
|
||||
}
|
||||
|
||||
// No orgs: show onboarding
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/onboarding.html.jinja",
|
||||
context! {
|
||||
title => "Get Started - Forage",
|
||||
description => "Create your first organisation",
|
||||
user => context! { username => session.user.username },
|
||||
csrf_token => &session.csrf_token,
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
|
||||
})?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
// ─── Tokens ─────────────────────────────────────────────────────────
|
||||
|
||||
async fn tokens_page(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
) -> Result<Response, Response> {
|
||||
let tokens = state
|
||||
.forest_client
|
||||
.list_tokens(&session.access_token, &session.user.user_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/tokens.html.jinja",
|
||||
context! {
|
||||
title => "API Tokens - Forage",
|
||||
description => "Manage your personal access tokens",
|
||||
user => context! { username => session.user.username },
|
||||
tokens => tokens.iter().map(|t| context! {
|
||||
token_id => t.token_id,
|
||||
name => t.name,
|
||||
created_at => t.created_at,
|
||||
last_used => t.last_used,
|
||||
expires_at => t.expires_at,
|
||||
}).collect::<Vec<_>>(),
|
||||
csrf_token => &session.csrf_token,
|
||||
created_token => None::<String>,
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
|
||||
})?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CsrfForm {
|
||||
_csrf: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateTokenForm {
|
||||
name: String,
|
||||
_csrf: String,
|
||||
}
|
||||
|
||||
async fn create_token_submit(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Form(form): Form<CreateTokenForm>,
|
||||
) -> Result<Response, Response> {
|
||||
if !auth::validate_csrf(&session, &form._csrf) {
|
||||
return Err(error_page(&state, StatusCode::FORBIDDEN, "Invalid request", "CSRF validation failed. Please try again."));
|
||||
}
|
||||
|
||||
let created = state
|
||||
.forest_client
|
||||
.create_token(&session.access_token, &session.user.user_id, &form.name)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("failed to create token: {e}");
|
||||
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
|
||||
})?;
|
||||
|
||||
let tokens = state
|
||||
.forest_client
|
||||
.list_tokens(&session.access_token, &session.user.user_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/tokens.html.jinja",
|
||||
context! {
|
||||
title => "API Tokens - Forage",
|
||||
description => "Manage your personal access tokens",
|
||||
user => context! { username => session.user.username },
|
||||
tokens => tokens.iter().map(|t| context! {
|
||||
token_id => t.token_id,
|
||||
name => t.name,
|
||||
created_at => t.created_at,
|
||||
last_used => t.last_used,
|
||||
expires_at => t.expires_at,
|
||||
}).collect::<Vec<_>>(),
|
||||
csrf_token => &session.csrf_token,
|
||||
created_token => Some(created.raw_token),
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
|
||||
})?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
async fn delete_token_submit(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
axum::extract::Path(token_id): axum::extract::Path<String>,
|
||||
Form(form): Form<CsrfForm>,
|
||||
) -> Result<Response, Response> {
|
||||
if !auth::validate_csrf(&session, &form._csrf) {
|
||||
return Err(error_page(&state, StatusCode::FORBIDDEN, "Invalid request", "CSRF validation failed. Please try again."));
|
||||
}
|
||||
|
||||
state
|
||||
.forest_client
|
||||
.delete_token(&session.access_token, &token_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("failed to delete token: {e}");
|
||||
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
|
||||
})?;
|
||||
|
||||
Ok(Redirect::to("/settings/tokens").into_response())
|
||||
}
|
||||
35
crates/forage-server/src/routes/mod.rs
Normal file
35
crates/forage-server/src/routes/mod.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
mod auth;
|
||||
mod pages;
|
||||
mod platform;
|
||||
|
||||
use axum::Router;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use minijinja::context;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.merge(pages::router())
|
||||
.merge(auth::router())
|
||||
.merge(platform::router())
|
||||
}
|
||||
|
||||
/// Render an error page with the given status code, heading, and message.
|
||||
fn error_page(state: &AppState, status: StatusCode, heading: &str, message: &str) -> Response {
|
||||
let html = state.templates.render(
|
||||
"pages/error.html.jinja",
|
||||
context! {
|
||||
title => format!("{} - Forage", heading),
|
||||
description => message,
|
||||
status => status.as_u16(),
|
||||
heading => heading,
|
||||
message => message,
|
||||
},
|
||||
);
|
||||
match html {
|
||||
Ok(body) => (status, Html(body)).into_response(),
|
||||
Err(_) => status.into_response(),
|
||||
}
|
||||
}
|
||||
59
crates/forage-server/src/routes/pages.rs
Normal file
59
crates/forage-server/src/routes/pages.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use axum::extract::State;
|
||||
use axum::response::Html;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use minijinja::context;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(landing))
|
||||
.route("/pricing", get(pricing))
|
||||
.route("/components", get(components))
|
||||
}
|
||||
|
||||
async fn landing(State(state): State<AppState>) -> Result<Html<String>, axum::http::StatusCode> {
|
||||
let html = state
|
||||
.templates
|
||||
.render("pages/landing.html.jinja", context! {
|
||||
title => "Forage - The Platform for Forest",
|
||||
description => "Push a forest.cue manifest, get production infrastructure.",
|
||||
})
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Html(html))
|
||||
}
|
||||
|
||||
async fn pricing(State(state): State<AppState>) -> Result<Html<String>, axum::http::StatusCode> {
|
||||
let html = state
|
||||
.templates
|
||||
.render("pages/pricing.html.jinja", context! {
|
||||
title => "Pricing - Forage",
|
||||
description => "Simple, transparent pricing. Pay only for what you use.",
|
||||
})
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Html(html))
|
||||
}
|
||||
|
||||
async fn components(State(state): State<AppState>) -> Result<Html<String>, axum::http::StatusCode> {
|
||||
let html = state
|
||||
.templates
|
||||
.render("pages/components.html.jinja", context! {
|
||||
title => "Components - Forage",
|
||||
description => "Discover and share reusable forest components.",
|
||||
})
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Html(html))
|
||||
}
|
||||
166
crates/forage-server/src/routes/platform.rs
Normal file
166
crates/forage-server/src/routes/platform.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use forage_core::platform::validate_slug;
|
||||
use forage_core::session::CachedOrg;
|
||||
use minijinja::context;
|
||||
|
||||
use super::error_page;
|
||||
use crate::auth::Session;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/orgs/{org}/projects", get(projects_list))
|
||||
.route("/orgs/{org}/projects/{project}", get(project_detail))
|
||||
.route("/orgs/{org}/usage", get(usage))
|
||||
}
|
||||
|
||||
fn orgs_context(orgs: &[CachedOrg]) -> Vec<minijinja::Value> {
|
||||
orgs.iter()
|
||||
.map(|o| context! { name => o.name, role => o.role })
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn projects_list(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Path(org): Path<String>,
|
||||
) -> Result<Response, Response> {
|
||||
if !validate_slug(&org) {
|
||||
return Err(error_page(&state, StatusCode::BAD_REQUEST, "Invalid request", "Invalid organisation name."));
|
||||
}
|
||||
|
||||
let orgs = &session.user.orgs;
|
||||
|
||||
if !orgs.iter().any(|o| o.name == org) {
|
||||
return Err(error_page(&state, StatusCode::FORBIDDEN, "Access denied", "You don't have access to this organisation."));
|
||||
}
|
||||
|
||||
let projects = state
|
||||
.platform_client
|
||||
.list_projects(&session.access_token, &org)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/projects.html.jinja",
|
||||
context! {
|
||||
title => format!("{org} - Projects - Forage"),
|
||||
description => format!("Projects in {org}"),
|
||||
user => context! { username => session.user.username },
|
||||
csrf_token => &session.csrf_token,
|
||||
current_org => &org,
|
||||
orgs => orgs_context(orgs),
|
||||
org_name => &org,
|
||||
projects => projects,
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
|
||||
})?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
async fn project_detail(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Path((org, project)): Path<(String, String)>,
|
||||
) -> Result<Response, Response> {
|
||||
if !validate_slug(&org) || !validate_slug(&project) {
|
||||
return Err(error_page(&state, StatusCode::BAD_REQUEST, "Invalid request", "Invalid organisation or project name."));
|
||||
}
|
||||
|
||||
let orgs = &session.user.orgs;
|
||||
|
||||
if !orgs.iter().any(|o| o.name == org) {
|
||||
return Err(error_page(&state, StatusCode::FORBIDDEN, "Access denied", "You don't have access to this organisation."));
|
||||
}
|
||||
|
||||
let artifacts = state
|
||||
.platform_client
|
||||
.list_artifacts(&session.access_token, &org, &project)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/project_detail.html.jinja",
|
||||
context! {
|
||||
title => format!("{project} - {org} - Forage"),
|
||||
description => format!("Project {project} in {org}"),
|
||||
user => context! { username => session.user.username },
|
||||
csrf_token => &session.csrf_token,
|
||||
current_org => &org,
|
||||
orgs => orgs_context(orgs),
|
||||
org_name => &org,
|
||||
project_name => &project,
|
||||
artifacts => artifacts.iter().map(|a| context! {
|
||||
slug => a.slug,
|
||||
title => a.context.title,
|
||||
description => a.context.description,
|
||||
created_at => a.created_at,
|
||||
}).collect::<Vec<_>>(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
|
||||
})?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
async fn usage(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Path(org): Path<String>,
|
||||
) -> Result<Response, Response> {
|
||||
if !validate_slug(&org) {
|
||||
return Err(error_page(&state, StatusCode::BAD_REQUEST, "Invalid request", "Invalid organisation name."));
|
||||
}
|
||||
|
||||
let orgs = &session.user.orgs;
|
||||
|
||||
let current_org_data = orgs.iter().find(|o| o.name == org);
|
||||
let current_org_data = match current_org_data {
|
||||
Some(o) => o,
|
||||
None => return Err(error_page(&state, StatusCode::FORBIDDEN, "Access denied", "You don't have access to this organisation.")),
|
||||
};
|
||||
|
||||
let projects = state
|
||||
.platform_client
|
||||
.list_projects(&session.access_token, &org)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/usage.html.jinja",
|
||||
context! {
|
||||
title => format!("Usage - {org} - Forage"),
|
||||
description => format!("Usage and plan for {org}"),
|
||||
user => context! { username => session.user.username },
|
||||
csrf_token => &session.csrf_token,
|
||||
current_org => &org,
|
||||
orgs => orgs_context(orgs),
|
||||
org_name => &org,
|
||||
role => ¤t_org_data.role,
|
||||
project_count => projects.len(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
|
||||
})?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
30
crates/forage-server/src/state.rs
Normal file
30
crates/forage-server/src/state.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::templates::TemplateEngine;
|
||||
use forage_core::auth::ForestAuth;
|
||||
use forage_core::platform::ForestPlatform;
|
||||
use forage_core::session::SessionStore;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub templates: TemplateEngine,
|
||||
pub forest_client: Arc<dyn ForestAuth>,
|
||||
pub platform_client: Arc<dyn ForestPlatform>,
|
||||
pub sessions: Arc<dyn SessionStore>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(
|
||||
templates: TemplateEngine,
|
||||
forest_client: Arc<dyn ForestAuth>,
|
||||
platform_client: Arc<dyn ForestPlatform>,
|
||||
sessions: Arc<dyn SessionStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
templates,
|
||||
forest_client,
|
||||
platform_client,
|
||||
sessions,
|
||||
}
|
||||
}
|
||||
}
|
||||
35
crates/forage-server/src/templates.rs
Normal file
35
crates/forage-server/src/templates.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Context;
|
||||
use minijinja::Environment;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TemplateEngine {
|
||||
env: Environment<'static>,
|
||||
}
|
||||
|
||||
impl TemplateEngine {
|
||||
pub fn from_path(path: &Path) -> anyhow::Result<Self> {
|
||||
if !path.exists() {
|
||||
anyhow::bail!("templates directory not found: {}", path.display());
|
||||
}
|
||||
|
||||
let mut env = Environment::new();
|
||||
env.set_loader(minijinja::path_loader(path));
|
||||
|
||||
Ok(Self { env })
|
||||
}
|
||||
|
||||
pub fn new() -> anyhow::Result<Self> {
|
||||
Self::from_path(Path::new("templates"))
|
||||
}
|
||||
|
||||
pub fn render(&self, template: &str, ctx: minijinja::Value) -> anyhow::Result<String> {
|
||||
let tmpl = self
|
||||
.env
|
||||
.get_template(template)
|
||||
.with_context(|| format!("template not found: {template}"))?;
|
||||
tmpl.render(ctx)
|
||||
.with_context(|| format!("failed to render template: {template}"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user