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