@@ -0,0 +1,41 @@
|
|||||||
|
# Forage Client - Project Memory
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
- Forage is a server-side rendered frontend for forest-server
|
||||||
|
- All auth/user/org management via gRPC to forest-server's UsersService
|
||||||
|
- No local user database - forest-server owns all auth state
|
||||||
|
- Follows VSDD methodology
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- Rust workspace with 5 crates: forage-server, forage-core, forage-db, forage-grpc, ci
|
||||||
|
- forage-grpc: generated proto stubs from forest's users.proto (buf generate)
|
||||||
|
- forage-core: ForestAuth trait (async_trait, object-safe), validation, types
|
||||||
|
- forage-server: axum routes, gRPC client impl, cookie-based session
|
||||||
|
- MiniJinja templates, Tailwind CSS
|
||||||
|
- Forest + Mise for task running
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
- `ForestAuth` trait uses `#[async_trait]` for object safety -> `Arc<dyn ForestAuth>`
|
||||||
|
- `GrpcForestClient` in forage-server implements ForestAuth via tonic
|
||||||
|
- `MockForestClient` in tests implements ForestAuth for testing without gRPC
|
||||||
|
- Auth via HTTP-only cookies: `forage_access` + `forage_refresh`
|
||||||
|
- `RequireAuth` extractor redirects to /login, `MaybeAuth` is optional
|
||||||
|
- Templates at workspace root, resolved via `CARGO_MANIFEST_DIR` in tests
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- tonic 0.14 + tonic-prost 0.14 + prost 0.14 (must match for generated code)
|
||||||
|
- axum-extra with cookie feature for cookie management
|
||||||
|
- async-trait for object-safe async traits
|
||||||
|
- buf for proto generation (users.proto from forest)
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
- Dagger-based CI in ci/ crate: `ci pr` and `ci main`
|
||||||
|
- `mise run ci:pr` / `mise run ci:main`
|
||||||
|
- Docker builds with distroless runtime
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
- 20 tests passing (6 validation + 14 integration)
|
||||||
|
- Spec 001 (landing page): complete
|
||||||
|
- Spec 002 (authentication): Phase 2 complete
|
||||||
|
- Routes: /, /pricing, /signup, /login, /logout, /dashboard, /settings/tokens
|
||||||
|
- FOREST_SERVER_URL env var configures gRPC endpoint (default localhost:4040)
|
||||||
8
.env.example
Normal file
8
.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Forest server gRPC endpoint
|
||||||
|
FOREST_SERVER_URL=http://localhost:4040
|
||||||
|
|
||||||
|
# HTTP port (default: 3000)
|
||||||
|
# PORT=3001
|
||||||
|
|
||||||
|
# PostgreSQL connection (optional - omit for in-memory sessions)
|
||||||
|
# DATABASE_URL=postgresql://forageuser:foragepassword@localhost:5432/forage
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/target
|
||||||
|
.env
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
92
CLAUDE.md
Normal file
92
CLAUDE.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Forage Client - AI Development Guide
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Forage is the managed platform and registry for [Forest](https://src.rawpotion.io/rawpotion/forest) - an infrastructure-as-code tool that lets organisations codify their development workflows, CI, deployments, and component sharing. Forage extends forest by providing:
|
||||||
|
|
||||||
|
- **Component Registry**: Host and distribute forest components
|
||||||
|
- **Managed Deployments**: Push a `forest.cue` manifest and get automatic deployment (Heroku-like experience)
|
||||||
|
- **Container Runtimes**: Pay-as-you-go alternative to Kubernetes
|
||||||
|
- **Managed Services**: Databases, user management, observability, and more
|
||||||
|
- **Organisation Management**: Teams, billing, access control
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Language**: Rust
|
||||||
|
- **Web Framework**: Axum
|
||||||
|
- **Templating**: MiniJinja (server-side rendered)
|
||||||
|
- **Styling**: Tailwind CSS (via standalone CLI)
|
||||||
|
- **Database**: PostgreSQL (via SQLx, compile-time checked queries)
|
||||||
|
- **Build System**: Forest + Mise for task running
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── CLAUDE.md # This file
|
||||||
|
├── Cargo.toml # Workspace root
|
||||||
|
├── forest.cue # Forest project manifest
|
||||||
|
├── mise.toml # Task runner configuration
|
||||||
|
├── crates/
|
||||||
|
│ ├── forage-server/ # Main axum web server
|
||||||
|
│ │ ├── src/
|
||||||
|
│ │ │ ├── main.rs
|
||||||
|
│ │ │ ├── routes/ # Axum route handlers
|
||||||
|
│ │ │ ├── templates/ # MiniJinja templates
|
||||||
|
│ │ │ └── state.rs # Application state
|
||||||
|
│ │ └── Cargo.toml
|
||||||
|
│ ├── forage-core/ # Business logic, pure functions
|
||||||
|
│ │ ├── src/
|
||||||
|
│ │ │ ├── lib.rs
|
||||||
|
│ │ │ ├── registry/ # Component registry logic
|
||||||
|
│ │ │ ├── deployments/ # Deployment orchestration
|
||||||
|
│ │ │ └── billing/ # Pricing and billing
|
||||||
|
│ │ └── Cargo.toml
|
||||||
|
│ └── forage-db/ # Database layer
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── lib.rs
|
||||||
|
│ │ └── migrations/
|
||||||
|
│ └── Cargo.toml
|
||||||
|
├── templates/ # Shared MiniJinja templates
|
||||||
|
│ ├── base.html.jinja
|
||||||
|
│ ├── pages/
|
||||||
|
│ └── components/
|
||||||
|
├── static/ # Static assets (CSS, JS, images)
|
||||||
|
├── specs/ # VSDD specification documents
|
||||||
|
└── tests/ # Integration tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Methodology: VSDD
|
||||||
|
|
||||||
|
This project follows **Verified Spec-Driven Development (VSDD)**. See `specs/VSDD.md` for the full methodology.
|
||||||
|
|
||||||
|
### Key Rules for AI Development
|
||||||
|
|
||||||
|
Follow the VSDD pipeline **religiously**. No shortcuts, no skipping phases.
|
||||||
|
|
||||||
|
1. **Spec First**: Never implement without a spec in `specs/`. Read the spec before writing code.
|
||||||
|
2. **Test First**: Write failing tests before implementation. No code exists without a test that demanded it. Confirm tests fail (Red) before writing implementation (Green).
|
||||||
|
3. **Pure Core / Effectful Shell**: `forage-core` is the pure, testable core. `forage-server` is the effectful shell. Database access lives in `forage-db`.
|
||||||
|
4. **Minimal Implementation**: Write the minimum code to pass each test. Refactor only after green.
|
||||||
|
5. **Trace Everything**: Every spec requirement maps to tests which map to implementation.
|
||||||
|
6. **Adversarial Review**: After implementation, conduct a thorough adversarial review (Phase 3). Save reviews in `specs/reviews/`.
|
||||||
|
7. **Feedback Loop**: Review findings feed back into specs and tests (Phase 4). Iterate until convergence.
|
||||||
|
8. **Hardening**: Run clippy, cargo-audit, and static analysis (Phase 5). Property-based tests where applicable.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `mise run develop` - Start the dev server
|
||||||
|
- `mise run test` - Run all tests
|
||||||
|
- `mise run db:migrate` - Run database migrations
|
||||||
|
- `mise run build` - Build release binary
|
||||||
|
- `forest run <command>` - Run forest-defined commands
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Use `snake_case` for all Rust identifiers
|
||||||
|
- Prefer `thiserror` for error types in libraries, `anyhow` in binaries
|
||||||
|
- All database queries use SQLx compile-time checking
|
||||||
|
- Templates use MiniJinja with `.html.jinja` extension
|
||||||
|
- Routes are organized by feature in `routes/` modules
|
||||||
|
- All public API endpoints return proper HTTP status codes
|
||||||
|
- Configuration via environment variables with sensible defaults
|
||||||
3887
Cargo.lock
generated
Normal file
3887
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
Cargo.toml
Normal file
32
Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"crates/forage-server",
|
||||||
|
"crates/forage-core",
|
||||||
|
"crates/forage-db",
|
||||||
|
"crates/forage-grpc",
|
||||||
|
"ci",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
axum = { version = "0.8", features = ["macros"] }
|
||||||
|
axum-extra = { version = "0.10", features = ["cookie"] }
|
||||||
|
minijinja = { version = "2", features = ["loader"] }
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "migrate", "uuid", "chrono"] }
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
tower = "0.5"
|
||||||
|
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "compression-gzip"] }
|
||||||
|
tonic = "0.14"
|
||||||
|
prost = "0.14"
|
||||||
|
prost-types = "0.14"
|
||||||
|
tonic-prost = "0.14"
|
||||||
|
async-trait = "0.1"
|
||||||
|
rand = "0.9"
|
||||||
9
buf.gen.yaml
Normal file
9
buf.gen.yaml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
version: v2
|
||||||
|
plugins:
|
||||||
|
- remote: buf.build/community/neoeinstein-prost:v0.5.0
|
||||||
|
out: crates/forage-grpc/src/grpc/
|
||||||
|
- remote: buf.build/community/neoeinstein-tonic:v0.5.0
|
||||||
|
out: crates/forage-grpc/src/grpc/
|
||||||
|
opt:
|
||||||
|
- client_mod_attribute=*=cfg(feature = "client")
|
||||||
|
- server_mod_attribute=*=cfg(feature = "server")
|
||||||
15
ci/Cargo.toml
Normal file
15
ci/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "ci"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ci"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
dagger-sdk = "0.20"
|
||||||
|
eyre = "0.6"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
chrono = "0.4"
|
||||||
325
ci/src/main.rs
Normal file
325
ci/src/main.rs
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
const BIN_NAME: &str = "forage-server";
|
||||||
|
const MOLD_VERSION: &str = "2.40.4";
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "ci")]
|
||||||
|
enum Cli {
|
||||||
|
/// Run PR validation pipeline (check + test + build)
|
||||||
|
Pr,
|
||||||
|
/// Run main branch pipeline (check + test + build + publish)
|
||||||
|
Main,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> eyre::Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
dagger_sdk::connect(|client| async move {
|
||||||
|
match cli {
|
||||||
|
Cli::Pr => run_pr(&client).await?,
|
||||||
|
Cli::Main => run_main(&client).await?,
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_pr(client: &dagger_sdk::Query) -> eyre::Result<()> {
|
||||||
|
eprintln!("==> PR pipeline: check + test + build");
|
||||||
|
|
||||||
|
let base = build_base(client).await?;
|
||||||
|
|
||||||
|
eprintln!("--- cargo check --workspace");
|
||||||
|
base.clone()
|
||||||
|
.with_exec(vec!["cargo", "check", "--workspace"])
|
||||||
|
.sync()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
eprintln!("--- cargo clippy");
|
||||||
|
base.clone()
|
||||||
|
.with_exec(vec![
|
||||||
|
"cargo",
|
||||||
|
"clippy",
|
||||||
|
"--workspace",
|
||||||
|
"--",
|
||||||
|
"-D",
|
||||||
|
"warnings",
|
||||||
|
])
|
||||||
|
.sync()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
eprintln!("--- cargo fmt --check");
|
||||||
|
base.clone()
|
||||||
|
.with_exec(vec!["cargo", "fmt", "--", "--check"])
|
||||||
|
.sync()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
eprintln!("--- running tests");
|
||||||
|
run_tests(&base).await?;
|
||||||
|
|
||||||
|
eprintln!("--- building release image");
|
||||||
|
let _image = build_release_image(client, &base).await?;
|
||||||
|
|
||||||
|
eprintln!("==> PR pipeline complete");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_main(client: &dagger_sdk::Query) -> eyre::Result<()> {
|
||||||
|
eprintln!("==> Main pipeline: check + test + build + publish");
|
||||||
|
|
||||||
|
let base = build_base(client).await?;
|
||||||
|
|
||||||
|
eprintln!("--- cargo check --workspace");
|
||||||
|
base.clone()
|
||||||
|
.with_exec(vec!["cargo", "check", "--workspace"])
|
||||||
|
.sync()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
eprintln!("--- running tests");
|
||||||
|
run_tests(&base).await?;
|
||||||
|
|
||||||
|
eprintln!("--- building release image");
|
||||||
|
let image = build_release_image(client, &base).await?;
|
||||||
|
|
||||||
|
eprintln!("--- publishing image");
|
||||||
|
publish_image(client, &image).await?;
|
||||||
|
|
||||||
|
eprintln!("==> Main pipeline complete");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load only Rust-relevant source files from host.
|
||||||
|
/// Using include patterns prevents cache busting from unrelated file changes.
|
||||||
|
fn load_source(client: &dagger_sdk::Query) -> eyre::Result<dagger_sdk::Directory> {
|
||||||
|
let src = client.host().directory_opts(
|
||||||
|
".",
|
||||||
|
dagger_sdk::HostDirectoryOptsBuilder::default()
|
||||||
|
.include(vec![
|
||||||
|
"**/*.rs",
|
||||||
|
"**/Cargo.toml",
|
||||||
|
"Cargo.lock",
|
||||||
|
".sqlx/**",
|
||||||
|
"**/*.sql",
|
||||||
|
"**/*.toml",
|
||||||
|
"templates/**",
|
||||||
|
"static/**",
|
||||||
|
])
|
||||||
|
.build()?,
|
||||||
|
);
|
||||||
|
Ok(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load dependency-only source (Cargo.toml + Cargo.lock + .sqlx, no .rs or tests).
|
||||||
|
fn load_dep_source(client: &dagger_sdk::Query) -> eyre::Result<dagger_sdk::Directory> {
|
||||||
|
let src = client.host().directory_opts(
|
||||||
|
".",
|
||||||
|
dagger_sdk::HostDirectoryOptsBuilder::default()
|
||||||
|
.include(vec!["**/Cargo.toml", "Cargo.lock", ".sqlx/**"])
|
||||||
|
.build()?,
|
||||||
|
);
|
||||||
|
Ok(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create skeleton source files so cargo can resolve deps without real source.
|
||||||
|
fn create_skeleton_files(client: &dagger_sdk::Query) -> eyre::Result<dagger_sdk::Directory> {
|
||||||
|
let main_content = r#"fn main() { panic!("skeleton"); }"#;
|
||||||
|
let lib_content = r#"pub fn _skeleton() {}"#;
|
||||||
|
|
||||||
|
let crate_paths = discover_crates()?;
|
||||||
|
let mut dir = client.directory();
|
||||||
|
|
||||||
|
for crate_path in &crate_paths {
|
||||||
|
let src_dir = crate_path.join("src");
|
||||||
|
dir = dir.with_new_file(
|
||||||
|
src_dir.join("main.rs").to_string_lossy().to_string(),
|
||||||
|
main_content,
|
||||||
|
);
|
||||||
|
dir = dir.with_new_file(
|
||||||
|
src_dir.join("lib.rs").to_string_lossy().to_string(),
|
||||||
|
lib_content,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also add skeleton for ci/ crate itself.
|
||||||
|
dir = dir.with_new_file("ci/src/main.rs".to_string(), main_content);
|
||||||
|
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover workspace crate directories by finding Cargo.toml files.
|
||||||
|
fn discover_crates() -> eyre::Result<Vec<PathBuf>> {
|
||||||
|
let mut crate_paths = Vec::new();
|
||||||
|
let root = PathBuf::from("crates");
|
||||||
|
if root.is_dir() {
|
||||||
|
find_crates_recursive(&root, &mut crate_paths)?;
|
||||||
|
}
|
||||||
|
Ok(crate_paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_crates_recursive(dir: &PathBuf, out: &mut Vec<PathBuf>) -> eyre::Result<()> {
|
||||||
|
if dir.join("Cargo.toml").exists() {
|
||||||
|
out.push(dir.clone());
|
||||||
|
}
|
||||||
|
for entry in std::fs::read_dir(dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
if entry.file_type()?.is_dir() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
if name == "target" || name == "node_modules" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
find_crates_recursive(&entry.path(), out)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the base Rust container with all deps cached.
|
||||||
|
async fn build_base(client: &dagger_sdk::Query) -> eyre::Result<dagger_sdk::Container> {
|
||||||
|
let src = load_source(client)?;
|
||||||
|
let dep_src = load_dep_source(client)?;
|
||||||
|
let skeleton = create_skeleton_files(client)?;
|
||||||
|
|
||||||
|
let dep_src_with_skeleton = dep_src.with_directory(".", skeleton);
|
||||||
|
|
||||||
|
// Base rust image with build tools.
|
||||||
|
let rust_base = client
|
||||||
|
.container()
|
||||||
|
.from("rust:1.85-bookworm")
|
||||||
|
.with_exec(vec!["apt", "update"])
|
||||||
|
.with_exec(vec!["apt", "install", "-y", "clang", "wget"])
|
||||||
|
// Install mold linker.
|
||||||
|
.with_exec(vec![
|
||||||
|
"wget",
|
||||||
|
"-q",
|
||||||
|
&format!(
|
||||||
|
"https://github.com/rui314/mold/releases/download/v{MOLD_VERSION}/mold-{MOLD_VERSION}-x86_64-linux.tar.gz"
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.with_exec(vec![
|
||||||
|
"tar",
|
||||||
|
"-xf",
|
||||||
|
&format!("mold-{MOLD_VERSION}-x86_64-linux.tar.gz"),
|
||||||
|
])
|
||||||
|
.with_exec(vec![
|
||||||
|
"mv",
|
||||||
|
&format!("mold-{MOLD_VERSION}-x86_64-linux/bin/mold"),
|
||||||
|
"/usr/bin/mold",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Step 1: build deps with skeleton source (cacheable layer).
|
||||||
|
let prebuild = rust_base
|
||||||
|
.clone()
|
||||||
|
.with_workdir("/mnt/src")
|
||||||
|
.with_env_variable("SQLX_OFFLINE", "true")
|
||||||
|
.with_directory("/mnt/src", dep_src_with_skeleton)
|
||||||
|
.with_exec(vec!["cargo", "build", "--release", "--bin", BIN_NAME]);
|
||||||
|
|
||||||
|
// Step 2: copy cargo registry from prebuild (avoids re-downloading deps).
|
||||||
|
let build_container = rust_base
|
||||||
|
.with_workdir("/mnt/src")
|
||||||
|
.with_env_variable("SQLX_OFFLINE", "true")
|
||||||
|
.with_directory("/usr/local/cargo", prebuild.directory("/usr/local/cargo"))
|
||||||
|
.with_directory("/mnt/src/", src);
|
||||||
|
|
||||||
|
Ok(build_container)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run tests.
|
||||||
|
async fn run_tests(base: &dagger_sdk::Container) -> eyre::Result<()> {
|
||||||
|
base.clone()
|
||||||
|
.with_exec(vec!["cargo", "test", "--workspace"])
|
||||||
|
.sync()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build release binary and package into a slim image.
|
||||||
|
async fn build_release_image(
|
||||||
|
client: &dagger_sdk::Query,
|
||||||
|
base: &dagger_sdk::Container,
|
||||||
|
) -> eyre::Result<dagger_sdk::Container> {
|
||||||
|
let built = base
|
||||||
|
.clone()
|
||||||
|
.with_exec(vec!["cargo", "build", "--release", "--bin", BIN_NAME]);
|
||||||
|
|
||||||
|
let binary = built.file(format!("/mnt/src/target/release/{BIN_NAME}"));
|
||||||
|
|
||||||
|
// Load templates and static assets from host for the runtime image.
|
||||||
|
let templates = client.host().directory("templates");
|
||||||
|
let static_assets = client.host().directory("static");
|
||||||
|
|
||||||
|
// Distroless cc-debian12 matches the build image's glibc
|
||||||
|
// and includes libgcc + ca-certificates with no shell or package manager.
|
||||||
|
let final_image = client
|
||||||
|
.container()
|
||||||
|
.from("gcr.io/distroless/cc-debian12")
|
||||||
|
.with_file(format!("/usr/local/bin/{BIN_NAME}"), binary)
|
||||||
|
.with_directory("/templates", templates)
|
||||||
|
.with_directory("/static", static_assets)
|
||||||
|
.with_env_variable("FORAGE_TEMPLATES_PATH", "/templates");
|
||||||
|
|
||||||
|
final_image.sync().await?;
|
||||||
|
|
||||||
|
// Set the final entrypoint for the published image.
|
||||||
|
let final_image = final_image.with_entrypoint(vec![BIN_NAME]);
|
||||||
|
|
||||||
|
eprintln!("--- release image built successfully");
|
||||||
|
Ok(final_image)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish image to container registry with latest, commit, and timestamp tags.
|
||||||
|
async fn publish_image(
|
||||||
|
client: &dagger_sdk::Query,
|
||||||
|
image: &dagger_sdk::Container,
|
||||||
|
) -> eyre::Result<()> {
|
||||||
|
let registry = std::env::var("CI_REGISTRY").unwrap_or_else(|_| "registry.forage.sh".into());
|
||||||
|
let user = std::env::var("CI_REGISTRY_USER").unwrap_or_else(|_| "forage".into());
|
||||||
|
let image_name = std::env::var("CI_IMAGE_NAME")
|
||||||
|
.unwrap_or_else(|_| format!("{registry}/{user}/forage-server"));
|
||||||
|
|
||||||
|
let password = std::env::var("CI_REGISTRY_PASSWORD")
|
||||||
|
.map_err(|_| eyre::eyre!("CI_REGISTRY_PASSWORD must be set for publishing"))?;
|
||||||
|
|
||||||
|
let commit = git_short_hash()?;
|
||||||
|
let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();
|
||||||
|
|
||||||
|
let tags = vec!["latest".to_string(), commit, timestamp];
|
||||||
|
|
||||||
|
let authed = image.clone().with_registry_auth(
|
||||||
|
®istry,
|
||||||
|
&user,
|
||||||
|
client.set_secret("registry-password", &password),
|
||||||
|
);
|
||||||
|
|
||||||
|
for tag in &tags {
|
||||||
|
let image_ref = format!("{image_name}:{tag}");
|
||||||
|
authed
|
||||||
|
.publish_opts(
|
||||||
|
&image_ref,
|
||||||
|
dagger_sdk::ContainerPublishOptsBuilder::default().build()?,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
eprintln!("--- published {image_ref}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the short git commit hash from the host.
|
||||||
|
fn git_short_hash() -> eyre::Result<String> {
|
||||||
|
let output = std::process::Command::new("git")
|
||||||
|
.args(["rev-parse", "--short", "HEAD"])
|
||||||
|
.output()?;
|
||||||
|
let hash = String::from_utf8(output.stdout)?.trim().to_string();
|
||||||
|
if hash.is_empty() {
|
||||||
|
return Err(eyre::eyre!("could not determine git commit hash"));
|
||||||
|
}
|
||||||
|
Ok(hash)
|
||||||
|
}
|
||||||
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}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
18
forest.cue
Normal file
18
forest.cue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
project: {
|
||||||
|
name: "forage-client"
|
||||||
|
organisation: "forage"
|
||||||
|
}
|
||||||
|
|
||||||
|
commands: {
|
||||||
|
dev: ["cargo run -p forage-server"]
|
||||||
|
build: ["cargo build --release -p forage-server"]
|
||||||
|
compile: ["cargo build --release"]
|
||||||
|
test: ["cargo test --workspace"]
|
||||||
|
check: ["cargo check --workspace", "cargo clippy --workspace -- -D warnings"]
|
||||||
|
fmt: ["cargo fmt"]
|
||||||
|
"fmt:check": ["cargo fmt -- --check"]
|
||||||
|
|
||||||
|
"docker:build": [
|
||||||
|
"docker build -f templates/forage-server.Dockerfile -t forage/forage-server:dev .",
|
||||||
|
]
|
||||||
|
}
|
||||||
104
interface/proto/forest/v1/organisations.proto
Normal file
104
interface/proto/forest/v1/organisations.proto
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package forest.v1;
|
||||||
|
|
||||||
|
import "google/protobuf/timestamp.proto";
|
||||||
|
|
||||||
|
message Organisation {
|
||||||
|
string organisation_id = 1;
|
||||||
|
string name = 2;
|
||||||
|
google.protobuf.Timestamp created_at = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateOrganisationRequest {
|
||||||
|
string name = 1;
|
||||||
|
}
|
||||||
|
message CreateOrganisationResponse {
|
||||||
|
string organisation_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetOrganisationRequest {
|
||||||
|
oneof identifier {
|
||||||
|
string organisation_id = 1;
|
||||||
|
string name = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message GetOrganisationResponse {
|
||||||
|
Organisation organisation = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SearchOrganisationsRequest {
|
||||||
|
string query = 1;
|
||||||
|
int32 page_size = 2;
|
||||||
|
string page_token = 3;
|
||||||
|
}
|
||||||
|
message SearchOrganisationsResponse {
|
||||||
|
repeated Organisation organisations = 1;
|
||||||
|
string next_page_token = 2;
|
||||||
|
int32 total_count = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListMyOrganisationsRequest {
|
||||||
|
// Optional role filter (e.g. "admin"); empty means all roles
|
||||||
|
string role = 1;
|
||||||
|
}
|
||||||
|
message ListMyOrganisationsResponse {
|
||||||
|
repeated Organisation organisations = 1;
|
||||||
|
// The role the caller has in each organisation (parallel to organisations)
|
||||||
|
repeated string roles = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Members ------------------------------------------------------------------
|
||||||
|
|
||||||
|
message OrganisationMember {
|
||||||
|
string user_id = 1;
|
||||||
|
string username = 2;
|
||||||
|
string role = 3;
|
||||||
|
google.protobuf.Timestamp joined_at = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddMemberRequest {
|
||||||
|
string organisation_id = 1;
|
||||||
|
string user_id = 2;
|
||||||
|
string role = 3;
|
||||||
|
}
|
||||||
|
message AddMemberResponse {
|
||||||
|
OrganisationMember member = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemoveMemberRequest {
|
||||||
|
string organisation_id = 1;
|
||||||
|
string user_id = 2;
|
||||||
|
}
|
||||||
|
message RemoveMemberResponse {}
|
||||||
|
|
||||||
|
message UpdateMemberRoleRequest {
|
||||||
|
string organisation_id = 1;
|
||||||
|
string user_id = 2;
|
||||||
|
string role = 3;
|
||||||
|
}
|
||||||
|
message UpdateMemberRoleResponse {
|
||||||
|
OrganisationMember member = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListMembersRequest {
|
||||||
|
string organisation_id = 1;
|
||||||
|
int32 page_size = 2;
|
||||||
|
string page_token = 3;
|
||||||
|
}
|
||||||
|
message ListMembersResponse {
|
||||||
|
repeated OrganisationMember members = 1;
|
||||||
|
string next_page_token = 2;
|
||||||
|
int32 total_count = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
service OrganisationService {
|
||||||
|
rpc CreateOrganisation(CreateOrganisationRequest) returns (CreateOrganisationResponse);
|
||||||
|
rpc GetOrganisation(GetOrganisationRequest) returns (GetOrganisationResponse);
|
||||||
|
rpc SearchOrganisations(SearchOrganisationsRequest) returns (SearchOrganisationsResponse);
|
||||||
|
rpc ListMyOrganisations(ListMyOrganisationsRequest) returns (ListMyOrganisationsResponse);
|
||||||
|
rpc AddMember(AddMemberRequest) returns (AddMemberResponse);
|
||||||
|
rpc RemoveMember(RemoveMemberRequest) returns (RemoveMemberResponse);
|
||||||
|
rpc UpdateMemberRole(UpdateMemberRoleRequest) returns (UpdateMemberRoleResponse);
|
||||||
|
rpc ListMembers(ListMembersRequest) returns (ListMembersResponse);
|
||||||
|
}
|
||||||
151
interface/proto/forest/v1/releases.proto
Normal file
151
interface/proto/forest/v1/releases.proto
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package forest.v1;
|
||||||
|
|
||||||
|
message AnnotateReleaseRequest {
|
||||||
|
string artifact_id = 1;
|
||||||
|
map<string, string> metadata = 2;
|
||||||
|
Source source = 3;
|
||||||
|
ArtifactContext context = 4;
|
||||||
|
Project project = 5;
|
||||||
|
Ref ref = 6;
|
||||||
|
}
|
||||||
|
message AnnotateReleaseResponse {
|
||||||
|
Artifact artifact = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetArtifactBySlugRequest {
|
||||||
|
string slug = 1;
|
||||||
|
}
|
||||||
|
message GetArtifactBySlugResponse {
|
||||||
|
Artifact artifact = 1;
|
||||||
|
}
|
||||||
|
message GetArtifactsByProjectRequest {
|
||||||
|
Project project = 1;
|
||||||
|
}
|
||||||
|
message GetArtifactsByProjectResponse {
|
||||||
|
repeated Artifact artifact = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ReleaseRequest {
|
||||||
|
string artifact_id = 1;
|
||||||
|
repeated string destinations = 2;
|
||||||
|
repeated string environments = 3;
|
||||||
|
}
|
||||||
|
message ReleaseResponse {
|
||||||
|
// List of release intents created (one per destination)
|
||||||
|
repeated ReleaseIntent intents = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ReleaseIntent {
|
||||||
|
string release_intent_id = 1;
|
||||||
|
string destination = 2;
|
||||||
|
string environment = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message WaitReleaseRequest {
|
||||||
|
string release_intent_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message WaitReleaseEvent {
|
||||||
|
oneof event {
|
||||||
|
ReleaseStatusUpdate status_update = 1;
|
||||||
|
ReleaseLogLine log_line = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message ReleaseStatusUpdate {
|
||||||
|
string destination = 1;
|
||||||
|
string status = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ReleaseLogLine {
|
||||||
|
string destination = 1;
|
||||||
|
string line = 2;
|
||||||
|
string timestamp = 3;
|
||||||
|
LogChannel channel = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LogChannel {
|
||||||
|
LOG_CHANNEL_UNSPECIFIED = 0;
|
||||||
|
LOG_CHANNEL_STDOUT = 1;
|
||||||
|
LOG_CHANNEL_STDERR = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetOrganisationsRequest {}
|
||||||
|
message GetOrganisationsResponse {
|
||||||
|
repeated OrganisationRef organisations = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetProjectsRequest {
|
||||||
|
oneof query {
|
||||||
|
OrganisationRef organisation = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message GetProjectsResponse {
|
||||||
|
repeated string projects = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
service ReleaseService {
|
||||||
|
rpc AnnotateRelease(AnnotateReleaseRequest) returns (AnnotateReleaseResponse);
|
||||||
|
rpc Release(ReleaseRequest) returns (ReleaseResponse);
|
||||||
|
rpc WaitRelease(WaitReleaseRequest) returns (stream WaitReleaseEvent);
|
||||||
|
|
||||||
|
rpc GetArtifactBySlug(GetArtifactBySlugRequest) returns (GetArtifactBySlugResponse);
|
||||||
|
rpc GetArtifactsByProject(GetArtifactsByProjectRequest) returns (GetArtifactsByProjectResponse);
|
||||||
|
rpc GetOrganisations(GetOrganisationsRequest) returns (GetOrganisationsResponse);
|
||||||
|
rpc GetProjects(GetProjectsRequest) returns (GetProjectsResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
message Source {
|
||||||
|
optional string user = 1;
|
||||||
|
optional string email = 2;
|
||||||
|
optional string source_type = 3;
|
||||||
|
optional string run_url = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ArtifactContext {
|
||||||
|
string title = 1;
|
||||||
|
optional string description = 2;
|
||||||
|
optional string web = 3;
|
||||||
|
optional string pr = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Artifact {
|
||||||
|
string id = 1;
|
||||||
|
string artifact_id = 2;
|
||||||
|
string slug = 3;
|
||||||
|
map<string, string> metadata = 4;
|
||||||
|
Source source = 5;
|
||||||
|
ArtifactContext context = 6;
|
||||||
|
Project project = 7;
|
||||||
|
repeated ArtifactDestination destinations = 8;
|
||||||
|
string created_at = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ArtifactDestination {
|
||||||
|
string name = 1;
|
||||||
|
string environment = 2;
|
||||||
|
string type_organisation = 3;
|
||||||
|
string type_name = 4;
|
||||||
|
uint64 type_version = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Project {
|
||||||
|
string organisation = 1;
|
||||||
|
string project = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Ref {
|
||||||
|
string commit_sha = 1;
|
||||||
|
optional string branch = 2;
|
||||||
|
optional string commit_message = 3;
|
||||||
|
optional string version = 4;
|
||||||
|
optional string repo_url = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OrganisationRef {
|
||||||
|
string organisation = 1;
|
||||||
|
}
|
||||||
317
interface/proto/forest/v1/users.proto
Normal file
317
interface/proto/forest/v1/users.proto
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package forest.v1;
|
||||||
|
|
||||||
|
import "google/protobuf/timestamp.proto";
|
||||||
|
|
||||||
|
// UsersService handles user management, authentication, and profile operations.
|
||||||
|
service UsersService {
|
||||||
|
// Authentication
|
||||||
|
rpc Register(RegisterRequest) returns (RegisterResponse);
|
||||||
|
rpc Login(LoginRequest) returns (LoginResponse);
|
||||||
|
rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse);
|
||||||
|
rpc Logout(LogoutRequest) returns (LogoutResponse);
|
||||||
|
|
||||||
|
// User CRUD
|
||||||
|
rpc GetUser(GetUserRequest) returns (GetUserResponse);
|
||||||
|
rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse);
|
||||||
|
rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse);
|
||||||
|
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
|
||||||
|
|
||||||
|
// Password management
|
||||||
|
rpc ChangePassword(ChangePasswordRequest) returns (ChangePasswordResponse);
|
||||||
|
|
||||||
|
// Email management
|
||||||
|
rpc AddEmail(AddEmailRequest) returns (AddEmailResponse);
|
||||||
|
rpc VerifyEmail(VerifyEmailRequest) returns (VerifyEmailResponse);
|
||||||
|
rpc RemoveEmail(RemoveEmailRequest) returns (RemoveEmailResponse);
|
||||||
|
|
||||||
|
// Social / OAuth login
|
||||||
|
rpc OAuthLogin(OAuthLoginRequest) returns (OAuthLoginResponse);
|
||||||
|
rpc LinkOAuthProvider(LinkOAuthProviderRequest) returns (LinkOAuthProviderResponse);
|
||||||
|
rpc UnlinkOAuthProvider(UnlinkOAuthProviderRequest) returns (UnlinkOAuthProviderResponse);
|
||||||
|
|
||||||
|
// Personal access tokens
|
||||||
|
rpc CreatePersonalAccessToken(CreatePersonalAccessTokenRequest) returns (CreatePersonalAccessTokenResponse);
|
||||||
|
rpc ListPersonalAccessTokens(ListPersonalAccessTokensRequest) returns (ListPersonalAccessTokensResponse);
|
||||||
|
rpc DeletePersonalAccessToken(DeletePersonalAccessTokenRequest) returns (DeletePersonalAccessTokenResponse);
|
||||||
|
|
||||||
|
// Token introspection (requires valid access token)
|
||||||
|
rpc TokenInfo(TokenInfoRequest) returns (TokenInfoResponse);
|
||||||
|
|
||||||
|
// MFA
|
||||||
|
rpc SetupMfa(SetupMfaRequest) returns (SetupMfaResponse);
|
||||||
|
rpc VerifyMfa(VerifyMfaRequest) returns (VerifyMfaResponse);
|
||||||
|
rpc DisableMfa(DisableMfaRequest) returns (DisableMfaResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Core types ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message User {
|
||||||
|
string user_id = 1; // UUID
|
||||||
|
string username = 2;
|
||||||
|
repeated UserEmail emails = 3;
|
||||||
|
repeated OAuthConnection oauth_connections = 4;
|
||||||
|
bool mfa_enabled = 5;
|
||||||
|
google.protobuf.Timestamp created_at = 6;
|
||||||
|
google.protobuf.Timestamp updated_at = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UserEmail {
|
||||||
|
string email = 1;
|
||||||
|
bool verified = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OAuthProvider {
|
||||||
|
OAUTH_PROVIDER_UNSPECIFIED = 0;
|
||||||
|
OAUTH_PROVIDER_GITHUB = 1;
|
||||||
|
OAUTH_PROVIDER_GOOGLE = 2;
|
||||||
|
OAUTH_PROVIDER_GITLAB = 3;
|
||||||
|
OAUTH_PROVIDER_MICROSOFT = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OAuthConnection {
|
||||||
|
OAuthProvider provider = 1;
|
||||||
|
string provider_user_id = 2;
|
||||||
|
string provider_email = 3;
|
||||||
|
google.protobuf.Timestamp linked_at = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Authentication ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message RegisterRequest {
|
||||||
|
string username = 1;
|
||||||
|
string email = 2;
|
||||||
|
string password = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RegisterResponse {
|
||||||
|
User user = 1;
|
||||||
|
AuthTokens tokens = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LoginRequest {
|
||||||
|
// Login with either username or email
|
||||||
|
oneof identifier {
|
||||||
|
string username = 1;
|
||||||
|
string email = 2;
|
||||||
|
}
|
||||||
|
string password = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LoginResponse {
|
||||||
|
User user = 1;
|
||||||
|
AuthTokens tokens = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RefreshTokenRequest {
|
||||||
|
string refresh_token = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RefreshTokenResponse {
|
||||||
|
AuthTokens tokens = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LogoutRequest {
|
||||||
|
string refresh_token = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LogoutResponse {}
|
||||||
|
|
||||||
|
message AuthTokens {
|
||||||
|
string access_token = 1;
|
||||||
|
string refresh_token = 2;
|
||||||
|
int64 expires_in_seconds = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Token introspection ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
message TokenInfoRequest {}
|
||||||
|
|
||||||
|
message TokenInfoResponse {
|
||||||
|
string user_id = 1;
|
||||||
|
int64 expires_at = 2; // Unix timestamp (seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── User CRUD ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message GetUserRequest {
|
||||||
|
oneof identifier {
|
||||||
|
string user_id = 1;
|
||||||
|
string username = 2;
|
||||||
|
string email = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetUserResponse {
|
||||||
|
User user = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateUserRequest {
|
||||||
|
string user_id = 1;
|
||||||
|
optional string username = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateUserResponse {
|
||||||
|
User user = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteUserRequest {
|
||||||
|
string user_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteUserResponse {}
|
||||||
|
|
||||||
|
message ListUsersRequest {
|
||||||
|
int32 page_size = 1;
|
||||||
|
string page_token = 2;
|
||||||
|
optional string search = 3; // search across username, email
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListUsersResponse {
|
||||||
|
repeated User users = 1;
|
||||||
|
string next_page_token = 2;
|
||||||
|
int32 total_count = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Password management ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
message ChangePasswordRequest {
|
||||||
|
string user_id = 1;
|
||||||
|
string current_password = 2;
|
||||||
|
string new_password = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ChangePasswordResponse {}
|
||||||
|
|
||||||
|
// ─── Email management ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message AddEmailRequest {
|
||||||
|
string user_id = 1;
|
||||||
|
string email = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddEmailResponse {
|
||||||
|
UserEmail email = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message VerifyEmailRequest {
|
||||||
|
string user_id = 1;
|
||||||
|
string email = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message VerifyEmailResponse {}
|
||||||
|
|
||||||
|
message RemoveEmailRequest {
|
||||||
|
string user_id = 1;
|
||||||
|
string email = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemoveEmailResponse {}
|
||||||
|
|
||||||
|
// ─── OAuth / Social login ────────────────────────────────────────────
|
||||||
|
|
||||||
|
message OAuthLoginRequest {
|
||||||
|
OAuthProvider provider = 1;
|
||||||
|
string authorization_code = 2;
|
||||||
|
string redirect_uri = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OAuthLoginResponse {
|
||||||
|
User user = 1;
|
||||||
|
AuthTokens tokens = 2;
|
||||||
|
bool is_new_user = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LinkOAuthProviderRequest {
|
||||||
|
string user_id = 1;
|
||||||
|
OAuthProvider provider = 2;
|
||||||
|
string authorization_code = 3;
|
||||||
|
string redirect_uri = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LinkOAuthProviderResponse {
|
||||||
|
OAuthConnection connection = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UnlinkOAuthProviderRequest {
|
||||||
|
string user_id = 1;
|
||||||
|
OAuthProvider provider = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UnlinkOAuthProviderResponse {}
|
||||||
|
|
||||||
|
// ─── Personal access tokens ──────────────────────────────────────────
|
||||||
|
|
||||||
|
message PersonalAccessToken {
|
||||||
|
string token_id = 1; // UUID
|
||||||
|
string name = 2;
|
||||||
|
repeated string scopes = 3;
|
||||||
|
google.protobuf.Timestamp expires_at = 4;
|
||||||
|
google.protobuf.Timestamp last_used = 5;
|
||||||
|
google.protobuf.Timestamp created_at = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreatePersonalAccessTokenRequest {
|
||||||
|
string user_id = 1;
|
||||||
|
string name = 2;
|
||||||
|
repeated string scopes = 3;
|
||||||
|
// Duration in seconds; 0 = no expiry
|
||||||
|
int64 expires_in_seconds = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreatePersonalAccessTokenResponse {
|
||||||
|
PersonalAccessToken token = 1;
|
||||||
|
// The raw token value, only returned on creation
|
||||||
|
string raw_token = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListPersonalAccessTokensRequest {
|
||||||
|
string user_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListPersonalAccessTokensResponse {
|
||||||
|
repeated PersonalAccessToken tokens = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeletePersonalAccessTokenRequest {
|
||||||
|
string token_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeletePersonalAccessTokenResponse {}
|
||||||
|
|
||||||
|
// ─── MFA ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum MfaType {
|
||||||
|
MFA_TYPE_UNSPECIFIED = 0;
|
||||||
|
MFA_TYPE_TOTP = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetupMfaRequest {
|
||||||
|
string user_id = 1;
|
||||||
|
MfaType mfa_type = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetupMfaResponse {
|
||||||
|
string mfa_id = 1; // UUID
|
||||||
|
// TOTP provisioning URI (otpauth://...)
|
||||||
|
string provisioning_uri = 2;
|
||||||
|
// Base32-encoded secret for manual entry
|
||||||
|
string secret = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message VerifyMfaRequest {
|
||||||
|
string mfa_id = 1;
|
||||||
|
// The TOTP code to verify setup
|
||||||
|
string code = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message VerifyMfaResponse {}
|
||||||
|
|
||||||
|
message DisableMfaRequest {
|
||||||
|
string user_id = 1;
|
||||||
|
// Current TOTP code to confirm disable
|
||||||
|
string code = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DisableMfaResponse {}
|
||||||
109
mise.toml
Normal file
109
mise.toml
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
[tools]
|
||||||
|
rust = "latest"
|
||||||
|
|
||||||
|
# ─── Core Development ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tasks.develop]
|
||||||
|
description = "Start the forage development server"
|
||||||
|
run = "cargo run -p forage-server"
|
||||||
|
|
||||||
|
[tasks.build]
|
||||||
|
description = "Build release binary"
|
||||||
|
run = "cargo build --release -p forage-server"
|
||||||
|
|
||||||
|
[tasks.compile]
|
||||||
|
description = "Build entire workspace in release mode"
|
||||||
|
run = "cargo build --release"
|
||||||
|
|
||||||
|
[tasks.test]
|
||||||
|
description = "Run all tests"
|
||||||
|
run = "cargo test --workspace"
|
||||||
|
|
||||||
|
[tasks.check]
|
||||||
|
description = "Run cargo check and clippy"
|
||||||
|
run = ["cargo check --workspace", "cargo clippy --workspace -- -D warnings"]
|
||||||
|
|
||||||
|
[tasks.fmt]
|
||||||
|
description = "Format all code"
|
||||||
|
run = "cargo fmt"
|
||||||
|
|
||||||
|
[tasks."fmt:check"]
|
||||||
|
description = "Check formatting"
|
||||||
|
run = "cargo fmt -- --check"
|
||||||
|
|
||||||
|
# ─── CI Pipelines ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tasks."ci:pr"]
|
||||||
|
description = "Run PR pipeline (check, test, build)"
|
||||||
|
run = "cargo run -p ci -- pr"
|
||||||
|
|
||||||
|
[tasks."ci:main"]
|
||||||
|
description = "Run main pipeline (check, test, build, publish)"
|
||||||
|
run = "cargo run -p ci -- main"
|
||||||
|
|
||||||
|
# ─── Docker ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tasks."docker:build"]
|
||||||
|
description = "Build forage-server Docker image"
|
||||||
|
run = "docker build -f templates/forage-server.Dockerfile -t forage/forage-server:dev ."
|
||||||
|
|
||||||
|
[tasks."docker:publish"]
|
||||||
|
description = "Build and publish Docker image with tags"
|
||||||
|
run = """
|
||||||
|
COMMIT=$(git rev-parse --short HEAD)
|
||||||
|
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
||||||
|
REGISTRY=${CI_REGISTRY:-registry.forage.sh}
|
||||||
|
IMAGE=${CI_IMAGE_NAME:-forage/forage-server}
|
||||||
|
docker build -f templates/forage-server.Dockerfile \
|
||||||
|
-t $REGISTRY/$IMAGE:latest \
|
||||||
|
-t $REGISTRY/$IMAGE:$COMMIT \
|
||||||
|
-t $REGISTRY/$IMAGE:$TIMESTAMP \
|
||||||
|
.
|
||||||
|
docker push $REGISTRY/$IMAGE:latest
|
||||||
|
docker push $REGISTRY/$IMAGE:$COMMIT
|
||||||
|
docker push $REGISTRY/$IMAGE:$TIMESTAMP
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ─── Local Infrastructure ──────────────────────────────────────────
|
||||||
|
|
||||||
|
[tasks."local:up"]
|
||||||
|
description = "Start local services (postgres)"
|
||||||
|
run = "docker compose -f templates/docker-compose.yaml up -d --wait"
|
||||||
|
|
||||||
|
[tasks."local:down"]
|
||||||
|
description = "Stop and remove local services"
|
||||||
|
run = "docker compose -f templates/docker-compose.yaml down -v"
|
||||||
|
|
||||||
|
[tasks."local:logs"]
|
||||||
|
description = "Tail local service logs"
|
||||||
|
run = "docker compose -f templates/docker-compose.yaml logs -f"
|
||||||
|
|
||||||
|
# ─── Database ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tasks."db:shell"]
|
||||||
|
description = "Connect to local postgres"
|
||||||
|
run = "psql postgresql://forageuser:foragepassword@localhost:5432/forage"
|
||||||
|
|
||||||
|
[tasks."db:migrate"]
|
||||||
|
description = "Run database migrations"
|
||||||
|
run = "cargo sqlx migrate run --source crates/forage-db/src/migrations"
|
||||||
|
|
||||||
|
[tasks."db:prepare"]
|
||||||
|
description = "Prepare sqlx offline query cache"
|
||||||
|
run = "cargo sqlx prepare --workspace"
|
||||||
|
|
||||||
|
# ─── Tailwind CSS ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tasks."tailwind:build"]
|
||||||
|
description = "Build tailwind CSS"
|
||||||
|
run = "npx @tailwindcss/cli -i static/css/input.css -o static/css/style.css --minify"
|
||||||
|
|
||||||
|
[tasks."tailwind:watch"]
|
||||||
|
description = "Watch and rebuild tailwind CSS"
|
||||||
|
run = "npx @tailwindcss/cli -i static/css/input.css -o static/css/style.css --watch"
|
||||||
|
|
||||||
|
# ─── Forest Commands ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tasks."forest:run"]
|
||||||
|
description = "Run a forest command"
|
||||||
|
run = "forest run {{arg(name='cmd')}}"
|
||||||
1055
package-lock.json
generated
Normal file
1055
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
package.json
Normal file
16
package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/cli": "^4.2.1",
|
||||||
|
"tailwindcss": "^4.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
212
specs/PITCH.md
Normal file
212
specs/PITCH.md
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
# Forage - The Platform for Forest
|
||||||
|
|
||||||
|
## Elevator Pitch
|
||||||
|
|
||||||
|
Forage is the managed platform for Forest. Push a `forest.cue` manifest, get production infrastructure. Think Heroku meets infrastructure-as-code, but built on the composable component model of Forest.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
Modern infrastructure tooling is fragmented:
|
||||||
|
- Kubernetes is powerful but complex - teams spend months just on platform engineering
|
||||||
|
- Heroku is simple but inflexible - you outgrow it fast
|
||||||
|
- Infrastructure-as-code tools (Terraform, Pulumi) require deep expertise
|
||||||
|
- CI/CD pipelines are copy-pasted across projects with slight variations
|
||||||
|
- Component sharing across teams is ad-hoc at best
|
||||||
|
|
||||||
|
Forest solves the composability problem: define workflows, components, and deployments in shareable, typed CUE files. But Forest still needs infrastructure to run on.
|
||||||
|
|
||||||
|
## The Solution: Forage
|
||||||
|
|
||||||
|
Forage is the missing runtime layer for Forest. It provides:
|
||||||
|
|
||||||
|
### 1. Component Registry
|
||||||
|
- Publish and discover forest components
|
||||||
|
- Semantic versioning and dependency resolution
|
||||||
|
- Organisation-scoped and public components
|
||||||
|
- `forest components publish` pushes to Forage registry
|
||||||
|
|
||||||
|
### 2. Managed Deployments
|
||||||
|
- Push a `forest.cue` with destinations pointing to Forage
|
||||||
|
- Forage provisions and manages the infrastructure
|
||||||
|
- Zero-config container runtime (no Kubernetes knowledge needed)
|
||||||
|
- Automatic scaling, health checks, rollbacks
|
||||||
|
- Multi-environment support (dev/staging/prod) out of the box
|
||||||
|
|
||||||
|
### 3. Managed Services
|
||||||
|
- **Databases**: PostgreSQL, Redis - provisioned alongside your app
|
||||||
|
- **Object Storage**: S3-compatible storage
|
||||||
|
- **User Management**: Auth, teams, RBAC
|
||||||
|
- **Observability**: Logs, metrics, traces - included by default
|
||||||
|
- **Secrets Management**: Encrypted at rest, injected at runtime
|
||||||
|
|
||||||
|
### 4. Organisation Management
|
||||||
|
- Team workspaces with role-based access
|
||||||
|
- Billing per organisation
|
||||||
|
- Audit logs for compliance
|
||||||
|
- SSO/SAML integration
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
```cue
|
||||||
|
// forest.cue - This is all you need
|
||||||
|
project: {
|
||||||
|
name: "my-api"
|
||||||
|
organisation: "acme"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies: {
|
||||||
|
"forage/service": version: "1.0"
|
||||||
|
"forage/postgres": version: "1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
forage: service: {
|
||||||
|
config: {
|
||||||
|
name: "my-api"
|
||||||
|
image: "my-api:latest"
|
||||||
|
ports: [{ container: 8080, protocol: "http" }]
|
||||||
|
}
|
||||||
|
env: {
|
||||||
|
prod: {
|
||||||
|
destinations: [{
|
||||||
|
type: { organisation: "forage", name: "managed", version: "1" }
|
||||||
|
metadata: { region: "eu-west-1", size: "small" }
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
forage: postgres: {
|
||||||
|
config: {
|
||||||
|
name: "my-db"
|
||||||
|
version: "16"
|
||||||
|
size: "small"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
```bash
|
||||||
|
forest release create --env prod
|
||||||
|
# Forage handles everything: container runtime, database provisioning,
|
||||||
|
# networking, TLS, DNS, health checks, scaling
|
||||||
|
```
|
||||||
|
|
||||||
|
## Target Users
|
||||||
|
|
||||||
|
### Primary: Small-to-Medium Engineering Teams (5-50 engineers)
|
||||||
|
- Need production infrastructure without a dedicated platform team
|
||||||
|
- Want the flexibility of IaC without the complexity
|
||||||
|
- Already using or willing to adopt Forest for workflow management
|
||||||
|
|
||||||
|
### Secondary: Individual Developers / Startups
|
||||||
|
- Want to ship fast without infrastructure overhead
|
||||||
|
- Need a path that scales from prototype to production
|
||||||
|
- Price-sensitive - pay only for what you use
|
||||||
|
|
||||||
|
### Tertiary: Enterprise Teams
|
||||||
|
- Want to standardize deployment across many teams
|
||||||
|
- Need compliance, audit, and access control
|
||||||
|
- Want to share internal components via private registry
|
||||||
|
|
||||||
|
## Pricing Model
|
||||||
|
|
||||||
|
### Free Tier
|
||||||
|
- 1 project, 1 environment
|
||||||
|
- 256MB RAM, shared CPU
|
||||||
|
- Community components only
|
||||||
|
- Ideal for experimentation
|
||||||
|
|
||||||
|
### Developer - $10/month
|
||||||
|
- 3 projects, 3 environments each
|
||||||
|
- 512MB RAM per service, dedicated CPU
|
||||||
|
- 1GB PostgreSQL included
|
||||||
|
- Custom domains
|
||||||
|
|
||||||
|
### Team - $25/user/month
|
||||||
|
- Unlimited projects and environments
|
||||||
|
- Configurable resources (up to 4GB RAM, 2 vCPU)
|
||||||
|
- 10GB PostgreSQL per project
|
||||||
|
- Private component registry
|
||||||
|
- Team management, RBAC
|
||||||
|
|
||||||
|
### Enterprise - Custom
|
||||||
|
- Dedicated infrastructure
|
||||||
|
- SLA guarantees
|
||||||
|
- SSO/SAML
|
||||||
|
- Audit logs
|
||||||
|
- Priority support
|
||||||
|
- On-premise registry option
|
||||||
|
|
||||||
|
### Usage-Based Add-ons
|
||||||
|
- Additional compute: $0.05/vCPU-hour
|
||||||
|
- Additional memory: $0.01/GB-hour
|
||||||
|
- Additional storage: $0.10/GB-month
|
||||||
|
- Bandwidth: $0.05/GB after 10GB free
|
||||||
|
- Managed databases: Starting at $5/month per instance
|
||||||
|
|
||||||
|
## Competitive Positioning
|
||||||
|
|
||||||
|
| Feature | Forage | Heroku | Railway | Fly.io | K8s (self-managed) |
|
||||||
|
|---------|--------|--------|---------|--------|---------------------|
|
||||||
|
| Simplicity | High | High | High | Medium | Low |
|
||||||
|
| Flexibility | High (CUE) | Low | Medium | Medium | Very High |
|
||||||
|
| Component Sharing | Native | None | None | None | Helm (limited) |
|
||||||
|
| Multi-environment | Native | Add-on | Basic | Manual | Manual |
|
||||||
|
| IaC Integration | Native (Forest) | None | None | Partial | Full |
|
||||||
|
| Price Entry | Free | $5/mo | $5/mo | $0 (usage) | $$$$ |
|
||||||
|
| Workflow Automation | Forest native | CI add-ons | Basic | Basic | Custom |
|
||||||
|
|
||||||
|
## Differentiators
|
||||||
|
|
||||||
|
1. **Forest-native**: Not another generic PaaS. Built specifically to make Forest's component model a deployable reality.
|
||||||
|
2. **Typed Manifests**: CUE gives you type-safe infrastructure definitions with validation before deploy.
|
||||||
|
3. **Component Ecosystem**: Publish once, use everywhere. Components are the unit of sharing.
|
||||||
|
4. **Progressive Complexity**: Start simple, add complexity only when needed. No cliff.
|
||||||
|
5. **Transparent Pricing**: No surprises. Usage-based with clear ceilings.
|
||||||
|
|
||||||
|
## Technical Architecture
|
||||||
|
|
||||||
|
### The Site (this repo)
|
||||||
|
- **Rust + Axum**: Fast, safe, minimal dependencies
|
||||||
|
- **MiniJinja**: Server-side rendered - fast page loads, SEO-friendly
|
||||||
|
- **Tailwind CSS**: Utility-first, consistent design
|
||||||
|
- **PostgreSQL**: Battle-tested data layer
|
||||||
|
|
||||||
|
### The Platform (future repos)
|
||||||
|
- **Container Runtime**: Built on Firecracker/Cloud Run/ECS depending on region
|
||||||
|
- **Registry Service**: gRPC service for component distribution (extends forest-server)
|
||||||
|
- **Deployment Engine**: Receives forest manifests, provisions infrastructure
|
||||||
|
- **Billing Service**: Usage tracking, Stripe integration
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### Phase 0 - Foundation (Current)
|
||||||
|
- [ ] Marketing site with pitch, pricing, and waitlist
|
||||||
|
- [ ] Component registry browser (read-only, pulls from forest-server)
|
||||||
|
- [ ] Authentication (sign up, sign in, API keys)
|
||||||
|
- [ ] Organisation and project management UI
|
||||||
|
|
||||||
|
### Phase 1 - Registry
|
||||||
|
- [ ] Component publishing via CLI (`forest components publish`)
|
||||||
|
- [ ] Component discovery and browsing
|
||||||
|
- [ ] Version management and dependency resolution
|
||||||
|
- [ ] Private organisation registries
|
||||||
|
|
||||||
|
### Phase 2 - Managed Deployments
|
||||||
|
- [ ] Container runtime integration
|
||||||
|
- [ ] Push-to-deploy from forest CLI
|
||||||
|
- [ ] Health checks and automatic rollbacks
|
||||||
|
- [ ] Environment management (dev/staging/prod)
|
||||||
|
- [ ] Custom domains and TLS
|
||||||
|
|
||||||
|
### Phase 3 - Managed Services
|
||||||
|
- [ ] PostgreSQL provisioning
|
||||||
|
- [ ] Redis provisioning
|
||||||
|
- [ ] Object storage
|
||||||
|
- [ ] Secrets management
|
||||||
|
|
||||||
|
### Phase 4 - Enterprise
|
||||||
|
- [ ] SSO/SAML
|
||||||
|
- [ ] Audit logging
|
||||||
|
- [ ] Compliance features
|
||||||
|
- [ ] On-premise options
|
||||||
111
specs/VSDD.md
Normal file
111
specs/VSDD.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Verified Spec-Driven Development (VSDD)
|
||||||
|
|
||||||
|
## The Fusion: VDD x TDD x SDD for AI-Native Engineering
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
VSDD is the unified software engineering methodology used for all forage development. It fuses three paradigms into a single AI-orchestrated pipeline:
|
||||||
|
|
||||||
|
- **Spec-Driven Development (SDD):** Define the contract before writing a single line of implementation. Specs are the source of truth.
|
||||||
|
- **Test-Driven Development (TDD):** Tests are written before code. Red -> Green -> Refactor. No code exists without a failing test that demanded it.
|
||||||
|
- **Verification-Driven Development (VDD):** Subject all surviving code to adversarial refinement until a hyper-critical reviewer is forced to hallucinate flaws.
|
||||||
|
|
||||||
|
### The Toolchain
|
||||||
|
|
||||||
|
| Role | Entity | Function |
|
||||||
|
|------|--------|----------|
|
||||||
|
| The Architect | Human Developer | Strategic vision, domain expertise, acceptance authority |
|
||||||
|
| The Builder | Claude | Spec authorship, test generation, code implementation, refactoring |
|
||||||
|
| The Adversary | External reviewer | Hyper-critical reviewer with zero patience |
|
||||||
|
|
||||||
|
### The Pipeline
|
||||||
|
|
||||||
|
#### Phase 1 - Spec Crystallization
|
||||||
|
|
||||||
|
Nothing gets built until the contract is airtight.
|
||||||
|
|
||||||
|
**Step 1a: Behavioral Specification**
|
||||||
|
- Behavioral Contract: preconditions, postconditions, invariants
|
||||||
|
- Interface Definition: input types, output types, error types
|
||||||
|
- Edge Case Catalog: exhaustive boundary conditions and failure modes
|
||||||
|
- Non-Functional Requirements: performance, memory, security
|
||||||
|
|
||||||
|
**Step 1b: Verification Architecture**
|
||||||
|
- Provable Properties Catalog: which invariants must be formally verified
|
||||||
|
- Purity Boundary Map: deterministic pure core vs effectful shell
|
||||||
|
- Property Specifications: formal property definitions where applicable
|
||||||
|
|
||||||
|
**Step 1c: Spec Review Gate**
|
||||||
|
- Reviewed by both human and adversary before any tests
|
||||||
|
|
||||||
|
#### Phase 2 - Test-First Implementation (The TDD Core)
|
||||||
|
|
||||||
|
Red -> Green -> Refactor, enforced by AI.
|
||||||
|
|
||||||
|
**Step 2a: Test Suite Generation**
|
||||||
|
- Unit tests per behavioral contract item
|
||||||
|
- Edge case tests from the catalog
|
||||||
|
- Integration tests for system context
|
||||||
|
- Property-based tests for invariants
|
||||||
|
|
||||||
|
**The Red Gate:** All tests must fail before implementation begins.
|
||||||
|
> **Enforcement note (from Review 002):** When writing tests alongside templates and routes,
|
||||||
|
> use stub handlers returning 501 to verify tests fail before implementing the real logic.
|
||||||
|
> This prevents false confidence from tests that were never red.
|
||||||
|
|
||||||
|
**Step 2b: Minimal Implementation**
|
||||||
|
1. Pick the next failing test
|
||||||
|
2. Write the smallest implementation that makes it pass
|
||||||
|
3. Run the full suite - nothing else should break
|
||||||
|
4. Repeat
|
||||||
|
|
||||||
|
**Step 2c: Refactor**
|
||||||
|
After all tests green, refactor for clarity and performance.
|
||||||
|
|
||||||
|
#### Phase 3 - Adversarial Refinement
|
||||||
|
|
||||||
|
The code survived testing. Now it faces the gauntlet.
|
||||||
|
|
||||||
|
Reviews: spec fidelity, test quality, code quality, security surface, spec gaps.
|
||||||
|
|
||||||
|
#### Phase 4 - Feedback Integration Loop
|
||||||
|
|
||||||
|
Critique feeds back through the pipeline:
|
||||||
|
- Spec-level flaws -> Phase 1
|
||||||
|
- Test-level flaws -> Phase 2a
|
||||||
|
- Implementation flaws -> Phase 2c
|
||||||
|
- New edge cases -> Spec update -> new tests -> fix
|
||||||
|
|
||||||
|
#### Phase 5 - Formal Hardening
|
||||||
|
|
||||||
|
- Fuzz testing on the pure core
|
||||||
|
- Security static analysis (cargo-audit, clippy)
|
||||||
|
- Mutation testing where applicable
|
||||||
|
|
||||||
|
#### Phase 6 - Convergence
|
||||||
|
|
||||||
|
Done when:
|
||||||
|
- Adversary critiques are nitpicks, not real issues
|
||||||
|
- No meaningful untested scenarios remain
|
||||||
|
- Implementation matches spec completely
|
||||||
|
- Security analysis is clean
|
||||||
|
|
||||||
|
### Core Principles
|
||||||
|
|
||||||
|
1. **Spec Supremacy**: The spec is the highest authority below the human developer
|
||||||
|
2. **Verification-First Architecture**: Pure core, effectful shell - designed from Phase 1
|
||||||
|
3. **Red Before Green**: No implementation without a failing test
|
||||||
|
4. **Anti-Slop Bias**: First "correct" version assumed to contain hidden debt
|
||||||
|
5. **Minimal Implementation**: Three similar lines > premature abstraction
|
||||||
|
|
||||||
|
### Applying VSDD in This Project
|
||||||
|
|
||||||
|
Each feature follows this flow:
|
||||||
|
|
||||||
|
1. Create spec in `specs/features/<feature-name>.md`
|
||||||
|
2. Spec review with human
|
||||||
|
3. Write failing tests in appropriate crate
|
||||||
|
4. Implement minimally in pure core (`forage-core`)
|
||||||
|
5. Wire up in effectful shell (`forage-server`, `forage-db`)
|
||||||
|
6. Adversarial review
|
||||||
|
7. Iterate until convergence
|
||||||
45
specs/features/001-landing-page.md
Normal file
45
specs/features/001-landing-page.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Spec 001: Landing Page and Marketing Site
|
||||||
|
|
||||||
|
## Status: Phase 2 (Implementation)
|
||||||
|
|
||||||
|
## Behavioral Contract
|
||||||
|
|
||||||
|
### Routes
|
||||||
|
- `GET /` returns the landing page with HTTP 200
|
||||||
|
- `GET /pricing` returns the pricing page with HTTP 200
|
||||||
|
- `GET /static/*` serves static files from the `static/` directory
|
||||||
|
- All pages use the shared base template layout
|
||||||
|
- Unknown routes return HTTP 404
|
||||||
|
|
||||||
|
### Landing Page Content
|
||||||
|
- Hero section with tagline and CTA buttons
|
||||||
|
- Code example showing a forest.cue manifest
|
||||||
|
- Feature grid highlighting: registry, deployments, managed services, type safety, teams, pricing
|
||||||
|
- Final CTA section
|
||||||
|
|
||||||
|
### Pricing Page Content
|
||||||
|
- Displays 4 tiers: Free ($0), Developer ($10/mo), Team ($25/user/mo), Enterprise (Custom)
|
||||||
|
- Usage-based add-on pricing table
|
||||||
|
- Accurate pricing data matching specs/PITCH.md
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
- Pages render server-side (no client-side JS required for content)
|
||||||
|
- Response time < 10ms for template rendering
|
||||||
|
- Valid HTML5 output
|
||||||
|
- Responsive layout (mobile + desktop)
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
- Template file missing -> 500 with error logged
|
||||||
|
- Static file not found -> 404
|
||||||
|
- Malformed path -> handled by axum routing (no panic)
|
||||||
|
|
||||||
|
## Purity Boundary
|
||||||
|
- Template rendering is effectful (file I/O) -> lives in forage-server
|
||||||
|
- No pure core logic needed for static pages
|
||||||
|
- Template engine initialized once at startup
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Integration test: GET / returns 200 with expected content
|
||||||
|
- Integration test: GET /pricing returns 200 with expected content
|
||||||
|
- Integration test: GET /nonexistent returns 404
|
||||||
|
- Compile check: `cargo check` passes
|
||||||
102
specs/features/002-authentication.md
Normal file
102
specs/features/002-authentication.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Spec 002: Authentication (Forest-Server Frontend)
|
||||||
|
|
||||||
|
## Status: Phase 2 Complete (20 tests passing)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Forage is a server-side rendered frontend for forest-server. All user management
|
||||||
|
(register, login, sessions, tokens) is handled by forest-server's UsersService
|
||||||
|
via gRPC. Forage stores access/refresh tokens in HTTP-only cookies and proxies
|
||||||
|
auth operations to the forest-server backend.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser <--HTML/cookies--> forage-server (axum) <--gRPC--> forest-server (UsersService)
|
||||||
|
```
|
||||||
|
|
||||||
|
- No local user database in forage
|
||||||
|
- forest-server owns all auth state (users, sessions, passwords)
|
||||||
|
- forage-server stores access_token + refresh_token in HTTP-only cookies
|
||||||
|
- forage-server has a gRPC client to forest-server's UsersService
|
||||||
|
|
||||||
|
## Behavioral Contract
|
||||||
|
|
||||||
|
### gRPC Client (`forage-core`)
|
||||||
|
|
||||||
|
A typed client wrapping forest-server's UsersService:
|
||||||
|
- `register(username, email, password) -> Result<AuthTokens>`
|
||||||
|
- `login(identifier, password) -> Result<AuthTokens>`
|
||||||
|
- `refresh_token(refresh_token) -> Result<AuthTokens>`
|
||||||
|
- `logout(refresh_token) -> Result<()>`
|
||||||
|
- `get_user(access_token) -> Result<User>`
|
||||||
|
- `list_personal_access_tokens(access_token, user_id) -> Result<Vec<Token>>`
|
||||||
|
- `create_personal_access_token(access_token, user_id, name, scopes, expires) -> Result<(Token, raw_key)>`
|
||||||
|
- `delete_personal_access_token(access_token, token_id) -> Result<()>`
|
||||||
|
|
||||||
|
### Cookie Management
|
||||||
|
|
||||||
|
- `forage_access` cookie: access_token, HttpOnly, Secure, SameSite=Lax, Path=/
|
||||||
|
- `forage_refresh` cookie: refresh_token, HttpOnly, Secure, SameSite=Lax, Path=/
|
||||||
|
- On every authenticated request: extract access_token from cookie
|
||||||
|
- If access_token expired but refresh_token valid: auto-refresh, set new cookies
|
||||||
|
- If both expired: redirect to /login
|
||||||
|
|
||||||
|
### Routes
|
||||||
|
|
||||||
|
#### Public Pages
|
||||||
|
- `GET /signup` -> signup form (200), redirect to /dashboard if authenticated
|
||||||
|
- `POST /signup` -> call Register RPC, set cookies, redirect to /dashboard (302)
|
||||||
|
- `GET /login` -> login form (200), redirect to /dashboard if authenticated
|
||||||
|
- `POST /login` -> call Login RPC, set cookies, redirect to /dashboard (302)
|
||||||
|
- `POST /logout` -> call Logout RPC, clear cookies, redirect to / (302)
|
||||||
|
|
||||||
|
#### Authenticated Pages
|
||||||
|
- `GET /dashboard` -> home page showing user info + orgs (200), or redirect to /login
|
||||||
|
- `GET /settings/tokens` -> list PATs (200)
|
||||||
|
- `POST /settings/tokens` -> create PAT, show raw key once (200)
|
||||||
|
- `POST /settings/tokens/:id/delete` -> delete PAT, redirect to /settings/tokens (302)
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- gRPC errors mapped to user-friendly messages in form re-renders
|
||||||
|
- Invalid credentials: "Invalid username/email or password" (no enumeration)
|
||||||
|
- Duplicate email/username on register: "Already registered"
|
||||||
|
- Network error to forest-server: 502 Bad Gateway page
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
- Forest-server unreachable: show error page, don't crash
|
||||||
|
- Expired access token with valid refresh: auto-refresh transparently
|
||||||
|
- Both tokens expired: redirect to login, clear cookies
|
||||||
|
- Malformed cookie values: treat as unauthenticated
|
||||||
|
- Concurrent requests during token refresh: only refresh once
|
||||||
|
|
||||||
|
## Purity Boundary
|
||||||
|
|
||||||
|
### Pure Core (`forage-core`)
|
||||||
|
- ForestClient trait (mockable for tests)
|
||||||
|
- Token cookie helpers (build Set-Cookie headers, parse cookies)
|
||||||
|
- Form validation (email format, password length)
|
||||||
|
|
||||||
|
### Effectful Shell (`forage-server`)
|
||||||
|
- Actual gRPC calls to forest-server
|
||||||
|
- HTTP cookie read/write
|
||||||
|
- Route handlers and template rendering
|
||||||
|
- Auth middleware (extractor)
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
### Unit Tests (forage-core)
|
||||||
|
- Cookie header building: correct flags, encoding
|
||||||
|
- Form validation: email format, password length
|
||||||
|
- Token expiry detection
|
||||||
|
|
||||||
|
### Integration Tests (forage-server)
|
||||||
|
- All routes render correct templates (using mock ForestClient)
|
||||||
|
- POST /signup calls register, sets cookies on success
|
||||||
|
- POST /login calls login, sets cookies on success
|
||||||
|
- GET /dashboard without cookies -> redirect to /login
|
||||||
|
- GET /dashboard with valid token -> 200 with user content
|
||||||
|
- POST /logout clears cookies
|
||||||
|
- Error paths: bad credentials, server down
|
||||||
|
|
||||||
|
The mock ForestClient allows testing all UI flows without a running forest-server.
|
||||||
286
specs/features/003-bff-sessions.md
Normal file
286
specs/features/003-bff-sessions.md
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
# Spec 003: BFF Session Management
|
||||||
|
|
||||||
|
## Status: Phase 2 Complete (34 tests passing)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The current auth implementation stores forest-server's raw access_token and
|
||||||
|
refresh_token directly in browser cookies. This has several problems:
|
||||||
|
|
||||||
|
1. **Security**: Forest-server credentials are exposed to the browser. If XSS
|
||||||
|
ever bypasses HttpOnly (or we need to read auth state client-side), the raw
|
||||||
|
tokens are right there.
|
||||||
|
2. **No transparent refresh**: The extractor checks cookie existence but can't
|
||||||
|
detect token expiry. When the access_token expires, `get_user()` fails and
|
||||||
|
the user gets redirected to login - even though the refresh_token is still
|
||||||
|
valid. Users get randomly logged out.
|
||||||
|
3. **No user caching**: Every authenticated page makes 2-3 gRPC round-trips
|
||||||
|
(token_info + get_user + page-specific call). For server-rendered pages
|
||||||
|
this is noticeable latency.
|
||||||
|
4. **No session concept**: There's no way to list active sessions, revoke
|
||||||
|
sessions, or track "last seen". The server is stateless in a way that
|
||||||
|
hurts the product.
|
||||||
|
|
||||||
|
## Solution: Backend-for-Frontend (BFF) Sessions
|
||||||
|
|
||||||
|
Forage server owns sessions. The browser gets an opaque session ID cookie.
|
||||||
|
Forest-server tokens and cached user info live server-side only.
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser --[forage_session cookie]--> forage-server --[access_token]--> forest-server
|
||||||
|
|
|
||||||
|
[session store]
|
||||||
|
sid -> { access_token, refresh_token,
|
||||||
|
expires_at, user_cache }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Session Store Trait
|
||||||
|
|
||||||
|
A trait in `forage-core` so the store is swappable and testable:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[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>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SessionId
|
||||||
|
|
||||||
|
An opaque, cryptographically random token. Not a UUID - use 32 bytes of
|
||||||
|
`rand::OsRng` encoded as base64url. This is the only thing the browser sees.
|
||||||
|
|
||||||
|
### SessionData
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct SessionData {
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub access_expires_at: chrono::DateTime<Utc>, // computed from expires_in_seconds
|
||||||
|
pub user: Option<CachedUser>, // cached to avoid repeated get_user calls
|
||||||
|
pub created_at: chrono::DateTime<Utc>,
|
||||||
|
pub last_seen_at: chrono::DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CachedUser {
|
||||||
|
pub user_id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub emails: Vec<UserEmail>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### In-Memory Store (Phase 1)
|
||||||
|
|
||||||
|
`HashMap<SessionId, SessionData>` behind a `RwLock`. Good enough for single-instance
|
||||||
|
deployment. A background task reaps expired sessions periodically.
|
||||||
|
|
||||||
|
This is sufficient for now. When forage needs horizontal scaling, swap to a
|
||||||
|
Redis or PostgreSQL-backed store behind the same trait.
|
||||||
|
|
||||||
|
### Cookie
|
||||||
|
|
||||||
|
Single cookie: `forage_session`
|
||||||
|
- Value: the opaque SessionId (base64url, ~43 chars)
|
||||||
|
- HttpOnly: yes
|
||||||
|
- Secure: yes (always - even if we need to configure for local dev)
|
||||||
|
- SameSite: Lax
|
||||||
|
- Path: /
|
||||||
|
- Max-Age: 30 days (the session lifetime, not the access token lifetime)
|
||||||
|
|
||||||
|
The previous `forage_access` and `forage_refresh` cookies are removed entirely.
|
||||||
|
|
||||||
|
## Behavioral Contract
|
||||||
|
|
||||||
|
### Login / Register Flow
|
||||||
|
|
||||||
|
1. User submits login/signup form
|
||||||
|
2. Forage calls forest-server's Login/Register RPC, gets AuthTokens
|
||||||
|
3. Forage computes `access_expires_at = now + expires_in_seconds`
|
||||||
|
4. Forage calls `get_user` to populate the user cache
|
||||||
|
5. Forage creates a session in the store with tokens + user cache
|
||||||
|
6. Forage sets `forage_session` cookie with the session ID
|
||||||
|
7. Redirect to /dashboard
|
||||||
|
|
||||||
|
### Authenticated Request Flow
|
||||||
|
|
||||||
|
1. Extract `forage_session` cookie
|
||||||
|
2. Look up session in store
|
||||||
|
3. If no session: redirect to /login
|
||||||
|
4. If `access_expires_at` is in the future (with margin): use cached access_token
|
||||||
|
5. If access_token is expired or near-expiry (< 60s remaining):
|
||||||
|
a. Call forest-server's RefreshToken RPC with the stored refresh_token
|
||||||
|
b. On success: update session with new tokens + new expiry
|
||||||
|
c. On failure (refresh_token also expired): delete session, redirect to /login
|
||||||
|
6. Return session to the route handler (which has access_token + cached user)
|
||||||
|
|
||||||
|
### Logout Flow
|
||||||
|
|
||||||
|
1. Extract session ID from cookie
|
||||||
|
2. Get refresh_token from session store
|
||||||
|
3. Call forest-server's Logout RPC (best-effort)
|
||||||
|
4. Delete session from store
|
||||||
|
5. Clear the `forage_session` cookie
|
||||||
|
6. Redirect to /
|
||||||
|
|
||||||
|
### Session Expiry
|
||||||
|
|
||||||
|
- Sessions expire after 30 days of inactivity (configurable)
|
||||||
|
- `last_seen_at` is updated on each request
|
||||||
|
- A background reaper runs every 5 minutes, removes sessions where
|
||||||
|
`last_seen_at + 30 days < now`
|
||||||
|
- If the refresh_token is rejected by forest-server, the session is
|
||||||
|
destroyed immediately regardless of age
|
||||||
|
|
||||||
|
## Changes to Existing Code
|
||||||
|
|
||||||
|
### What Gets Replaced
|
||||||
|
|
||||||
|
- `auth.rs`: `MaybeAuth` and `RequireAuth` extractors rewritten to use session store
|
||||||
|
- `auth.rs`: `auth_cookies()` and `clear_cookies()` replaced with session cookie helpers
|
||||||
|
- `routes/auth.rs`: Login/signup handlers create sessions instead of setting token cookies
|
||||||
|
- `routes/auth.rs`: Logout handler destroys session
|
||||||
|
- `routes/auth.rs`: Dashboard and token pages use `session.user` cache instead of calling `get_user()` every time
|
||||||
|
|
||||||
|
### What Stays the Same
|
||||||
|
|
||||||
|
- `ForestAuth` trait and `GrpcForestClient` - unchanged, still the interface to forest-server
|
||||||
|
- Validation functions in `forage-core` - unchanged
|
||||||
|
- Templates - unchanged (they receive the same data)
|
||||||
|
- Route structure and URLs - unchanged
|
||||||
|
- All existing tests continue to pass (mock gets wrapped in mock session store)
|
||||||
|
|
||||||
|
### New Dependencies
|
||||||
|
|
||||||
|
- `rand` (workspace): for cryptographic session ID generation
|
||||||
|
- No new external session framework - the store is simple enough to own
|
||||||
|
|
||||||
|
### AppState Changes
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct AppState {
|
||||||
|
pub templates: TemplateEngine,
|
||||||
|
pub forest_client: Arc<dyn ForestAuth>,
|
||||||
|
pub sessions: Arc<dyn SessionStore>, // NEW
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extractors (New Design)
|
||||||
|
|
||||||
|
### `Session` extractor (replaces `RequireAuth`)
|
||||||
|
|
||||||
|
Extracts the session, handles refresh transparently, provides both the
|
||||||
|
access_token (for forest-server calls that aren't cached) and cached user info.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Session {
|
||||||
|
pub session_id: SessionId,
|
||||||
|
pub access_token: String,
|
||||||
|
pub user: CachedUser,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The extractor:
|
||||||
|
1. Reads cookie
|
||||||
|
2. Looks up session
|
||||||
|
3. Refreshes token if needed (updating the store)
|
||||||
|
4. Returns `Session` or redirects to /login
|
||||||
|
|
||||||
|
Because refresh updates the session store (not the cookie), no response
|
||||||
|
headers need to be set during extraction. The cookie stays the same.
|
||||||
|
|
||||||
|
### `MaybeSession` extractor (replaces `MaybeAuth`)
|
||||||
|
|
||||||
|
Same as `Session` but returns `Option<Session>` instead of redirecting.
|
||||||
|
Used for pages like /signup and /login that behave differently when
|
||||||
|
already authenticated.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
- **Concurrent requests during refresh**: Two requests arrive with the same
|
||||||
|
expired access_token. Both try to refresh. The session store update is
|
||||||
|
behind a RwLock, so the second one will see the already-refreshed token.
|
||||||
|
Alternatively, use a per-session Mutex for refresh operations to avoid
|
||||||
|
double-refresh. Start simple (accept occasional double-refresh), optimize
|
||||||
|
if it becomes a problem.
|
||||||
|
|
||||||
|
- **Session ID collision**: 32 bytes of crypto-random = 256 bits of entropy.
|
||||||
|
Collision probability is negligible.
|
||||||
|
|
||||||
|
- **Store grows unbounded**: The reaper task handles this. For in-memory store,
|
||||||
|
also enforce a max session count (e.g., 100k) as a safety valve.
|
||||||
|
|
||||||
|
- **Server restart loses all sessions**: Yes. In-memory store is not durable.
|
||||||
|
All users will need to re-login after a deploy. This is acceptable for now
|
||||||
|
and is the primary motivation for eventually moving to Redis/Postgres.
|
||||||
|
|
||||||
|
- **Cookie without valid session**: Treat as unauthenticated. Clear the stale
|
||||||
|
cookie.
|
||||||
|
|
||||||
|
- **Forest-server down during refresh**: Keep the existing session alive with
|
||||||
|
the expired access_token. The next forest-server call will fail, and the
|
||||||
|
route handler deals with it (same as today). Don't destroy the session just
|
||||||
|
because refresh failed due to network - only destroy it if forest-server
|
||||||
|
explicitly rejects the refresh token.
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
### Unit Tests (forage-core)
|
||||||
|
|
||||||
|
- `SessionId` generation: length, format, uniqueness (generate 1000, assert no dupes)
|
||||||
|
- `SessionData` expiry logic: `is_access_expired()`, `needs_refresh()` (with margin)
|
||||||
|
- `InMemorySessionStore`: create/get/update/delete round-trip
|
||||||
|
- `InMemorySessionStore`: get non-existent returns None
|
||||||
|
- `InMemorySessionStore`: delete then get returns None
|
||||||
|
|
||||||
|
### Integration Tests (forage-server)
|
||||||
|
|
||||||
|
All existing tests must continue passing. Additionally:
|
||||||
|
|
||||||
|
- Login creates a session and sets `forage_session` cookie (not `forage_access`)
|
||||||
|
- Dashboard with valid session cookie returns 200 with user content
|
||||||
|
- Dashboard with expired access_token (but valid refresh) still returns 200
|
||||||
|
(transparent refresh)
|
||||||
|
- Dashboard with expired session redirects to /login
|
||||||
|
- Logout destroys session and clears cookie
|
||||||
|
- Signup creates session same as login
|
||||||
|
- Old `forage_access` / `forage_refresh` cookies are ignored (clean break)
|
||||||
|
|
||||||
|
### Mock Session Store
|
||||||
|
|
||||||
|
For tests, use `InMemorySessionStore` directly (it's already simple). The mock
|
||||||
|
`ForestClient` stays as-is for controlling gRPC behavior.
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Add `SessionId`, `SessionData`, `SessionStore` trait, `InMemorySessionStore` to `forage-core`
|
||||||
|
2. Add unit tests for session types and in-memory store
|
||||||
|
3. Add `rand` dependency, implement `SessionId::generate()`
|
||||||
|
4. Rewrite `auth.rs` extractors to use session store
|
||||||
|
5. Rewrite route handlers to use new extractors
|
||||||
|
6. Update `AppState` to include session store
|
||||||
|
7. Update `main.rs` to create the in-memory store
|
||||||
|
8. Update integration tests
|
||||||
|
9. Add session reaper background task
|
||||||
|
10. Remove old cookie helpers and constants
|
||||||
|
|
||||||
|
## What This Does NOT Do
|
||||||
|
|
||||||
|
- No Redis/Postgres session store yet (in-memory only)
|
||||||
|
- No "active sessions" UI for users
|
||||||
|
- No CSRF protection (SameSite=Lax is sufficient for form POSTs from same origin)
|
||||||
|
- No session fixation protection beyond generating new IDs on login
|
||||||
|
- No rate limiting on session creation (defer to forest-server's rate limiting)
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. Should we invalidate all sessions for a user when they change their password?
|
||||||
|
(Requires either forest-server notification or polling.)
|
||||||
|
2. Session cookie name: `forage_session` or `__Host-forage_session`?
|
||||||
|
(`__Host-` prefix forces Secure + no Domain + Path=/, which is stricter.)
|
||||||
|
3. Should the user cache have a separate TTL (e.g., refresh user info every 5 min)?
|
||||||
|
Or only refresh on explicit actions like "edit profile"?
|
||||||
187
specs/features/004-projects-and-usage.md
Normal file
187
specs/features/004-projects-and-usage.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# 004 - Projects View & Usage/Pricing
|
||||||
|
|
||||||
|
**Status**: Phase 1 - Spec
|
||||||
|
**Depends on**: 003 (BFF Sessions)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The dashboard currently shows placeholder text ("No projects yet"). Authenticated users need to:
|
||||||
|
|
||||||
|
1. See their organisations and projects (pulled from forest-server via gRPC)
|
||||||
|
2. Understand their current usage and plan limits
|
||||||
|
3. Navigate between organisations and their projects
|
||||||
|
|
||||||
|
The pricing page exists but is disconnected from the authenticated experience - there's no "your current plan" or usage visibility.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This spec covers:
|
||||||
|
- **Projects view**: List organisations -> projects for the authenticated user
|
||||||
|
- **Usage view**: Show current plan, resource usage, and upgrade path
|
||||||
|
- **gRPC integration**: Add OrganisationService and ReleaseService clients
|
||||||
|
- **Navigation**: Authenticated sidebar/nav with org switcher
|
||||||
|
|
||||||
|
Out of scope (future specs):
|
||||||
|
- Creating organisations or projects from the UI (CLI-first)
|
||||||
|
- Billing/Stripe integration
|
||||||
|
- Deployment management (viewing releases, logs)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### New gRPC Services
|
||||||
|
|
||||||
|
We need to generate stubs for and integrate:
|
||||||
|
- `OrganisationService.ListMyOrganisations` - get orgs the user belongs to
|
||||||
|
- `ReleaseService.GetProjects` - get projects within an org
|
||||||
|
- `ReleaseService.GetArtifactsByProject` - get recent releases for a project
|
||||||
|
|
||||||
|
These require copying `organisations.proto` and `releases.proto` into `interface/proto/forest/v1/` and regenerating with buf.
|
||||||
|
|
||||||
|
### New Trait: `ForestPlatform`
|
||||||
|
|
||||||
|
Separate from `ForestAuth` (which handles identity), this trait handles platform data:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[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>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Domain Types (forage-core)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// forage-core::platform
|
||||||
|
|
||||||
|
pub struct Organisation {
|
||||||
|
pub organisation_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub role: String, // user's role in this org
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Artifact {
|
||||||
|
pub artifact_id: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub context: ArtifactContext,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ArtifactContext {
|
||||||
|
pub title: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(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),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Routes
|
||||||
|
|
||||||
|
| Route | Auth | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `GET /dashboard` | Required | Redirect to first org's projects, or onboarding if no orgs |
|
||||||
|
| `GET /orgs/{org}/projects` | Required | List projects for an organisation |
|
||||||
|
| `GET /orgs/{org}/projects/{project}` | Required | Project detail: recent artifacts/releases |
|
||||||
|
| `GET /orgs/{org}/usage` | Required | Usage & plan info for the organisation |
|
||||||
|
|
||||||
|
### Templates
|
||||||
|
|
||||||
|
- `pages/projects.html.jinja` - Project list within an org
|
||||||
|
- `pages/project_detail.html.jinja` - Single project with recent artifacts
|
||||||
|
- `pages/usage.html.jinja` - Usage dashboard with plan info
|
||||||
|
- `components/app_nav.html.jinja` - Authenticated navigation with org switcher
|
||||||
|
|
||||||
|
### Authenticated Navigation
|
||||||
|
|
||||||
|
When logged in, replace the marketing nav with an app nav:
|
||||||
|
- Left: forage logo, org switcher dropdown
|
||||||
|
- Center: Projects, Usage links (scoped to current org)
|
||||||
|
- Right: user menu (settings, tokens, sign out)
|
||||||
|
|
||||||
|
The base template needs to support both modes: marketing (unauthenticated) and app (authenticated).
|
||||||
|
|
||||||
|
## Behavioral Contract
|
||||||
|
|
||||||
|
### Dashboard redirect
|
||||||
|
- Authenticated user with orgs -> redirect to `/orgs/{first_org}/projects`
|
||||||
|
- Authenticated user with no orgs -> show onboarding: "Create your first organisation with the forest CLI"
|
||||||
|
- Unauthenticated -> redirect to `/login` (existing behavior)
|
||||||
|
|
||||||
|
### Projects list
|
||||||
|
- Shows all projects in the organisation
|
||||||
|
- Each project shows: name, latest artifact slug, last deploy time
|
||||||
|
- Empty state: "No projects yet. Deploy with `forest release create`"
|
||||||
|
- User must be a member of the org (403 otherwise)
|
||||||
|
|
||||||
|
### Project detail
|
||||||
|
- Shows project name, recent artifacts (last 10)
|
||||||
|
- Each artifact: slug, title, description, created_at
|
||||||
|
- Empty state: "No releases yet"
|
||||||
|
|
||||||
|
### Usage page
|
||||||
|
- Current plan tier (hardcoded to "Early Access - Free" for now)
|
||||||
|
- Resource summary (placeholder - no real metering yet)
|
||||||
|
- "Upgrade" CTA pointing to pricing page
|
||||||
|
- Early access notice
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
### Unit tests (forage-core) - ~6 tests
|
||||||
|
- PlatformError display strings
|
||||||
|
- Organisation/Artifact type construction
|
||||||
|
|
||||||
|
### Integration tests (forage-server) - ~12 tests
|
||||||
|
- Dashboard redirect: authenticated with orgs -> redirect to first org
|
||||||
|
- Dashboard redirect: authenticated no orgs -> onboarding page
|
||||||
|
- Projects list: returns 200 with projects
|
||||||
|
- Projects list: empty org shows empty state
|
||||||
|
- Projects list: unauthenticated -> redirect to login
|
||||||
|
- Project detail: returns 200 with artifacts
|
||||||
|
- Project detail: unknown project -> 404
|
||||||
|
- Usage page: returns 200 with plan info
|
||||||
|
- Usage page: unauthenticated -> redirect to login
|
||||||
|
- Forest-server unavailable -> error page
|
||||||
|
- Org switcher: nav shows user's organisations
|
||||||
|
- Non-member org access -> 403
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Copy protos, regenerate stubs (buf generate)
|
||||||
|
2. Add domain types and `ForestPlatform` trait to forage-core
|
||||||
|
3. Write failing tests (Red)
|
||||||
|
4. Implement `GrpcForestPlatform` in forage-server
|
||||||
|
5. Add `MockForestPlatform` to tests
|
||||||
|
6. Implement routes and templates (Green)
|
||||||
|
7. Update dashboard redirect logic
|
||||||
|
8. Add authenticated nav component
|
||||||
|
9. Clippy + review (Phase 3)
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should org switcher persist selection in session or always default to first org?
|
||||||
|
- Do we want a `/orgs/{org}/settings` page in this spec or defer?
|
||||||
281
specs/reviews/001-adversarial-review-phase2.md
Normal file
281
specs/reviews/001-adversarial-review-phase2.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# Adversarial Review: Forage Client (Post Phase 2)
|
||||||
|
|
||||||
|
## Date: 2026-03-07
|
||||||
|
## Scope: Full project review - architecture, workflow, business, security
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Forage Needs a Clear Ownership Boundary
|
||||||
|
|
||||||
|
Forage is entirely dependent on forest-server for its core functionality. Every
|
||||||
|
route either renders static marketing content or proxies to forest-server.
|
||||||
|
|
||||||
|
What does forage own today?
|
||||||
|
- Auth? No, forest-server owns it.
|
||||||
|
- User data? No, forest-server owns it.
|
||||||
|
- Component registry? Future, and forest-server will own that too.
|
||||||
|
- Deployment logic? Future, likely another backend service.
|
||||||
|
|
||||||
|
This isn't wrong - forage is the web product layer on top of forest's API layer.
|
||||||
|
But this intent isn't crystallized anywhere. The PITCH.md lists a huge roadmap
|
||||||
|
(deployments, managed services, billing) without clarifying what lives in forage
|
||||||
|
vs. what lives in forest-server or new backend services.
|
||||||
|
|
||||||
|
**Why this matters**: Every architectural decision (session management, crate
|
||||||
|
boundaries, database usage) depends on what forage will own. Without this
|
||||||
|
boundary, we risk either building too much (duplicating forest-server) or too
|
||||||
|
little (being a dumb proxy forever).
|
||||||
|
|
||||||
|
**Action**: Write a clear architecture doc or update PITCH.md with an explicit
|
||||||
|
"forage owns X, forest-server owns Y, future services own Z" section. At
|
||||||
|
minimum, forage will own: web sessions, billing/subscription state, org-level
|
||||||
|
configuration, and the web UI itself. Forest-server owns: users, auth tokens,
|
||||||
|
components, deployments.
|
||||||
|
|
||||||
|
Comment:
|
||||||
|
Forage in the future is going to have many services that forest is going to be relying on, hence the brand, and site. Also forest-client might be fine as a UI for forest itself, but a flutter app isn't that great at web apps, and we need something native for SEO and the likes.
|
||||||
|
We could adopt the forest-client as the dashboard tbd. Forage is the business entity of forest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The Crate Structure Is Premature
|
||||||
|
|
||||||
|
Five crates today:
|
||||||
|
- **forage-db**: 3 lines. Re-exports `PgPool`. No queries, no migrations.
|
||||||
|
- **forage-core**: ~110 lines. A trait, types, 3 validation functions.
|
||||||
|
- **forage-grpc**: Generated code wrapper. Justified.
|
||||||
|
- **forage-server**: The actual application. All real logic lives here.
|
||||||
|
- **ci**: Separate build tool. Justified.
|
||||||
|
|
||||||
|
The "pure core / effectful shell" split sounds principled, but `forage-core`
|
||||||
|
is mostly type definitions. The `ForestAuth` trait is defined in core but
|
||||||
|
implemented in server. The "pure" validation is 3 functions totaling ~50 lines.
|
||||||
|
|
||||||
|
**forage-db is dead weight.** There are no database queries, no migrations, no
|
||||||
|
schema. It exists because CLAUDE.md says there should be a db crate. Either
|
||||||
|
remove it or explicitly acknowledge it as a placeholder for future forage-owned
|
||||||
|
state (sessions, billing, org config).
|
||||||
|
|
||||||
|
**Action**: Either consolidate to 3 crates (server, grpc, ci) until there's
|
||||||
|
a real consumer for the core/db split, or commit to what forage-core and
|
||||||
|
forage-db will contain (tied to decision #1). Premature crate boundaries add
|
||||||
|
compile time and cognitive overhead without benefit.
|
||||||
|
|
||||||
|
Comment
|
||||||
|
Lets keep the split for now, we're gonna fill it out shortly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Token Refresh Is Specified But Not Implemented
|
||||||
|
|
||||||
|
The spec says:
|
||||||
|
> If access_token expired but refresh_token valid: auto-refresh, set new cookies
|
||||||
|
|
||||||
|
Reality: `RequireAuth` checks if a cookie exists. It doesn't validate the
|
||||||
|
token, check expiry, or attempt refresh. When the access_token expires,
|
||||||
|
`get_user()` fails and the user gets redirected to login - losing their
|
||||||
|
session even though the refresh_token is valid.
|
||||||
|
|
||||||
|
Depending on forest-server's token lifetime configuration (could be 15 min to
|
||||||
|
1 hour), users will get randomly logged out. This is the single most impactful
|
||||||
|
missing feature.
|
||||||
|
|
||||||
|
**Action**: Implement BFF sessions (spec 003) which solves this by moving
|
||||||
|
tokens server-side and handling refresh transparently.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The get_user Double-Call Pattern
|
||||||
|
|
||||||
|
Every authenticated page does:
|
||||||
|
1. `get_user(access_token)` which internally calls `token_info` then `get_user`
|
||||||
|
(2 gRPC calls in `forest_client.rs:161-192`)
|
||||||
|
2. Then page-specific calls (e.g., `list_tokens` - another gRPC call)
|
||||||
|
|
||||||
|
That's 3 gRPC round-trips per page load. For server-rendered pages where
|
||||||
|
latency = perceived performance, this matters.
|
||||||
|
|
||||||
|
The `get_user` implementation calls `token_info` to get the `user_id`, then
|
||||||
|
`get_user` with that ID. This should be a single call.
|
||||||
|
|
||||||
|
**Action**: Short-term, BFF sessions with user caching (spec 003) eliminates
|
||||||
|
repeated get_user calls. Long-term, consider pushing for a "get current user"
|
||||||
|
endpoint in forest-server that combines token_info + get_user.
|
||||||
|
|
||||||
|
We should be able to store most of this in the session, with a jwt etc. That should be fine for now
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Cookie Security Gap
|
||||||
|
|
||||||
|
`auth_cookies()` sets `HttpOnly` and `SameSite=Lax` but does NOT set `Secure`.
|
||||||
|
|
||||||
|
The spec explicitly requires:
|
||||||
|
> forage_access cookie: access_token, HttpOnly, **Secure**, SameSite=Lax
|
||||||
|
|
||||||
|
Without `Secure`, cookies are sent over plain HTTP. Access tokens can be
|
||||||
|
intercepted on any non-HTTPS connection.
|
||||||
|
|
||||||
|
**Action**: Fix immediately regardless of whether BFF sessions are implemented.
|
||||||
|
If BFF sessions come first, ensure the session cookie sets `Secure`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. The Mock Is Too Friendly
|
||||||
|
|
||||||
|
`MockForestClient` always succeeds (except one login check). Tests prove:
|
||||||
|
- Templates render without errors
|
||||||
|
- Redirects go to the right places
|
||||||
|
- Cookies get set
|
||||||
|
|
||||||
|
Tests do NOT prove:
|
||||||
|
- Error handling for real error scenarios (only one bad-credentials test)
|
||||||
|
- What happens when `get_user` fails mid-flow (token expired between pages)
|
||||||
|
- What happens when `create_token` or `delete_token` fails
|
||||||
|
- What happens when forest-server returns unexpected/partial data
|
||||||
|
- Behavior under concurrent requests
|
||||||
|
|
||||||
|
**Action**: Make the mock configurable per-test. A builder pattern or
|
||||||
|
`Arc<Mutex<MockBehavior>>` would let tests control which calls succeed/fail.
|
||||||
|
Add error-path tests for every route, not just login.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Navigation Links to Nowhere
|
||||||
|
|
||||||
|
`base.html.jinja` links to: `/docs`, `/components`, `/about`, `/blog`,
|
||||||
|
`/privacy`, `/terms`, `/docs/deployments`, `/docs/registry`, `/docs/services`.
|
||||||
|
|
||||||
|
None exist. They all 404.
|
||||||
|
|
||||||
|
This isn't a code quality issue - it's a user experience issue for anyone
|
||||||
|
visiting the site. Every page has a nav and footer full of dead links.
|
||||||
|
|
||||||
|
**Action**: Either remove links to unbuilt pages, add placeholder pages with
|
||||||
|
"coming soon" content, or use a `disabled` / `cursor-not-allowed` style that
|
||||||
|
makes it clear they're not yet available.
|
||||||
|
|
||||||
|
Comment
|
||||||
|
add a place holder and a todo, also remove the docs, we don't need that yet. also remove the blog and other stuff. Lets just stick with the main things. components and the login etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. VSDD Methodology vs. Reality
|
||||||
|
|
||||||
|
VSDD.md describes 6 phases: spec crystallization, test-first implementation,
|
||||||
|
adversarial refinement, feedback integration, formal hardening (fuzz testing,
|
||||||
|
mutation testing, static analysis), and convergence.
|
||||||
|
|
||||||
|
In practice:
|
||||||
|
- Phase 1 (specs): Done well
|
||||||
|
- Phase 2 (TDD-ish): Tests written, but not strictly red-green-refactor
|
||||||
|
- Phase 3 (adversarial): This review
|
||||||
|
- Phases 4-6: Not done
|
||||||
|
|
||||||
|
The full pipeline includes fuzz testing, mutation testing, and property-based
|
||||||
|
tests. None of these exist. The convergence criterion ("adversary must
|
||||||
|
hallucinate flaws") is unrealistic - real code always has real improvements.
|
||||||
|
|
||||||
|
This isn't a problem if VSDD is treated as aspirational guidance rather than
|
||||||
|
a strict process. But if the methodology doc says one thing and practice does
|
||||||
|
another, the doc loses authority.
|
||||||
|
|
||||||
|
**Action**: Either trim VSDD.md to match what's actually practiced (spec ->
|
||||||
|
test -> implement -> review -> iterate), or commit to running the full pipeline
|
||||||
|
on at least one feature to validate whether the overhead is worth it.
|
||||||
|
|
||||||
|
Comment: Write in claude.md that we need to follow the process religiously
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. The Pricing Page Sells Vapor
|
||||||
|
|
||||||
|
The pricing page lists managed deployments, container runtimes, PostgreSQL
|
||||||
|
provisioning, private registries. None of this exists. The roadmap has 4
|
||||||
|
phases before any of it works.
|
||||||
|
|
||||||
|
The landing page has a "Get started for free" CTA leading to `/signup`, which
|
||||||
|
creates an account on forest-server. After signup, the dashboard is empty -
|
||||||
|
there's nothing to do. No components to browse, no deployments to create.
|
||||||
|
|
||||||
|
If this site goes live as-is, you're either:
|
||||||
|
- Collecting signups for a waitlist (fine, but say so explicitly)
|
||||||
|
- Implying a product exists that doesn't (bad)
|
||||||
|
|
||||||
|
**Action**: Add "early access" / "waitlist" framing. The dashboard should
|
||||||
|
explain what's coming and what the user can do today (manage tokens, explore
|
||||||
|
the registry when it exists). The pricing page should indicate which features
|
||||||
|
are available vs. planned.
|
||||||
|
|
||||||
|
Comment: Only add container deployments for now, add the other things as tbd, forget postgresql for now
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Tailwind CSS Not Wired Up
|
||||||
|
|
||||||
|
Templates use Tailwind classes (`bg-white`, `text-gray-900`, `max-w-6xl`, etc.)
|
||||||
|
throughout, but the CSS is loaded from `/static/css/style.css`. If this file
|
||||||
|
doesn't contain compiled Tailwind output, none of the styling works and the
|
||||||
|
site is unstyled HTML.
|
||||||
|
|
||||||
|
`mise.toml` has `tailwind:build` and `tailwind:watch` tasks, but it's unclear
|
||||||
|
if these have been run or if the output is committed.
|
||||||
|
|
||||||
|
**Action**: Verify the Tailwind pipeline works end-to-end. Either commit the
|
||||||
|
compiled CSS or ensure CI builds it. An unstyled site is worse than no site.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. forage-server Isn't Horizontally Scalable
|
||||||
|
|
||||||
|
With in-memory session state (post BFF sessions), raw token cookies (today),
|
||||||
|
and no shared state layer, forage-server is a single-instance application.
|
||||||
|
That's fine for now, but it constrains deployment options.
|
||||||
|
|
||||||
|
This isn't urgent - single-instance Rust serving SSR pages can handle
|
||||||
|
significant traffic. But it should be a conscious decision, not an accident.
|
||||||
|
|
||||||
|
**Action**: Document this constraint. When horizontal scaling becomes needed,
|
||||||
|
the session store trait makes it straightforward to swap to Redis/Postgres.
|
||||||
|
|
||||||
|
Comment: Set up postgresql like we do in forest and so forth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary: Prioritized Actions
|
||||||
|
|
||||||
|
### Must Do (before any deployment)
|
||||||
|
1. **Fix cookie Secure flag** - real security gap
|
||||||
|
2. **Implement BFF sessions** (spec 003) - fixes token refresh, caching, security
|
||||||
|
3. **Remove dead nav links** or add placeholders - broken UX
|
||||||
|
|
||||||
|
### Should Do (before public launch)
|
||||||
|
4. **Add "early access" framing** to pricing/dashboard - honesty about product state
|
||||||
|
5. **Verify Tailwind pipeline** - unstyled site is unusable
|
||||||
|
6. **Improve test mock** - configurable per-test, error path coverage
|
||||||
|
|
||||||
|
### Do When Relevant
|
||||||
|
7. **Define ownership boundary** (forage vs. forest-server) - shapes all future work
|
||||||
|
8. **Simplify crate structure** or justify it with concrete plans
|
||||||
|
9. **Align VSDD doc with practice** - keep methodology honest
|
||||||
|
10. **Plan for horizontal scaling** - document the constraint, prepare the escape hatch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Good
|
||||||
|
|
||||||
|
To be fair, the project has strong foundations:
|
||||||
|
|
||||||
|
- **Architecture is sound.** Thin frontend proxying to forest-server is the
|
||||||
|
right call. Trait-based abstraction for testability is clean.
|
||||||
|
- **Spec-first approach works.** Specs are clear, implementation matches them,
|
||||||
|
tests verify the contract.
|
||||||
|
- **Tech choices are appropriate.** Axum + MiniJinja for SSR is fast, simple,
|
||||||
|
and right-sized. No over-engineering with SPAs or heavy frameworks.
|
||||||
|
- **Cookie-based auth proxy is correct** for this kind of frontend (once moved
|
||||||
|
to BFF sessions).
|
||||||
|
- **CI mirrors forest's patterns** - good for consistency across the ecosystem.
|
||||||
|
- **ForestAuth trait** makes testing painless and the gRPC boundary clean.
|
||||||
|
- **The gRPC client** is well-structured with proper error mapping.
|
||||||
|
|
||||||
|
The issues are about what's missing, not what's wrong with what exists.
|
||||||
176
specs/reviews/002-adversarial-review-phase3.md
Normal file
176
specs/reviews/002-adversarial-review-phase3.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# Adversarial Review 002 - Post Spec 004 (Projects & Usage)
|
||||||
|
|
||||||
|
**Date**: 2026-03-07
|
||||||
|
**Scope**: Full codebase review after specs 001-004
|
||||||
|
**Tests**: 53 total (17 core + 36 server), clippy clean
|
||||||
|
**Verified**: Against real forest-server on localhost:4040
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture: Repeated gRPC Calls Per Page Load
|
||||||
|
|
||||||
|
**Severity: High**
|
||||||
|
|
||||||
|
Every authenticated platform page (`projects_list`, `project_detail`, `usage`) calls `list_my_organisations` to verify membership. This means:
|
||||||
|
|
||||||
|
- `/orgs/testorg/projects` -> 1 call to list orgs + 1 call to list projects = **2 gRPC calls**
|
||||||
|
- `/orgs/testorg/projects/my-api` -> 1 call to list orgs + 1 call to list artifacts = **2 gRPC calls**
|
||||||
|
- Dashboard -> 1 call to list orgs (redirect) then the target page makes its own calls
|
||||||
|
|
||||||
|
This is the same pattern we fixed for `get_user()` in spec 003 (caching user in session). The org list should be cached in the session too, or at minimum passed through from the `Session` extractor.
|
||||||
|
|
||||||
|
**Recommendation**: Cache the user's org memberships in `SessionData` / `CachedUser`. Refresh on session refresh or after a configurable TTL. This eliminates the most expensive repeated call.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture: Two Traits, One Struct, Inconsistent Error Handling
|
||||||
|
|
||||||
|
**Severity: Medium**
|
||||||
|
|
||||||
|
`GrpcForestClient` now implements both `ForestAuth` and `ForestPlatform`. The `authed_request` helper is duplicated:
|
||||||
|
- `GrpcForestClient::authed_request()` returns `AuthError`
|
||||||
|
- `platform_authed_request()` is a free function returning `PlatformError`
|
||||||
|
|
||||||
|
Same logic, two copies, two error types. `AppState` holds `Arc<dyn ForestAuth>` + `Arc<dyn ForestPlatform>` which in production point to the same struct. This is fine for testability but means the constructors are getting wide (4 args now).
|
||||||
|
|
||||||
|
**Recommendation**: Consider a single `ForestClient` trait that combines both, or unify the auth helper into a generic form. Not urgent but will become pain as more services are added.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Security: Org Name in URL Path is User-Controlled
|
||||||
|
|
||||||
|
**Severity: Medium**
|
||||||
|
|
||||||
|
Routes use `{org}` from the URL path and pass it directly to gRPC calls and template rendering:
|
||||||
|
- `format!("{org} - Projects - Forage")` in HTML title
|
||||||
|
- `format!("Projects in {org}")` in meta description
|
||||||
|
|
||||||
|
MiniJinja auto-escapes by default in HTML context, so XSS via `<script>` in org name is mitigated. However:
|
||||||
|
- The `title` tag is outside normal HTML body escaping in some edge cases
|
||||||
|
- The `description` meta tag uses attribute context escaping
|
||||||
|
|
||||||
|
**Recommendation**: Validate or sanitize `{org}` and `{project}` path params at the route level. The org membership check already prevents arbitrary names from rendering (403 if not a member), but defense in depth matters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Session: `last_seen_at` Updated on Every Request
|
||||||
|
|
||||||
|
**Severity: Low**
|
||||||
|
|
||||||
|
The `Session` extractor calls `state.sessions.update()` on **every single request** to update `last_seen_at`. For the PostgreSQL store, this means a write query per page load. For the in-memory store, it's a write lock on the HashMap.
|
||||||
|
|
||||||
|
**Recommendation**: Only update `last_seen_at` if the previous value is older than some threshold (e.g., 5 minutes). This is a simple check that eliminates 95%+ of session writes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Testing: No Test for the `ForestPlatform` gRPC Implementation
|
||||||
|
|
||||||
|
**Severity: Medium**
|
||||||
|
|
||||||
|
The `GrpcForestClient` `ForestPlatform` impl (lines 294-393 of `forest_client.rs`) has zero test coverage. It's only tested indirectly via integration tests that use `MockPlatformClient`. The mapping from proto types to domain types (`Organisation`, `Artifact`) is untested.
|
||||||
|
|
||||||
|
Specifically:
|
||||||
|
- The `zip(resp.roles)` could silently truncate if lengths don't match
|
||||||
|
- The `unwrap_or_default()` on `a.context` hides missing data
|
||||||
|
- The empty-string-to-None conversion for `description` is a subtle behavior
|
||||||
|
|
||||||
|
**Recommendation**: Add unit tests for the proto-to-domain conversion functions. Extract them into named functions (like `convert_user` and `convert_token` for auth) to make them testable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Testing: Dashboard Test Changed Behavior Without Full Coverage
|
||||||
|
|
||||||
|
**Severity: Medium**
|
||||||
|
|
||||||
|
`dashboard_with_session_returns_200` was renamed to `dashboard_with_session_redirects_to_org` and now only checks for `StatusCode::SEE_OTHER`. The old test verified the dashboard rendered with `testuser` content. The new behavior (redirect) is tested, but the onboarding page content is only tested in `dashboard_no_orgs_shows_onboarding` which checks for `"forest orgs create"`.
|
||||||
|
|
||||||
|
Nobody tests:
|
||||||
|
- What happens if `list_my_organisations` returns an error (not empty, an actual error)
|
||||||
|
- The dashboard template rendering is correct (title, user info)
|
||||||
|
|
||||||
|
**Recommendation**: Add test for platform unavailable during dashboard load.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. VSDD Process: Spec 004 Skipped the Red Gate
|
||||||
|
|
||||||
|
**Severity: Medium (Process)**
|
||||||
|
|
||||||
|
The VSDD spec says "All tests must fail before implementation begins." In spec 004, we wrote templates, routes, AND tests in the same step. Tests never had a Red phase - they were green on first run. This is pragmatic but violates VSDD.
|
||||||
|
|
||||||
|
The earlier specs (001-003) had proper Red->Green cycles. Spec 004 was implemented as "write everything at once."
|
||||||
|
|
||||||
|
**Recommendation**: For future specs, write the test assertions first with stub routes that return 501/500, verify they fail, then implement. Even if the cycle is fast, the discipline catches assumption errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Template: No Authenticated Navigation
|
||||||
|
|
||||||
|
**Severity: Medium (UX)**
|
||||||
|
|
||||||
|
The spec called for "Authenticated navigation with org switcher" but it wasn't implemented. All pages (projects, usage, onboarding) use the same marketing `base.html.jinja` which shows "Pricing / Components / Sign in" in the nav, even when the user is authenticated and browsing their org's projects.
|
||||||
|
|
||||||
|
This means:
|
||||||
|
- No way to switch orgs from the nav
|
||||||
|
- No visual indication you're logged in (except the page content)
|
||||||
|
- No link back to projects/usage from the nav on authenticated pages
|
||||||
|
|
||||||
|
**Recommendation**: Either pass `user` and `orgs` to the base template and conditionally render an app nav, or create a separate `app_base.html.jinja` that authenticated pages extend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Error UX: Raw Status Codes as Responses
|
||||||
|
|
||||||
|
**Severity: Medium**
|
||||||
|
|
||||||
|
403 and 500 errors return bare Axum status codes with no HTML body:
|
||||||
|
- Non-member accessing `/orgs/someorg/projects` -> blank 403 page
|
||||||
|
- Template error -> blank 500 page
|
||||||
|
|
||||||
|
**Recommendation**: Add simple error templates (`403.html.jinja`, `500.html.jinja`) and render them instead of bare status codes. Even a one-line "You don't have access to this organisation" is better than a browser default error page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Code: `expires_in_seconds` is Suspiciously Large
|
||||||
|
|
||||||
|
**Severity: Low (Upstream)**
|
||||||
|
|
||||||
|
During integration testing, forest-server returned `expiresInSeconds: 1775498883` which is ~56 years. This is likely a bug in forest-server (perhaps it's returning an absolute timestamp instead of a duration). Our code treats it as a duration: `now + Duration::seconds(tokens.expires_in_seconds)`. If forest-server is actually returning a Unix timestamp, we'd set expiry to year 2082.
|
||||||
|
|
||||||
|
The session refresh logic would never trigger, which means tokens are effectively permanent. The BFF session protects the browser from this (sessions expire by `last_seen_at` reaper), but the underlying token is never refreshed.
|
||||||
|
|
||||||
|
**Recommendation**: Verify with forest-server what `expires_in_seconds` actually means. If it's a bug, cap it to a sane maximum (e.g., 24h) client-side.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Missing: CSRF Protection on State-Mutating Endpoints
|
||||||
|
|
||||||
|
**Severity: Medium (Security)**
|
||||||
|
|
||||||
|
`POST /logout`, `POST /login`, `POST /signup`, `POST /settings/tokens`, `POST /settings/tokens/{id}/delete` all accept form submissions with no CSRF token. The `SameSite=Lax` cookie provides baseline protection against cross-origin POST from foreign sites, but:
|
||||||
|
|
||||||
|
- `SameSite=Lax` allows top-level navigations (e.g., form auto-submit from a link)
|
||||||
|
- A CSRF token is the standard defense-in-depth
|
||||||
|
|
||||||
|
**Recommendation**: Add CSRF tokens to all forms. MiniJinja can render a hidden `<input>` and the server validates it against a session-bound value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prioritized Actions
|
||||||
|
|
||||||
|
### Must Do (before next feature)
|
||||||
|
1. **Error pages**: Add 403/500 error templates (bare status codes are bad UX)
|
||||||
|
2. **Authenticated nav**: Implement app navigation for logged-in users
|
||||||
|
3. **Platform-unavailable test**: Add test for dashboard when `list_my_organisations` errors
|
||||||
|
|
||||||
|
### Should Do (this iteration)
|
||||||
|
4. **Cache org memberships in session**: Eliminate repeated `list_my_organisations` gRPC call
|
||||||
|
5. **Throttle session writes**: Only update `last_seen_at` if stale (>5min)
|
||||||
|
6. **Extract proto conversion functions**: Make them testable, add unit tests
|
||||||
|
7. **CSRF tokens**: Add to all POST forms
|
||||||
|
|
||||||
|
### Do When Relevant
|
||||||
|
8. **Unify auth helper**: Deduplicate `authed_request` / `platform_authed_request`
|
||||||
|
9. **Validate path params**: Sanitize `{org}` and `{project}` at route level
|
||||||
|
10. **Investigate `expires_in_seconds`**: Confirm forest-server semantics, cap if needed
|
||||||
|
11. **VSDD discipline**: Enforce Red Gate for future specs
|
||||||
1
static/css/input.css
Normal file
1
static/css/input.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
2
static/css/style.css
Normal file
2
static/css/style.css
Normal file
File diff suppressed because one or more lines are too long
91
templates/base.html.jinja
Normal file
91
templates/base.html.jinja
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<meta name="description" content="{{ description }}">
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-white text-gray-900 antialiased">
|
||||||
|
<nav class="border-b border-gray-200">
|
||||||
|
<div class="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
|
{% if user is defined and user %}
|
||||||
|
{# Authenticated nav #}
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<a href="/dashboard" class="text-xl font-bold tracking-tight">forage</a>
|
||||||
|
{% if current_org is defined and current_org %}
|
||||||
|
<span class="text-sm text-gray-400">/</span>
|
||||||
|
<span class="text-sm font-medium">{{ current_org }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
{% if current_org is defined and current_org %}
|
||||||
|
<a href="/orgs/{{ current_org }}/projects" class="text-sm text-gray-600 hover:text-gray-900">Projects</a>
|
||||||
|
<a href="/orgs/{{ current_org }}/usage" class="text-sm text-gray-600 hover:text-gray-900">Usage</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if orgs is defined and orgs | length > 1 %}
|
||||||
|
<details class="relative">
|
||||||
|
<summary class="text-sm text-gray-600 hover:text-gray-900 cursor-pointer list-none">
|
||||||
|
Switch org
|
||||||
|
</summary>
|
||||||
|
<div class="absolute right-0 mt-2 w-48 bg-white border border-gray-200 rounded-md shadow-lg z-10">
|
||||||
|
{% for org in orgs %}
|
||||||
|
<a href="/orgs/{{ org.name }}/projects" class="block px-4 py-2 text-sm hover:bg-gray-50">{{ org.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/settings/tokens" class="text-sm text-gray-600 hover:text-gray-900">Tokens</a>
|
||||||
|
<form method="POST" action="/logout" class="inline">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" class="text-sm text-gray-600 hover:text-gray-900">Sign out</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{# Marketing nav #}
|
||||||
|
<a href="/" class="text-xl font-bold tracking-tight">forage</a>
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<a href="/pricing" class="text-sm text-gray-600 hover:text-gray-900">Pricing</a>
|
||||||
|
<a href="/components" class="text-sm text-gray-600 hover:text-gray-900">Components</a>
|
||||||
|
<a href="/login" class="text-sm font-medium px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-800">Sign in</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="border-t border-gray-200 mt-24">
|
||||||
|
<div class="max-w-6xl mx-auto px-4 py-12">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-8">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-sm mb-3">Product</h3>
|
||||||
|
<ul class="space-y-2 text-sm text-gray-600">
|
||||||
|
<li><a href="/pricing" class="hover:text-gray-900">Pricing</a></li>
|
||||||
|
<li><a href="/components" class="hover:text-gray-900">Components</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-sm mb-3">Platform</h3>
|
||||||
|
<ul class="space-y-2 text-sm text-gray-600">
|
||||||
|
<li><a href="/signup" class="hover:text-gray-900">Get Started</a></li>
|
||||||
|
<li><a href="/login" class="hover:text-gray-900">Sign In</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-sm mb-3">Forest</h3>
|
||||||
|
<ul class="space-y-2 text-sm text-gray-600">
|
||||||
|
<li><a href="https://src.rawpotion.io/rawpotion/forest" class="hover:text-gray-900">Forest on Git</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-12 pt-8 border-t border-gray-200 text-sm text-gray-500">
|
||||||
|
© 2026 Forage. Built with Forest.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
19
templates/docker-compose.yaml
Normal file
19
templates/docker-compose.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: forage
|
||||||
|
POSTGRES_USER: forageuser
|
||||||
|
POSTGRES_PASSWORD: foragepassword
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- forage-pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U forageuser -d forage"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
forage-pgdata:
|
||||||
57
templates/forage-server.Dockerfile
Normal file
57
templates/forage-server.Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
FROM rust:1.85-bookworm AS builder
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y clang mold && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /mnt/src
|
||||||
|
|
||||||
|
# Cargo config for mold linker
|
||||||
|
RUN mkdir -p /usr/local/cargo && \
|
||||||
|
printf '[target.x86_64-unknown-linux-gnu]\nlinker = "clang"\nrustflags = ["-C", "link-arg=-fuse-ld=mold"]\n' \
|
||||||
|
> /usr/local/cargo/config.toml
|
||||||
|
|
||||||
|
ENV SQLX_OFFLINE=true
|
||||||
|
|
||||||
|
# Copy manifests first for dependency caching
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY crates/forage-server/Cargo.toml crates/forage-server/Cargo.toml
|
||||||
|
COPY crates/forage-core/Cargo.toml crates/forage-core/Cargo.toml
|
||||||
|
COPY crates/forage-db/Cargo.toml crates/forage-db/Cargo.toml
|
||||||
|
|
||||||
|
# Create skeleton source files for dependency build
|
||||||
|
RUN mkdir -p crates/forage-server/src && echo 'fn main() {}' > crates/forage-server/src/main.rs && \
|
||||||
|
mkdir -p crates/forage-core/src && echo '' > crates/forage-core/src/lib.rs && \
|
||||||
|
mkdir -p crates/forage-db/src && echo '' > crates/forage-db/src/lib.rs
|
||||||
|
|
||||||
|
# Build dependencies only (cacheable layer)
|
||||||
|
RUN cargo build --release -p forage-server 2>/dev/null || true
|
||||||
|
|
||||||
|
# Copy real source
|
||||||
|
COPY crates/ crates/
|
||||||
|
COPY templates/ templates/
|
||||||
|
COPY static/ static/
|
||||||
|
COPY .sqlx/ .sqlx/
|
||||||
|
|
||||||
|
# Touch source files to invalidate the skeleton build
|
||||||
|
RUN find crates -name "*.rs" -exec touch {} +
|
||||||
|
|
||||||
|
# Build the real binary
|
||||||
|
RUN cargo build --release -p forage-server
|
||||||
|
|
||||||
|
# Verify it runs
|
||||||
|
RUN ./target/release/forage-server --help || true
|
||||||
|
|
||||||
|
# Runtime image
|
||||||
|
FROM gcr.io/distroless/cc-debian12:latest
|
||||||
|
|
||||||
|
COPY --from=builder /mnt/src/target/release/forage-server /usr/local/bin/forage-server
|
||||||
|
COPY --from=builder /mnt/src/templates /templates
|
||||||
|
COPY --from=builder /mnt/src/static /static
|
||||||
|
|
||||||
|
WORKDIR /
|
||||||
|
ENV FORAGE_TEMPLATES_PATH=/templates
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/forage-server"]
|
||||||
28
templates/pages/components.html.jinja
Normal file
28
templates/pages/components.html.jinja
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "base.html.jinja" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="max-w-4xl mx-auto px-4 pt-24 pb-8 text-center">
|
||||||
|
<h1 class="text-4xl font-bold tracking-tight">Component Registry</h1>
|
||||||
|
<p class="mt-4 text-lg text-gray-600">
|
||||||
|
Discover and share reusable forest components. Deployment patterns,
|
||||||
|
service templates, and infrastructure modules - all in one place.
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-gray-400">The component registry is coming soon.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||||
|
<div class="p-8 border border-gray-200 rounded-lg text-center">
|
||||||
|
<p class="text-gray-500 mb-4">
|
||||||
|
Components will be browsable here once the registry is live.
|
||||||
|
In the meantime, you can publish components via the forest CLI.
|
||||||
|
</p>
|
||||||
|
<div class="bg-gray-950 rounded-md p-4 text-sm font-mono text-gray-300 text-left max-w-lg mx-auto">
|
||||||
|
<pre><span class="text-gray-500"># Publish a component</span>
|
||||||
|
forest components publish ./my-component
|
||||||
|
|
||||||
|
<span class="text-gray-500"># Use a component</span>
|
||||||
|
forest components add forage/service@1.0</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
57
templates/pages/dashboard.html.jinja
Normal file
57
templates/pages/dashboard.html.jinja
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{% extends "base.html.jinja" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="max-w-4xl mx-auto px-4 pt-12">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Welcome, {{ user.username }}</h1>
|
||||||
|
<p class="text-sm text-gray-600 mt-1">{{ user.emails[0] if user.emails }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<a href="/settings/tokens" class="px-4 py-2 text-sm border border-gray-300 rounded-md hover:border-gray-400">
|
||||||
|
API Tokens
|
||||||
|
</a>
|
||||||
|
<form method="POST" action="/logout">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" class="px-4 py-2 text-sm border border-gray-300 rounded-md hover:border-gray-400">
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 border border-gray-200 rounded-lg mb-6">
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Forage is in early access. Container deployments and the component registry
|
||||||
|
are under active development. You can manage your API tokens now and deploy
|
||||||
|
once the platform is live.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div class="p-6 border border-gray-200 rounded-lg">
|
||||||
|
<h2 class="font-bold text-lg mb-4">Projects</h2>
|
||||||
|
<p class="text-sm text-gray-600">No projects yet. Deploy your first forest.cue manifest to get started.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 border border-gray-200 rounded-lg">
|
||||||
|
<h2 class="font-bold text-lg mb-4">Organisations</h2>
|
||||||
|
<p class="text-sm text-gray-600">You're not part of any organisation yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 p-6 border border-gray-200 rounded-lg">
|
||||||
|
<h2 class="font-bold text-lg mb-4">Quick start</h2>
|
||||||
|
<div class="bg-gray-950 rounded-md p-4 text-sm font-mono text-gray-300">
|
||||||
|
<pre><span class="text-gray-500"># Install forest CLI</span>
|
||||||
|
cargo install forest
|
||||||
|
|
||||||
|
<span class="text-gray-500"># Create a project</span>
|
||||||
|
forest init my-project --component forage/service
|
||||||
|
|
||||||
|
<span class="text-gray-500"># Deploy</span>
|
||||||
|
forest release create --env dev</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
10
templates/pages/error.html.jinja
Normal file
10
templates/pages/error.html.jinja
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{% extends "base.html.jinja" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="max-w-lg mx-auto px-4 pt-24 text-center">
|
||||||
|
<p class="text-6xl font-bold text-gray-300">{{ status }}</p>
|
||||||
|
<h1 class="mt-4 text-2xl font-bold">{{ heading }}</h1>
|
||||||
|
<p class="mt-2 text-gray-600">{{ message }}</p>
|
||||||
|
<a href="/" class="inline-block mt-8 text-sm text-gray-500 hover:text-gray-700">← Back to home</a>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
104
templates/pages/landing.html.jinja
Normal file
104
templates/pages/landing.html.jinja
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
{% extends "base.html.jinja" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="max-w-4xl mx-auto px-4 pt-24 pb-16 text-center">
|
||||||
|
<h1 class="text-5xl font-bold tracking-tight leading-tight">
|
||||||
|
Push a manifest.<br>Get production infrastructure.
|
||||||
|
</h1>
|
||||||
|
<p class="mt-6 text-xl text-gray-600 max-w-2xl mx-auto">
|
||||||
|
Forage is the managed platform for Forest. Define your infrastructure in a
|
||||||
|
<code class="bg-gray-100 px-2 py-0.5 rounded text-base">forest.cue</code> file,
|
||||||
|
push it, and we handle the rest.
|
||||||
|
</p>
|
||||||
|
<div class="mt-10 flex items-center justify-center gap-4">
|
||||||
|
<a href="/signup" class="px-6 py-3 bg-gray-900 text-white rounded-md font-medium hover:bg-gray-800">
|
||||||
|
Get started free
|
||||||
|
</a>
|
||||||
|
<a href="/pricing" class="px-6 py-3 border border-gray-300 rounded-md font-medium hover:border-gray-400">
|
||||||
|
View pricing
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="max-w-4xl mx-auto px-4 py-16">
|
||||||
|
<div class="bg-gray-950 rounded-lg p-6 text-sm font-mono text-gray-300 overflow-x-auto">
|
||||||
|
<pre><span class="text-gray-500">// forest.cue</span>
|
||||||
|
<span class="text-blue-400">project</span>: {
|
||||||
|
<span class="text-green-400">name</span>: <span class="text-amber-400">"my-api"</span>
|
||||||
|
<span class="text-green-400">organisation</span>: <span class="text-amber-400">"acme"</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="text-blue-400">dependencies</span>: {
|
||||||
|
<span class="text-amber-400">"forage/service"</span>: <span class="text-green-400">version</span>: <span class="text-amber-400">"1.0"</span>
|
||||||
|
<span class="text-amber-400">"forage/postgres"</span>: <span class="text-green-400">version</span>: <span class="text-amber-400">"1.0"</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="text-blue-400">forage</span>: <span class="text-blue-400">service</span>: {
|
||||||
|
<span class="text-green-400">config</span>: {
|
||||||
|
<span class="text-green-400">name</span>: <span class="text-amber-400">"my-api"</span>
|
||||||
|
<span class="text-green-400">image</span>: <span class="text-amber-400">"my-api:latest"</span>
|
||||||
|
<span class="text-green-400">ports</span>: [{ <span class="text-green-400">container</span>: <span class="text-purple-400">8080</span>, <span class="text-green-400">protocol</span>: <span class="text-amber-400">"http"</span> }]
|
||||||
|
}
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<code class="text-sm text-gray-500">$ forest release create --env prod</code>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="max-w-6xl mx-auto px-4 py-16">
|
||||||
|
<h2 class="text-3xl font-bold text-center mb-12">Everything you need to ship</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
<div class="p-6 border border-gray-200 rounded-lg">
|
||||||
|
<h3 class="font-bold text-lg mb-2">Component Registry</h3>
|
||||||
|
<p class="text-gray-600 text-sm">
|
||||||
|
Publish and discover reusable forest components. Share deployment patterns,
|
||||||
|
service templates, and infrastructure modules across your organisation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 border border-gray-200 rounded-lg">
|
||||||
|
<h3 class="font-bold text-lg mb-2">Managed Deployments</h3>
|
||||||
|
<p class="text-gray-600 text-sm">
|
||||||
|
Zero-config container runtime. Push your manifest and get automatic scaling,
|
||||||
|
health checks, rollbacks, and multi-environment support.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 border border-gray-200 rounded-lg">
|
||||||
|
<h3 class="font-bold text-lg mb-2">Container Deployments</h3>
|
||||||
|
<p class="text-gray-600 text-sm">
|
||||||
|
Push your containers and let Forage handle the runtime. No Kubernetes
|
||||||
|
knowledge needed. Pay only for what you use.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 border border-gray-200 rounded-lg">
|
||||||
|
<h3 class="font-bold text-lg mb-2">Type-Safe Infrastructure</h3>
|
||||||
|
<p class="text-gray-600 text-sm">
|
||||||
|
CUE gives you typed, validated infrastructure definitions. Catch configuration
|
||||||
|
errors before they reach production.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 border border-gray-200 rounded-lg">
|
||||||
|
<h3 class="font-bold text-lg mb-2">Team Management</h3>
|
||||||
|
<p class="text-gray-600 text-sm">
|
||||||
|
Organisations, role-based access, and audit logs. Manage who can deploy
|
||||||
|
what, where, and when.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 border border-gray-200 rounded-lg">
|
||||||
|
<h3 class="font-bold text-lg mb-2">Pay As You Go</h3>
|
||||||
|
<p class="text-gray-600 text-sm">
|
||||||
|
Transparent, usage-based pricing. Start free, scale smoothly. No surprise
|
||||||
|
bills, no hidden fees.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="max-w-4xl mx-auto px-4 py-16 text-center">
|
||||||
|
<h2 class="text-3xl font-bold mb-4">Ready to simplify your infrastructure?</h2>
|
||||||
|
<p class="text-gray-600 mb-8">Join the waitlist and be first to try Forage.</p>
|
||||||
|
<a href="/signup" class="px-6 py-3 bg-gray-900 text-white rounded-md font-medium hover:bg-gray-800">
|
||||||
|
Get started free
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
48
templates/pages/login.html.jinja
Normal file
48
templates/pages/login.html.jinja
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "base.html.jinja" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="max-w-md mx-auto px-4 pt-24">
|
||||||
|
<h1 class="text-2xl font-bold text-center mb-8">Sign in to Forage</h1>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-md text-sm text-red-700">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="/login" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="identifier" class="block text-sm font-medium mb-1">Username or email</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="identifier"
|
||||||
|
name="identifier"
|
||||||
|
value="{{ identifier }}"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-gray-900"
|
||||||
|
placeholder="alice or alice@example.com">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-gray-900"
|
||||||
|
placeholder="Your password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full py-2 bg-gray-900 text-white rounded-md font-medium hover:bg-gray-800">
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-6 text-center text-sm text-gray-600">
|
||||||
|
Don't have an account? <a href="/signup" class="font-medium text-gray-900 hover:underline">Create one</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
26
templates/pages/onboarding.html.jinja
Normal file
26
templates/pages/onboarding.html.jinja
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends "base.html.jinja" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="max-w-4xl mx-auto px-4 pt-12 text-center">
|
||||||
|
<h1 class="text-2xl font-bold">Welcome to Forage</h1>
|
||||||
|
<p class="mt-4 text-gray-600">Create your first organisation with the forest CLI to get started.</p>
|
||||||
|
|
||||||
|
<div class="mt-8 max-w-lg mx-auto bg-gray-950 rounded-md p-4 text-sm font-mono text-gray-300 text-left">
|
||||||
|
<pre><span class="text-gray-500"># Install forest CLI</span>
|
||||||
|
cargo install forest
|
||||||
|
|
||||||
|
<span class="text-gray-500"># Create an organisation</span>
|
||||||
|
forest orgs create my-org
|
||||||
|
|
||||||
|
<span class="text-gray-500"># Create a project</span>
|
||||||
|
forest init my-project --component forage/service
|
||||||
|
|
||||||
|
<span class="text-gray-500"># Deploy</span>
|
||||||
|
forest release create --env dev</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<a href="/settings/tokens" class="text-sm text-gray-500 hover:text-gray-700">Manage API tokens →</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
123
templates/pages/pricing.html.jinja
Normal file
123
templates/pages/pricing.html.jinja
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
{% extends "base.html.jinja" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="max-w-4xl mx-auto px-4 pt-24 pb-8 text-center">
|
||||||
|
<h1 class="text-4xl font-bold tracking-tight">Simple, transparent pricing</h1>
|
||||||
|
<p class="mt-4 text-lg text-gray-600">Start free. Scale when you need to. No surprises.</p>
|
||||||
|
<p class="mt-2 text-sm text-gray-400">Forage is in early access. Pricing may change.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="max-w-6xl mx-auto px-4 py-12">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
|
<div class="p-6 border border-gray-200 rounded-lg">
|
||||||
|
<h3 class="font-bold text-lg">Free</h3>
|
||||||
|
<p class="mt-2 text-3xl font-bold">$0</p>
|
||||||
|
<p class="text-sm text-gray-500">forever</p>
|
||||||
|
<ul class="mt-6 space-y-3 text-sm">
|
||||||
|
<li>1 project</li>
|
||||||
|
<li>1 environment</li>
|
||||||
|
<li>256MB RAM, shared CPU</li>
|
||||||
|
<li>Community components</li>
|
||||||
|
</ul>
|
||||||
|
<a href="/signup" class="mt-8 block text-center py-2 border border-gray-300 rounded-md text-sm font-medium hover:border-gray-400">
|
||||||
|
Get started
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 border border-gray-200 rounded-lg">
|
||||||
|
<h3 class="font-bold text-lg">Developer</h3>
|
||||||
|
<p class="mt-2 text-3xl font-bold">$10<span class="text-base font-normal text-gray-500">/mo</span></p>
|
||||||
|
<p class="text-sm text-gray-500">per user</p>
|
||||||
|
<ul class="mt-6 space-y-3 text-sm">
|
||||||
|
<li>3 projects</li>
|
||||||
|
<li>3 environments each</li>
|
||||||
|
<li>512MB RAM, dedicated CPU</li>
|
||||||
|
<li>Custom domains</li>
|
||||||
|
</ul>
|
||||||
|
<a href="/signup" class="mt-8 block text-center py-2 bg-gray-900 text-white rounded-md text-sm font-medium hover:bg-gray-800">
|
||||||
|
Start trial
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 border-2 border-gray-900 rounded-lg relative">
|
||||||
|
<span class="absolute -top-3 left-4 bg-gray-900 text-white text-xs px-2 py-0.5 rounded">Popular</span>
|
||||||
|
<h3 class="font-bold text-lg">Team</h3>
|
||||||
|
<p class="mt-2 text-3xl font-bold">$25<span class="text-base font-normal text-gray-500">/user/mo</span></p>
|
||||||
|
<p class="text-sm text-gray-500">billed monthly</p>
|
||||||
|
<ul class="mt-6 space-y-3 text-sm">
|
||||||
|
<li>Unlimited projects</li>
|
||||||
|
<li>Unlimited environments</li>
|
||||||
|
<li>Up to 4GB RAM, 2 vCPU</li>
|
||||||
|
<li>Private component registry</li>
|
||||||
|
<li>Team management, RBAC</li>
|
||||||
|
</ul>
|
||||||
|
<a href="/signup" class="mt-8 block text-center py-2 bg-gray-900 text-white rounded-md text-sm font-medium hover:bg-gray-800">
|
||||||
|
Start trial
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 border border-gray-200 rounded-lg">
|
||||||
|
<h3 class="font-bold text-lg">Enterprise</h3>
|
||||||
|
<p class="mt-2 text-3xl font-bold">Custom</p>
|
||||||
|
<p class="text-sm text-gray-500">tailored to your needs</p>
|
||||||
|
<ul class="mt-6 space-y-3 text-sm">
|
||||||
|
<li>Dedicated infrastructure</li>
|
||||||
|
<li>SLA guarantees</li>
|
||||||
|
<li>SSO / SAML</li>
|
||||||
|
<li>Audit logs</li>
|
||||||
|
<li>Priority support</li>
|
||||||
|
</ul>
|
||||||
|
<a href="mailto:sales@forage.sh" class="mt-8 block text-center py-2 border border-gray-300 rounded-md text-sm font-medium hover:border-gray-400">
|
||||||
|
Contact sales
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||||
|
<h2 class="text-2xl font-bold text-center mb-8">Usage-based add-ons</h2>
|
||||||
|
<div class="overflow-hidden border border-gray-200 rounded-lg">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-6 py-3 font-medium">Resource</th>
|
||||||
|
<th class="text-left px-6 py-3 font-medium">Price</th>
|
||||||
|
<th class="text-left px-6 py-3 font-medium">Included free</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-3">Compute (vCPU)</td>
|
||||||
|
<td class="px-6 py-3">$0.05/hour</td>
|
||||||
|
<td class="px-6 py-3">Varies by plan</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-3">Memory</td>
|
||||||
|
<td class="px-6 py-3">$0.01/GB-hour</td>
|
||||||
|
<td class="px-6 py-3">Varies by plan</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-3">Storage</td>
|
||||||
|
<td class="px-6 py-3">$0.10/GB-month</td>
|
||||||
|
<td class="px-6 py-3">1GB</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-3">Bandwidth</td>
|
||||||
|
<td class="px-6 py-3">$0.05/GB</td>
|
||||||
|
<td class="px-6 py-3">10GB</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="text-gray-400">
|
||||||
|
<td class="px-6 py-3">Managed Databases <span class="text-xs">(coming soon)</span></td>
|
||||||
|
<td class="px-6 py-3">TBD</td>
|
||||||
|
<td class="px-6 py-3">-</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="text-gray-400">
|
||||||
|
<td class="px-6 py-3">Managed Services <span class="text-xs">(coming soon)</span></td>
|
||||||
|
<td class="px-6 py-3">TBD</td>
|
||||||
|
<td class="px-6 py-3">-</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
40
templates/pages/project_detail.html.jinja
Normal file
40
templates/pages/project_detail.html.jinja
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends "base.html.jinja" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="max-w-4xl mx-auto px-4 pt-12">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<a href="/orgs/{{ org_name }}/projects" class="text-sm text-gray-500 hover:text-gray-700">← {{ org_name }}</a>
|
||||||
|
<h1 class="text-2xl font-bold mt-1">{{ project_name }}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="font-bold text-lg mb-4">Recent releases</h2>
|
||||||
|
|
||||||
|
{% if artifacts %}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% for artifact in artifacts %}
|
||||||
|
<div class="p-4 border border-gray-200 rounded-lg">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{{ artifact.title }}</p>
|
||||||
|
{% if artifact.description %}
|
||||||
|
<p class="text-sm text-gray-600 mt-1">{{ artifact.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-right text-sm text-gray-500">
|
||||||
|
<p class="font-mono">{{ artifact.slug }}</p>
|
||||||
|
<p>{{ artifact.created_at }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-6 border border-gray-200 rounded-lg text-center">
|
||||||
|
<p class="text-gray-600">No releases yet.</p>
|
||||||
|
<p class="text-sm text-gray-400 mt-2">Create a release with <code class="bg-gray-100 px-1 rounded">forest release create</code></p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
24
templates/pages/projects.html.jinja
Normal file
24
templates/pages/projects.html.jinja
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends "base.html.jinja" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="max-w-4xl mx-auto px-4 pt-12">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-2xl font-bold">Projects</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if projects %}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% for project in projects %}
|
||||||
|
<a href="/orgs/{{ org_name }}/projects/{{ project }}" class="block p-6 border border-gray-200 rounded-lg hover:border-gray-400">
|
||||||
|
<h2 class="font-bold text-lg">{{ project }}</h2>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-6 border border-gray-200 rounded-lg text-center">
|
||||||
|
<p class="text-gray-600">No projects yet.</p>
|
||||||
|
<p class="text-sm text-gray-400 mt-2">Deploy with <code class="bg-gray-100 px-1 rounded">forest release create</code></p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
75
templates/pages/signup.html.jinja
Normal file
75
templates/pages/signup.html.jinja
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{% extends "base.html.jinja" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="max-w-md mx-auto px-4 pt-24">
|
||||||
|
<h1 class="text-2xl font-bold text-center mb-8">Create your account</h1>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-md text-sm text-red-700">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="/signup" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium mb-1">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
value="{{ username }}"
|
||||||
|
required
|
||||||
|
minlength="3"
|
||||||
|
maxlength="64"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-gray-900"
|
||||||
|
placeholder="alice">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
value="{{ email }}"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-gray-900"
|
||||||
|
placeholder="alice@example.com">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
minlength="12"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-gray-900"
|
||||||
|
placeholder="At least 12 characters">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password_confirm" class="block text-sm font-medium mb-1">Confirm password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password_confirm"
|
||||||
|
name="password_confirm"
|
||||||
|
required
|
||||||
|
minlength="12"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-gray-900"
|
||||||
|
placeholder="Repeat your password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full py-2 bg-gray-900 text-white rounded-md font-medium hover:bg-gray-800">
|
||||||
|
Create account
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-6 text-center text-sm text-gray-600">
|
||||||
|
Already have an account? <a href="/login" class="font-medium text-gray-900 hover:underline">Sign in</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
71
templates/pages/tokens.html.jinja
Normal file
71
templates/pages/tokens.html.jinja
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{% extends "base.html.jinja" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="max-w-4xl mx-auto px-4 pt-12">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<h1 class="text-2xl font-bold">Personal Access Tokens</h1>
|
||||||
|
<a href="/dashboard" class="text-sm text-gray-600 hover:text-gray-900">← Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if created_token %}
|
||||||
|
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-md">
|
||||||
|
<p class="text-sm font-medium text-green-800 mb-2">Token created successfully. Copy it now - it won't be shown again.</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="flex-1 px-3 py-2 bg-white border border-green-300 rounded text-sm font-mono break-all">{{ created_token }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mb-8 p-6 border border-gray-200 rounded-lg">
|
||||||
|
<h2 class="font-bold mb-4">Create new token</h2>
|
||||||
|
<form method="POST" action="/settings/tokens" class="flex gap-3">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
placeholder="Token name (e.g. CI/CD)"
|
||||||
|
class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-gray-900 text-white rounded-md text-sm font-medium hover:bg-gray-800">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if tokens %}
|
||||||
|
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-6 py-3 font-medium">Name</th>
|
||||||
|
<th class="text-left px-6 py-3 font-medium">Created</th>
|
||||||
|
<th class="text-left px-6 py-3 font-medium">Last used</th>
|
||||||
|
<th class="text-left px-6 py-3 font-medium">Expires</th>
|
||||||
|
<th class="px-6 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
{% for token in tokens %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-3 font-medium">{{ token.name }}</td>
|
||||||
|
<td class="px-6 py-3 text-gray-600">{{ token.created_at or "—" }}</td>
|
||||||
|
<td class="px-6 py-3 text-gray-600">{{ token.last_used or "Never" }}</td>
|
||||||
|
<td class="px-6 py-3 text-gray-600">{{ token.expires_at or "Never" }}</td>
|
||||||
|
<td class="px-6 py-3 text-right">
|
||||||
|
<form method="POST" action="/settings/tokens/{{ token.token_id }}/delete">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" class="text-red-600 hover:text-red-800 text-sm">Revoke</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-gray-600">No tokens yet. Create one to use with the forest CLI.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
55
templates/pages/usage.html.jinja
Normal file
55
templates/pages/usage.html.jinja
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{% extends "base.html.jinja" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="max-w-4xl mx-auto px-4 pt-12">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<a href="/orgs/{{ org_name }}/projects" class="text-sm text-gray-500 hover:text-gray-700">← {{ org_name }}</a>
|
||||||
|
<h1 class="text-2xl font-bold mt-1">Usage & Plan</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 border border-gray-200 rounded-lg mb-6">
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Forage is in early access. Usage metering and billing are not yet active.
|
||||||
|
All accounts currently have free access to the platform.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||||
|
<div class="p-6 border border-gray-200 rounded-lg">
|
||||||
|
<h2 class="font-bold text-lg mb-2">Current Plan</h2>
|
||||||
|
<p class="text-2xl font-bold">Early Access</p>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Free during early access</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 border border-gray-200 rounded-lg">
|
||||||
|
<h2 class="font-bold text-lg mb-2">Organisation</h2>
|
||||||
|
<p class="text-2xl font-bold">{{ org_name }}</p>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Role: {{ role }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 border border-gray-200 rounded-lg">
|
||||||
|
<h2 class="font-bold text-lg mb-4">Resource Usage</h2>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600">Projects</span>
|
||||||
|
<span>{{ project_count }} used</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600">Compute</span>
|
||||||
|
<span class="text-gray-400">Not yet metered</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600">Storage</span>
|
||||||
|
<span class="text-gray-400">Not yet metered</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 text-center">
|
||||||
|
<a href="/pricing" class="text-sm text-gray-500 hover:text-gray-700">View full pricing →</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user