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
|
||||
Reference in New Issue
Block a user