feat: add basic website

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-07 19:46:13 +01:00
commit b439762877
71 changed files with 16576 additions and 0 deletions

212
specs/PITCH.md Normal file
View 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
View 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

View 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

View 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.

View 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"?

View 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?

View 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.

View 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