setup local dev env

This commit is contained in:
2026-03-10 17:13:23 +01:00
parent b27db93a90
commit f4d3fade9b
61 changed files with 6695 additions and 228 deletions

View File

@@ -1,7 +1,23 @@
# Backend
DATABASE_URL=postgres://campaign_manager:devpassword@localhost:5432/campaign_manager
# Campaign service
USERS_DATABASE_URL=postgres://cm_campaign_app:devpassword@localhost:5432/cm_users
CAMPAIGN_DATABASE_URL=postgres://cm_campaign_app:devpassword@localhost:5432/cm_campaign
CAMPAIGN_PORT=3000
# Content service
CONTENT_DATABASE_URL=postgres://cm_content_app:devpassword@localhost:5432/cm_content
CONTENT_PORT=3001
# Shared auth
JWT_SECRET=change-me-to-a-long-random-secret
PORT=3000
# Web BFF upstreams (server-side only)
CAMPAIGN_API_BASE=http://localhost:3000/v1
CONTENT_API_BASE=http://localhost:3001/v1
WEB_PORT=5173
# Symbiote direct upstreams (build-time)
VITE_CAMPAIGN_API_BASE=http://localhost:3000/v1
VITE_CONTENT_API_BASE=http://localhost:3001/v1
# Object storage (Cloudflare R2 or any S3-compatible)
S3_ENDPOINT=https://<accountid>.r2.cloudflarestorage.com

2
.gitignore vendored
View File

@@ -2,6 +2,7 @@
node_modules/
dist/
.svelte-kit/
web/build/
.env
.env.*
!.env.example
@@ -12,7 +13,6 @@ pnpm-debug.log*
# Rust
target/
Cargo.lock
# OS
.DS_Store

29
.woodpecker.yml Normal file
View File

@@ -0,0 +1,29 @@
steps:
js-build:
image: node:current-alpine
commands:
- corepack enable
- pnpm install --frozen-lockfile
- pnpm build:web
- pnpm build:symbiote
rust-check:
image: rust:alpine
commands:
- apk add --no-cache build-base pkgconfig openssl-dev
- rustup component add rustfmt
- cargo fmt --manifest-path backend/Cargo.toml --all -- --check
- cargo check --manifest-path backend/Cargo.toml --workspace
compose-validate:
image: docker:cli
commands:
- apk add --no-cache docker-cli-compose
- docker compose version
- docker compose config
when:
event:
- push
- pull_request
- manual

2794
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +0,0 @@
[workspace]
members = ["backend"]
resolver = "2"

139
README.md
View File

@@ -1,66 +1,97 @@
Root
- .gitignore, pnpm-workspace.yaml, package.json (workspace scripts), Cargo.toml (workspace), .npmrc
rulesets/ — @campaign-manager/rulesets workspace package
- types.ts — Ruleset, SheetData, ValidationResult, DiceAction, StatBlock, VNode interfaces
- dsa5e/index.ts — DSA5e stub (typed default sheet with attributes, talents, combat, spells)
- generic/index.ts — Generic stub (name, HP, notes)
- index.ts — re-exports everything + RULESETS registry map
# Campaign Manager
symbiote/ — Plain Svelte + Vite (target es2022 for top-level await support)
- vite.config.ts, tsconfig.json, index.html
- public/manifest.json — valid TaleSpire manifest with colorStyles, fonts, diceFinder, icons extras
- src/lib/ts-api.d.ts — ambient TS global (storage, dice, players, sync)
- src/lib/store.ts — accessToken, refreshToken, currentUser, currentGroup, isBoardTransition stores +
loadTokens/saveTokens/clearTokens via TS.storage
- src/lib/api.ts — auto-refresh on 401, suppresses calls during board transition
- src/App.svelte — currentView store drives view switching
- src/main.ts — board transition listeners, awaits TS.hasInitialized, mounts App
- src/views/ — Login, GroupList, GroupDetail, CharacterSheet, RollHistory stubs
Campaign Manager is split into two Rust backend services, a SvelteKit web app, and a TaleSpire Symbiote frontend.
web/ — SvelteKit + adapter-static
- svelte.config.js, vite.config.ts, tsconfig.json, src/app.html
- src/lib/api.ts — access token in memory, refresh via HttpOnly cookie
- src/routes/+layout.svelte — silent token refresh on mount, redirects to /login if unauthenticated
- All routes stubbed: /, /login, /groups, /groups/[id], /groups/[id]/settings, /characters, /characters/[id]
## Architecture
backend/ — Rust + Axum
- Cargo.toml with all specified dependencies
- src/main.rs — reads DATABASE_URL/JWT_SECRET, builds Axum router, CORS, tracing, binds :3000
- src/lib.rs — module entrypoint
- `backend/common`: shared Rust crate for JWT and API error primitives.
- `backend/campaign-service`: auth + campaign domain service.
- `backend/content-service`: ruleset/content domain service.
- `web`: SvelteKit Node server that uses server-side BFF proxy routes (`/api/*`).
- `symbiote`: static Svelte app for TaleSpire.
Verification results: ✅ symbiote build → dist/ with index.html + manifest.json ✅ web build → SvelteKit static output
✅ cargo check → no errors
## Data layout
All databases run on one Postgres instance with separate logical databases:
Local dev workflow is now:
# Start Postgres
docker compose up -d
# Copy and fill in secrets
cp .env.example .env
- `cm_users`: users/auth/session data.
- `cm_campaign`: campaign and character data.
- `cm_content`: rulesets/content data.
# Run backend
cd backend && cargo run
## Environment
# Symbiote (build-watch mode — see below)
pnpm dev:symbiote
Copy `.env.example` to `.env` and set values.
# Web app
pnpm dev:web Local dev workflow is now:
# Start Postgres
docker compose up -d
# Copy and fill in secrets
cp .env.example .env
Important variables:
# Run backend
cd backend && cargo run
- `USERS_DATABASE_URL`
- `CAMPAIGN_DATABASE_URL`
- `CONTENT_DATABASE_URL`
- `JWT_SECRET`
- `CAMPAIGN_API_BASE` / `CONTENT_API_BASE` (web BFF upstreams)
- `VITE_CAMPAIGN_API_BASE` / `VITE_CONTENT_API_BASE` (symbiote build-time)
# Symbiote (build-watch mode — see below)
pnpm dev:symbiote
## Local development
# Web app
pnpm dev:web
```bash
# Install JS dependencies
pnpm install
# Run campaign service
pnpm dev:campaign
# Run content service
pnpm dev:content
# Run web app (SvelteKit dev server)
pnpm dev:web
# Run symbiote app
pnpm dev:symbiote
```
Build commands:
```bash
pnpm build:campaign
pnpm build:content
pnpm build:backend
pnpm build:web
pnpm build:symbiote
```
Run the same checks as CI locally:
```bash
pnpm ci:local
```
## Web BFF routes
The web app proxies backend calls via SvelteKit server routes:
- `/api/auth/*` -> campaign service (`CAMPAIGN_API_BASE`)
- `/api/campaign/*` -> campaign service (`CAMPAIGN_API_BASE`)
- `/api/content/*` -> content service (`CONTENT_API_BASE`)
The web app is intended to run as a server process (Node adapter) in production.
## Docker compose
`docker-compose.yml` runs both backend services plus the web server and expects an external `dev-infra` network for Postgres connectivity.
## Woodpecker without Public Ingress
If your Gitea is remote and your Woodpecker server runs locally, push/PR webhooks cannot reach your machine without a public endpoint.
Recommended workflow until ingress/tunnel exists:
- Run `pnpm ci:local` before push.
- Use local Woodpecker only for manual runs.
Local stack files are in `../../infra/woodpecker` from this directory:
```bash
cp ../../infra/woodpecker/.env.example ../../infra/woodpecker/.env
docker compose -f ../../infra/woodpecker/docker-compose.yml up -d
```

2842
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,19 @@
[package]
name = "campaign-manager-backend"
version = "0.1.0"
edition = "2021"
[workspace]
members = ["common", "campaign-service", "content-service"]
resolver = "2"
[dependencies]
[workspace.dependencies]
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["postgres", "uuid", "runtime-tokio-native-tls", "chrono"] }
chrono = { version = "0.4", features = ["serde", "clock"] }
dotenvy = "0.15"
jsonwebtoken = "9"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
jsonwebtoken = "9"
argon2 = "0.5"
uuid = { version = "1", features = ["v4", "serde"] }
sqlx = { version = "0.8", features = ["postgres", "uuid", "runtime-tokio-native-tls", "chrono"] }
thiserror = "1"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"
uuid = { version = "1", features = ["v4", "serde"] }
argon2 = "0.5"

View File

@@ -0,0 +1,18 @@
[package]
name = "campaign-service"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
argon2 = { workspace = true }
common = { path = "../common" }
dotenvy = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
tokio = { workspace = true }
tower-http = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }

View File

@@ -0,0 +1,14 @@
FROM rust:alpine AS builder
WORKDIR /app
RUN apk add --no-cache build-base pkgconfig openssl-dev
COPY backend ./backend
RUN cargo build --release --manifest-path backend/Cargo.toml -p campaign-service
FROM alpine:3.23
RUN apk add --no-cache ca-certificates openssl libgcc libstdc++
COPY --from=builder /app/backend/target/release/campaign-service /usr/local/bin/campaign-service
EXPOSE 3000
CMD ["campaign-service"]

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS groups (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
dm_user_id UUID NOT NULL,
ruleset_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS group_members (
group_id UUID NOT NULL REFERENCES groups(id),
user_id UUID NOT NULL,
role TEXT NOT NULL,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (group_id, user_id)
);

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS refresh_sessions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,118 @@
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{any, get, post},
Json, Router,
};
use common::{issue_access_token, AppResult};
use serde::Serialize;
use sqlx::{postgres::PgPoolOptions, PgPool};
use tower_http::cors::CorsLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Clone)]
struct AppState {
users_pool: PgPool,
campaign_pool: PgPool,
jwt_secret: String,
}
#[derive(Serialize)]
struct HealthResponse {
service: &'static str,
status: &'static str,
}
#[derive(Serialize)]
struct RefreshResponse {
#[serde(rename = "accessToken")]
access_token: String,
#[serde(rename = "refreshToken")]
refresh_token: String,
}
#[tokio::main]
async fn main() {
let _ = dotenvy::dotenv();
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "campaign_service=debug,tower_http=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let users_database_url =
std::env::var("USERS_DATABASE_URL").expect("USERS_DATABASE_URL must be set");
let campaign_database_url =
std::env::var("CAMPAIGN_DATABASE_URL").expect("CAMPAIGN_DATABASE_URL must be set");
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let port: u16 = std::env::var("CAMPAIGN_PORT")
.or_else(|_| std::env::var("PORT"))
.unwrap_or_else(|_| "3000".to_string())
.parse()
.expect("PORT must be a valid u16");
let users_pool = PgPoolOptions::new()
.max_connections(10)
.connect_lazy(&users_database_url)
.expect("invalid USERS_DATABASE_URL");
let campaign_pool = PgPoolOptions::new()
.max_connections(10)
.connect_lazy(&campaign_database_url)
.expect("invalid CAMPAIGN_DATABASE_URL");
let state = AppState {
users_pool,
campaign_pool,
jwt_secret,
};
let app = Router::new()
.route("/health", get(health))
.route("/v1/auth/refresh", post(refresh_token))
.route("/v1/auth/login", post(not_implemented))
.route("/v1/auth/register", post(not_implemented))
.route("/v1/auth/logout", post(not_implemented))
.route("/v1/*path", any(not_implemented))
.with_state(state)
.layer(CorsLayer::permissive());
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
.await
.expect("failed to bind");
tracing::info!(
"campaign-service listening on {}",
listener.local_addr().unwrap()
);
axum::serve(listener, app).await.expect("server error");
}
async fn health(State(state): State<AppState>) -> impl IntoResponse {
let _ = &state.users_pool;
let _ = &state.campaign_pool;
Json(HealthResponse {
service: "campaign-service",
status: "ok",
})
}
async fn refresh_token(State(state): State<AppState>) -> AppResult<Json<RefreshResponse>> {
// Stub response until real refresh-token/session persistence is implemented.
let access_token = issue_access_token(&state.jwt_secret, "dev-user", vec!["user".to_string()])?;
let refresh_token = uuid::Uuid::new_v4().to_string();
Ok(Json(RefreshResponse {
access_token,
refresh_token,
}))
}
async fn not_implemented() -> impl IntoResponse {
(StatusCode::NOT_IMPLEMENTED, "not implemented")
}

12
backend/common/Cargo.toml Normal file
View File

@@ -0,0 +1,12 @@
[package]
name = "common"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
chrono = { workspace = true }
jsonwebtoken = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

View File

@@ -0,0 +1,40 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use thiserror::Error;
#[derive(Debug, Serialize)]
pub struct ApiErrorBody {
pub error: String,
}
#[derive(Debug, Error)]
pub enum AppError {
#[error("unauthorized: {0}")]
Unauthorized(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("internal error: {0}")]
Internal(String),
}
pub type AppResult<T> = Result<T, AppError>;
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = match self {
AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
let body = ApiErrorBody {
error: self.to_string(),
};
(status, Json(body)).into_response()
}
}

49
backend/common/src/jwt.rs Normal file
View File

@@ -0,0 +1,49 @@
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use crate::{AppError, AppResult};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub iat: usize,
pub exp: usize,
pub global_roles: Vec<String>,
}
pub fn issue_access_token(
secret: &str,
user_id: &str,
global_roles: Vec<String>,
) -> AppResult<String> {
let now = Utc::now();
let exp = now + Duration::minutes(15);
let claims = Claims {
sub: user_id.to_owned(),
iat: now.timestamp() as usize,
exp: exp.timestamp() as usize,
global_roles,
};
encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(|err| AppError::Internal(format!("failed to encode JWT: {err}")))
}
pub fn decode_jwt(secret: &str, token: &str) -> AppResult<Claims> {
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = true;
decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
)
.map(|data| data.claims)
.map_err(|err| AppError::Unauthorized(format!("invalid token: {err}")))
}

View File

@@ -0,0 +1,5 @@
pub mod error;
pub mod jwt;
pub use error::{ApiErrorBody, AppError, AppResult};
pub use jwt::{decode_jwt, issue_access_token, Claims};

View File

@@ -0,0 +1,16 @@
[package]
name = "content-service"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
common = { path = "../common" }
dotenvy = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
tokio = { workspace = true }
tower-http = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }

View File

@@ -0,0 +1,14 @@
FROM rust:alpine AS builder
WORKDIR /app
RUN apk add --no-cache build-base pkgconfig openssl-dev
COPY backend ./backend
RUN cargo build --release --manifest-path backend/Cargo.toml -p content-service
FROM alpine:3.23
RUN apk add --no-cache ca-certificates openssl libgcc libstdc++
COPY --from=builder /app/backend/target/release/content-service /usr/local/bin/content-service
EXPOSE 3001
CMD ["content-service"]

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS rulesets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
version TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,124 @@
use axum::{
extract::State,
http::{header::AUTHORIZATION, HeaderMap, StatusCode},
response::IntoResponse,
routing::{any, get},
Json, Router,
};
use common::{decode_jwt, AppError, AppResult};
use serde::Serialize;
use sqlx::{postgres::PgPoolOptions, PgPool};
use tower_http::cors::CorsLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Clone)]
struct AppState {
content_pool: PgPool,
jwt_secret: String,
}
#[derive(Serialize)]
struct HealthResponse {
service: &'static str,
status: &'static str,
}
#[derive(Serialize)]
struct RulesetSummary {
id: &'static str,
name: &'static str,
}
#[tokio::main]
async fn main() {
let _ = dotenvy::dotenv();
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "content_service=debug,tower_http=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let content_database_url =
std::env::var("CONTENT_DATABASE_URL").expect("CONTENT_DATABASE_URL must be set");
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let port: u16 = std::env::var("CONTENT_PORT")
.or_else(|_| std::env::var("PORT"))
.unwrap_or_else(|_| "3001".to_string())
.parse()
.expect("PORT must be a valid u16");
let content_pool = PgPoolOptions::new()
.max_connections(10)
.connect_lazy(&content_database_url)
.expect("invalid CONTENT_DATABASE_URL");
let state = AppState {
content_pool,
jwt_secret,
};
let app = Router::new()
.route("/health", get(health))
.route("/v1/rulesets", get(list_rulesets))
.route("/v1/*path", any(not_implemented))
.with_state(state)
.layer(CorsLayer::permissive());
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
.await
.expect("failed to bind");
tracing::info!(
"content-service listening on {}",
listener.local_addr().unwrap()
);
axum::serve(listener, app).await.expect("server error");
}
async fn health(State(state): State<AppState>) -> impl IntoResponse {
let _ = &state.content_pool;
Json(HealthResponse {
service: "content-service",
status: "ok",
})
}
async fn list_rulesets(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Vec<RulesetSummary>>> {
require_bearer(&state.jwt_secret, &headers)?;
Ok(Json(vec![
RulesetSummary {
id: "generic",
name: "Generic",
},
RulesetSummary {
id: "dsa5e",
name: "DSA5e",
},
]))
}
fn require_bearer(jwt_secret: &str, headers: &HeaderMap) -> AppResult<()> {
let header = headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| AppError::Unauthorized("missing Authorization header".to_string()))?;
let token = header
.strip_prefix("Bearer ")
.ok_or_else(|| AppError::Unauthorized("expected Bearer token".to_string()))?;
let _claims = decode_jwt(jwt_secret, token)?;
Ok(())
}
async fn not_implemented() -> impl IntoResponse {
(StatusCode::NOT_IMPLEMENTED, "not implemented")
}

View File

@@ -1 +0,0 @@
// Module entrypoint — reserved for integration tests and shared types.

View File

@@ -1,43 +0,0 @@
use axum::{
http::StatusCode,
response::IntoResponse,
routing::any,
Router,
};
use tower_http::cors::CorsLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main]
async fn main() {
// Load .env if present (dev convenience)
let _ = dotenvy::dotenv();
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "campaign_manager_backend=debug,tower_http=debug".into()))
.with(tracing_subscriber::fmt::layer())
.init();
let _database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let _jwt_secret = std::env::var("JWT_SECRET")
.expect("JWT_SECRET must be set");
// TODO: initialize sqlx connection pool
// let pool = sqlx::PgPool::connect(&database_url).await.expect("failed to connect to DB");
let app = Router::new()
.route("/v1/*path", any(not_implemented))
.layer(CorsLayer::permissive());
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.expect("failed to bind");
tracing::info!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.expect("server error");
}
async fn not_implemented() -> impl IntoResponse {
(StatusCode::NOT_IMPLEMENTED, "not implemented")
}

View File

@@ -1,20 +1,64 @@
services:
postgres:
image: postgres:18
campaign-service:
build:
context: .
dockerfile: backend/campaign-service/Dockerfile
restart: unless-stopped
env_file:
- path: .env
required: false
environment:
POSTGRES_DB: campaign_manager
POSTGRES_USER: campaign_manager
POSTGRES_PASSWORD: devpassword
USERS_DATABASE_URL: ${USERS_DATABASE_URL:-postgres://cm_campaign_app:devpassword@postgres:5432/cm_users}
CAMPAIGN_DATABASE_URL: ${CAMPAIGN_DATABASE_URL:-postgres://cm_campaign_app:devpassword@postgres:5432/cm_campaign}
JWT_SECRET: ${JWT_SECRET:-change-me-to-a-long-random-secret}
PORT: ${CAMPAIGN_PORT:-3000}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U campaign_manager"]
interval: 5s
timeout: 5s
retries: 5
- "3000:3000"
networks:
- dev-infra
- internal
volumes:
postgres_data:
content-service:
build:
context: .
dockerfile: backend/content-service/Dockerfile
restart: unless-stopped
env_file:
- path: .env
required: false
environment:
CONTENT_DATABASE_URL: ${CONTENT_DATABASE_URL:-postgres://cm_content_app:devpassword@postgres:5432/cm_content}
JWT_SECRET: ${JWT_SECRET:-change-me-to-a-long-random-secret}
PORT: ${CONTENT_PORT:-3001}
ports:
- "3001:3001"
networks:
- dev-infra
- internal
web:
build:
context: .
dockerfile: web/Dockerfile
restart: unless-stopped
env_file:
- path: .env
required: false
environment:
CAMPAIGN_API_BASE: ${CAMPAIGN_API_BASE:-http://campaign-service:3000/v1}
CONTENT_API_BASE: ${CONTENT_API_BASE:-http://content-service:3001/v1}
PORT: ${WEB_PORT:-5173}
HOST: 0.0.0.0
ports:
- "${WEB_PORT:-5173}:${WEB_PORT:-5173}"
depends_on:
- campaign-service
- content-service
networks:
- internal
networks:
dev-infra:
external: true
internal:
driver: bridge

View File

@@ -5,10 +5,16 @@
"onlyBuiltDependencies": ["esbuild"]
},
"scripts": {
"ci:local": "./scripts/ci-local.sh",
"dev:campaign": "cargo run --manifest-path backend/Cargo.toml -p campaign-service",
"dev:content": "cargo run --manifest-path backend/Cargo.toml -p content-service",
"dev:symbiote": "pnpm --filter symbiote dev",
"dev:web": "pnpm --filter web dev",
"start:web": "pnpm --filter web start",
"build:campaign": "cargo build --release --manifest-path backend/Cargo.toml -p campaign-service",
"build:content": "cargo build --release --manifest-path backend/Cargo.toml -p content-service",
"build:symbiote": "pnpm --filter symbiote build",
"build:web": "pnpm --filter web build",
"build:backend": "cargo build --release"
"build:backend": "cargo build --release --manifest-path backend/Cargo.toml --workspace"
}
}

160
pnpm-lock.yaml generated
View File

@@ -41,9 +41,9 @@ importers:
specifier: ^5.0.0
version: 5.53.8
devDependencies:
'@sveltejs/adapter-static':
specifier: ^3.0.0
version: 3.0.10(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.8)(vite@6.4.1))(svelte@5.53.8)(typescript@5.9.3)(vite@6.4.1))
'@sveltejs/adapter-node':
specifier: ^5.5.4
version: 5.5.4(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.8)(vite@6.4.1))(svelte@5.53.8)(typescript@5.9.3)(vite@6.4.1))
'@sveltejs/vite-plugin-svelte':
specifier: ^5.0.0
version: 5.1.1(svelte@5.53.8)(vite@6.4.1)
@@ -231,6 +231,42 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@rollup/plugin-commonjs@29.0.2':
resolution: {integrity: sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==}
engines: {node: '>=16.0.0 || 14 >= 14.17'}
peerDependencies:
rollup: ^2.68.0||^3.0.0||^4.0.0
peerDependenciesMeta:
rollup:
optional: true
'@rollup/plugin-json@6.1.0':
resolution: {integrity: sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==}
engines: {node: '>=14.0.0'}
peerDependencies:
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
peerDependenciesMeta:
rollup:
optional: true
'@rollup/plugin-node-resolve@16.0.3':
resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==}
engines: {node: '>=14.0.0'}
peerDependencies:
rollup: ^2.78.0||^3.0.0||^4.0.0
peerDependenciesMeta:
rollup:
optional: true
'@rollup/pluginutils@5.3.0':
resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
engines: {node: '>=14.0.0'}
peerDependencies:
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
peerDependenciesMeta:
rollup:
optional: true
'@rollup/rollup-android-arm-eabi@4.59.0':
resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
cpu: [arm]
@@ -377,10 +413,10 @@ packages:
peerDependencies:
acorn: ^8.9.0
'@sveltejs/adapter-static@3.0.10':
resolution: {integrity: sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==}
'@sveltejs/adapter-node@5.5.4':
resolution: {integrity: sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ==}
peerDependencies:
'@sveltejs/kit': ^2.0.0
'@sveltejs/kit': ^2.4.0
'@sveltejs/kit@2.53.4':
resolution: {integrity: sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==}
@@ -419,6 +455,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -439,6 +478,9 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
commondir@1.0.1:
resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
cookie@0.6.0:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'}
@@ -470,6 +512,9 @@ packages:
esrap@2.2.3:
resolution: {integrity: sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -484,6 +529,23 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
is-core-module@2.16.1:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'}
is-module@1.0.0:
resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==}
is-reference@1.2.1:
resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
is-reference@3.0.3:
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
@@ -509,6 +571,9 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -520,6 +585,11 @@ packages:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
resolve@1.22.11:
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'}
hasBin: true
rollup@4.59.0:
resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -536,6 +606,10 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
supports-preserve-symlinks-flag@1.0.0:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
svelte@5.53.8:
resolution: {integrity: sha512-UD++BnEc3PUFgjin381LiMHzDjT187Fy+KsPZxvaKrYPZqR0GQ/Ha8h7GDoegIF8tFl1uogoNUejKgcRk77T2Q==}
engines: {node: '>=18'}
@@ -705,6 +779,42 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
'@rollup/plugin-commonjs@29.0.2(rollup@4.59.0)':
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.59.0)
commondir: 1.0.1
estree-walker: 2.0.2
fdir: 6.5.0(picomatch@4.0.3)
is-reference: 1.2.1
magic-string: 0.30.21
picomatch: 4.0.3
optionalDependencies:
rollup: 4.59.0
'@rollup/plugin-json@6.1.0(rollup@4.59.0)':
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.59.0)
optionalDependencies:
rollup: 4.59.0
'@rollup/plugin-node-resolve@16.0.3(rollup@4.59.0)':
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.59.0)
'@types/resolve': 1.20.2
deepmerge: 4.3.1
is-module: 1.0.0
resolve: 1.22.11
optionalDependencies:
rollup: 4.59.0
'@rollup/pluginutils@5.3.0(rollup@4.59.0)':
dependencies:
'@types/estree': 1.0.8
estree-walker: 2.0.2
picomatch: 4.0.3
optionalDependencies:
rollup: 4.59.0
'@rollup/rollup-android-arm-eabi@4.59.0':
optional: true
@@ -786,9 +896,13 @@ snapshots:
dependencies:
acorn: 8.16.0
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.8)(vite@6.4.1))(svelte@5.53.8)(typescript@5.9.3)(vite@6.4.1))':
'@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.8)(vite@6.4.1))(svelte@5.53.8)(typescript@5.9.3)(vite@6.4.1))':
dependencies:
'@rollup/plugin-commonjs': 29.0.2(rollup@4.59.0)
'@rollup/plugin-json': 6.1.0(rollup@4.59.0)
'@rollup/plugin-node-resolve': 16.0.3(rollup@4.59.0)
'@sveltejs/kit': 2.53.4(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.8)(vite@6.4.1))(svelte@5.53.8)(typescript@5.9.3)(vite@6.4.1)
rollup: 4.59.0
'@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.8)(vite@6.4.1))(svelte@5.53.8)(typescript@5.9.3)(vite@6.4.1)':
dependencies:
@@ -836,6 +950,8 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/resolve@1.20.2': {}
'@types/trusted-types@2.0.7': {}
acorn@8.16.0: {}
@@ -846,6 +962,8 @@ snapshots:
clsx@2.1.1: {}
commondir@1.0.1: {}
cookie@0.6.0: {}
debug@4.4.3:
@@ -891,6 +1009,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
estree-walker@2.0.2: {}
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@@ -898,6 +1018,22 @@ snapshots:
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
is-core-module@2.16.1:
dependencies:
hasown: 2.0.2
is-module@1.0.0: {}
is-reference@1.2.1:
dependencies:
'@types/estree': 1.0.8
is-reference@3.0.3:
dependencies:
'@types/estree': 1.0.8
@@ -916,6 +1052,8 @@ snapshots:
nanoid@3.3.11: {}
path-parse@1.0.7: {}
picocolors@1.1.1: {}
picomatch@4.0.3: {}
@@ -926,6 +1064,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
resolve@1.22.11:
dependencies:
is-core-module: 2.16.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
rollup@4.59.0:
dependencies:
'@types/estree': 1.0.8
@@ -967,6 +1111,8 @@ snapshots:
source-map-js@1.2.1: {}
supports-preserve-symlinks-flag@1.0.0: {}
svelte@5.53.8:
dependencies:
'@jridgewell/remapping': 2.3.5

15
scripts/ci-local.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env sh
set -eu
corepack enable
pnpm install --frozen-lockfile
pnpm build:web
pnpm build:symbiote
cargo fmt --manifest-path backend/Cargo.toml --all -- --check
cargo check --manifest-path backend/Cargo.toml --workspace
docker compose config >/dev/null
echo "Local CI checks passed."

View File

@@ -1,17 +1,23 @@
import { get } from 'svelte/store';
import { accessToken, refreshToken, saveTokens, clearTokens, isBoardTransition } from './store';
export const BASE_URL = 'https://api.campaign-manager.example.com/v1';
const CAMPAIGN_BASE_URL = import.meta.env.VITE_CAMPAIGN_API_BASE || 'http://localhost:3000/v1';
const CONTENT_BASE_URL = import.meta.env.VITE_CONTENT_API_BASE || 'http://localhost:3001/v1';
interface RequestOptions extends RequestInit {
skipAuth?: boolean;
}
function joinUrl(base: string, path: string): string {
if (!path) return base;
return `${base}${path.startsWith('/') ? path : `/${path}`}`;
}
async function silentRefresh(): Promise<boolean> {
const rt = get(refreshToken);
if (!rt) return false;
const res = await fetch(`${BASE_URL}/auth/refresh`, {
const res = await fetch(joinUrl(CAMPAIGN_BASE_URL, '/auth/refresh'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: rt }),
@@ -27,7 +33,7 @@ async function silentRefresh(): Promise<boolean> {
return true;
}
async function apiFetch(path: string, options: RequestOptions = {}): Promise<Response> {
async function apiFetch(baseUrl: string, path: string, options: RequestOptions = {}): Promise<Response> {
if (get(isBoardTransition)) {
throw new Error('API call suppressed during board transition');
}
@@ -44,14 +50,15 @@ async function apiFetch(path: string, options: RequestOptions = {}): Promise<Res
headers.set('Content-Type', 'application/json');
}
let res = await fetch(`${BASE_URL}${path}`, { ...fetchOptions, headers });
const targetUrl = joinUrl(baseUrl, path);
let res = await fetch(targetUrl, { ...fetchOptions, headers });
if (res.status === 401 && !skipAuth) {
const refreshed = await silentRefresh();
if (refreshed) {
const newToken = get(accessToken);
if (newToken) headers.set('Authorization', `Bearer ${newToken}`);
res = await fetch(`${BASE_URL}${path}`, { ...fetchOptions, headers });
res = await fetch(targetUrl, { ...fetchOptions, headers });
}
}
@@ -60,17 +67,34 @@ async function apiFetch(path: string, options: RequestOptions = {}): Promise<Res
export const api = {
get: (path: string, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'GET' }),
apiFetch(CAMPAIGN_BASE_URL, path, { ...options, method: 'GET' }),
post: (path: string, body?: unknown, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'POST', body: body ? JSON.stringify(body) : undefined }),
apiFetch(CAMPAIGN_BASE_URL, path, { ...options, method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: (path: string, body?: unknown, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
apiFetch(CAMPAIGN_BASE_URL, path, { ...options, method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
patch: (path: string, body?: unknown, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'PATCH', body: body ? JSON.stringify(body) : undefined }),
apiFetch(CAMPAIGN_BASE_URL, path, { ...options, method: 'PATCH', body: body ? JSON.stringify(body) : undefined }),
delete: (path: string, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'DELETE' }),
apiFetch(CAMPAIGN_BASE_URL, path, { ...options, method: 'DELETE' }),
};
export const contentApi = {
get: (path: string, options?: RequestOptions) =>
apiFetch(CONTENT_BASE_URL, path, { ...options, method: 'GET' }),
post: (path: string, body?: unknown, options?: RequestOptions) =>
apiFetch(CONTENT_BASE_URL, path, { ...options, method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: (path: string, body?: unknown, options?: RequestOptions) =>
apiFetch(CONTENT_BASE_URL, path, { ...options, method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
patch: (path: string, body?: unknown, options?: RequestOptions) =>
apiFetch(CONTENT_BASE_URL, path, { ...options, method: 'PATCH', body: body ? JSON.stringify(body) : undefined }),
delete: (path: string, options?: RequestOptions) =>
apiFetch(CONTENT_BASE_URL, path, { ...options, method: 'DELETE' }),
};

22
web/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:current-alpine
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY web ./web
COPY rulesets ./rulesets
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @campaign-manager/web build
WORKDIR /app/web
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=5173
EXPOSE 5173
CMD ["node", "build"]

View File

@@ -1,38 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.png" />
<link href="/_app/immutable/entry/start.Pj34kLt-.js" rel="modulepreload">
<link href="/_app/immutable/chunks/YzYuob9f.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CUCwB180.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DNqN6DmX.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.H3SWXino.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Db9w--lA.js" rel="modulepreload">
<link href="/_app/immutable/chunks/pTMRHjpX.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Bzak7iHL.js" rel="modulepreload">
<link href="/_app/immutable/chunks/C5YqYP7P.js" rel="modulepreload">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_1smak34 = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.Pj34kLt-.js"),
import("/_app/immutable/entry/app.H3SWXino.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
}
</script>
</div>
</body>
</html>

View File

@@ -1 +0,0 @@
export const env={}

View File

@@ -1 +0,0 @@
var e;typeof window<"u"&&((e=window.__svelte??(window.__svelte={})).v??(e.v=new Set)).add("5");

View File

@@ -1 +0,0 @@
import{s as c,g as l}from"./DNqN6DmX.js";import{b as o,d as b,n as a,m as d,g as p,e as g}from"./CUCwB180.js";let s=!1,i=Symbol();function m(e,u,r){const n=r[u]??(r[u]={store:null,source:d(void 0),unsubscribe:a});if(n.store!==e&&!(i in r))if(n.unsubscribe(),n.store=e??null,e==null)n.source.v=void 0,n.unsubscribe=a;else{var t=!0;n.unsubscribe=c(e,f=>{t?n.source.v=f:g(n.source,f)}),t=!1}return e&&i in r?l(e):p(n.source)}function y(){const e={};function u(){o(()=>{for(var r in e)e[r].unsubscribe();b(e,i,{enumerable:!1,value:!0})})}return[e,u]}function N(e){var u=s;try{return s=!1,[e(),s]}finally{s=u}}export{m as a,N as c,y as s};

View File

@@ -1 +0,0 @@
import{w as a}from"./CUCwB180.js";a();

View File

@@ -1 +0,0 @@
import{s as e}from"./YzYuob9f.js";const r=()=>{const s=e;return{page:{subscribe:s.page.subscribe},navigating:{subscribe:s.navigating.subscribe},updated:s.updated}},b={subscribe(s){return r().page.subscribe(s)}};export{b as p};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{n as c,j as a,J as p,i as d,h as l,K as m}from"./CUCwB180.js";function h(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function g(e,n,s){if(e==null)return n(void 0),c;const u=a(()=>e.subscribe(n,s));return u.unsubscribe?()=>u.unsubscribe():u}const i=[];function q(e,n=c){let s=null;const u=new Set;function r(o){if(p(e,o)&&(e=o,s)){const f=!i.length;for(const t of u)t[1](),i.push(t,e);if(f){for(let t=0;t<i.length;t+=2)i[t][0](i[t+1]);i.length=0}}}function b(o){r(o(e))}function _(o,f=c){const t=[o,f];return u.add(t),u.size===1&&(s=n(r,b)||c),o(e),()=>{u.delete(t),u.size===0&&s&&(s(),s=null)}}return{set:r,update:b,subscribe:_}}function k(e){let n;return g(e,s=>n=s)(),n}function x(e){l===null&&h(),m&&l.l!==null?w(l).m.push(e):d(()=>{const n=a(e);if(typeof n=="function")return n})}function w(e){var n=e.l;return n.u??(n.u={a:[],b:[],m:[]})}export{k as g,x as o,g as s,q as w};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{h as d,u as g,i as c,j as m,k as l,l as b,g as p,o as h,q as k}from"./CUCwB180.js";function x(n=!1){const s=d,e=s.l.u;if(!e)return;let r=()=>h(s.s);if(n){let o=0,t={};const _=k(()=>{let i=!1;const a=s.s;for(const f in a)a[f]!==t[f]&&(t[f]=a[f],i=!0);return i&&o++,o});r=()=>p(_)}e.b.length&&g(()=>{u(s,r),l(e.b)}),c(()=>{const o=m(()=>e.m.map(b));return()=>{for(const t of o)typeof t=="function"&&t()}}),e.a.length&&c(()=>{u(s,r),l(e.a)})}function u(n,s){if(n.l.s)for(const e of n.l.s)p(e);s()}export{x as i};

View File

@@ -1 +0,0 @@
import{x as h,y as d,z as _,A as l,B as p,T as E,C as g,D as u,E as s,R as y,F as x,G as A,H as M,I as N}from"./CUCwB180.js";var f;const i=((f=globalThis==null?void 0:globalThis.window)==null?void 0:f.trustedTypes)&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:t=>t});function b(t){return(i==null?void 0:i.createHTML(t))??t}function L(t){var r=h("template");return r.innerHTML=b(t.replaceAll("<!>","<!---->")),r.content}function n(t,r){var e=_;e.nodes===null&&(e.nodes={start:t,end:r,a:null,t:null})}function w(t,r){var e=(r&E)!==0,c=(r&g)!==0,a,m=!t.startsWith("<!>");return()=>{if(u)return n(s,null),s;a===void 0&&(a=L(m?t:"<!>"+t),e||(a=l(a)));var o=c||p?document.importNode(a,!0):a.cloneNode(!0);if(e){var T=l(o),v=o.lastChild;n(T,v)}else n(o,o);return o}}function C(t=""){if(!u){var r=d(t+"");return n(r,r),r}var e=s;return e.nodeType!==A?(e.before(e=d()),M(e)):N(e),n(e,e),e}function D(){if(u)return n(s,null),s;var t=document.createDocumentFragment(),r=document.createComment(""),e=d();return t.append(r,e),n(r,e),t}function H(t,r){if(u){var e=_;((e.f&y)===0||e.nodes.end===null)&&(e.nodes.end=s),x();return}t!==null&&t.before(r)}export{H as a,n as b,D as c,w as f,C as t};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{l as o,a as r}from"../chunks/YzYuob9f.js";export{o as load_css,r as start};

View File

@@ -1 +0,0 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{o as h}from"../chunks/DNqN6DmX.js";import{D as m,F as g,p as y,f as T,a as $}from"../chunks/CUCwB180.js";import{s as _,a as k}from"../chunks/C5YqYP7P.js";import{c as v,a as w}from"../chunks/pTMRHjpX.js";import{i as A}from"../chunks/eiK12uJk.js";import{g as u}from"../chunks/YzYuob9f.js";import{p as O}from"../chunks/CGowzvwH.js";function S(t,e,a,n,o){var f;m&&g();var s=(f=e.$$slots)==null?void 0:f[a],r=!1;s===!0&&(s=e.children,r=!0),s===void 0||s(t,r?()=>n:n)}const d="https://api.campaign-manager.example.com/v1";let i=null;function E(t){i=t}async function P(){const t=await fetch(`${d}/auth/refresh`,{method:"POST",credentials:"include"});return t.ok?(i=(await t.json()).accessToken,!0):(i=null,!1)}async function c(t,e={}){const{skipAuth:a=!1,...n}=e,o=new Headers(n.headers);!a&&i&&o.set("Authorization",`Bearer ${i}`),n.body&&!o.has("Content-Type")&&o.set("Content-Type","application/json");let s=await fetch(`${d}${t}`,{...n,headers:o,credentials:"include"});return s.status===401&&!a&&await P()&&i&&(o.set("Authorization",`Bearer ${i}`),s=await fetch(`${d}${t}`,{...n,headers:o,credentials:"include"})),s}const B={get:(t,e)=>c(t,{...e,method:"GET"}),post:(t,e,a)=>c(t,{...a,method:"POST",body:e?JSON.stringify(e):void 0}),put:(t,e,a)=>c(t,{...a,method:"PUT",body:e?JSON.stringify(e):void 0}),patch:(t,e,a)=>c(t,{...a,method:"PATCH",body:e?JSON.stringify(e):void 0}),delete:(t,e)=>c(t,{...e,method:"DELETE"})};function z(t,e){y(e,!1);const a=()=>k(O,"$page",n),[n,o]=_(),s=["/login"];h(async()=>{if(!s.includes(a().url.pathname))try{const l=await B.post("/auth/refresh",void 0,{skipAuth:!0});if(l.ok){const p=await l.json();E(p.accessToken)}else u("/login")}catch{u("/login")}}),A();var r=v(),f=T(r);S(f,e,"default",{}),w(t,r),$(),o()}export{z as component};

View File

@@ -1 +0,0 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{p as h,f as g,t as l,a as v,c as e,r as o,s as d}from"../chunks/CUCwB180.js";import{s as p}from"../chunks/Db9w--lA.js";import{a as _,f as x}from"../chunks/pTMRHjpX.js";import{i as $}from"../chunks/eiK12uJk.js";import{s as k,p as m}from"../chunks/YzYuob9f.js";const b={get error(){return m.error},get status(){return m.status}};k.updated.check;const i=b;var E=x("<h1> </h1> <p> </p>",1);function C(f,c){h(c,!1),$();var t=E(),r=g(t),n=e(r,!0);o(r);var s=d(r,2),u=e(s,!0);o(s),l(()=>{var a;p(n,i.status),p(u,(a=i.error)==null?void 0:a.message)}),_(f,t),v()}export{C as component};

View File

@@ -1 +0,0 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{o as p}from"../chunks/DNqN6DmX.js";import{p as r,a as t}from"../chunks/CUCwB180.js";import{i as m}from"../chunks/eiK12uJk.js";import{g as a}from"../chunks/YzYuob9f.js";function c(i,o){r(o,!1),p(()=>a("/groups")),m(),t()}export{c as component};

View File

@@ -1 +0,0 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{a,f as t}from"../chunks/pTMRHjpX.js";var p=t("<h1>Your Characters</h1>");function n(o){var r=p();a(o,r)}export{n as component};

View File

@@ -1 +0,0 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{p as i,s as f,f as c,t as n,a as h,c as g,r as l}from"../chunks/CUCwB180.js";import{s as _,a as $}from"../chunks/C5YqYP7P.js";import{s as d}from"../chunks/Db9w--lA.js";import{a as u,f as v}from"../chunks/pTMRHjpX.js";import{i as x}from"../chunks/eiK12uJk.js";import{p as C}from"../chunks/CGowzvwH.js";var b=v("<h1>Character Sheet</h1> <p> </p>",1);function z(r,s){i(s,!1);const e=()=>$(C,"$page",p),[p,o]=_();x();var a=b(),t=f(c(a),2),m=g(t);l(t),n(()=>d(m,`Character ID: ${e().params.id??""}`)),u(r,a),h(),o()}export{z as component};

View File

@@ -1 +0,0 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{a as p,f as a}from"../chunks/pTMRHjpX.js";var t=a("<h1>Your Groups</h1>");function f(o){var r=t();p(o,r)}export{f as component};

View File

@@ -1 +0,0 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{p as i,s as f,f as n,t as c,a as l,c as g,r as _}from"../chunks/CUCwB180.js";import{s as $,a as h}from"../chunks/C5YqYP7P.js";import{s as u}from"../chunks/Db9w--lA.js";import{a as d,f as v}from"../chunks/pTMRHjpX.js";import{i as x}from"../chunks/eiK12uJk.js";import{p as D}from"../chunks/CGowzvwH.js";var G=v("<h1>Group Detail</h1> <p> </p>",1);function A(s,r){i(r,!1);const p=()=>h(D,"$page",o),[o,e]=$();x();var a=G(),t=f(n(a),2),m=g(t);_(t),c(()=>u(m,`Group ID: ${p().params.id??""}`)),d(s,a),l(),e()}export{A as component};

View File

@@ -1 +0,0 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{p as i,s as f,f as n,t as c,a as g,c as l,r as _}from"../chunks/CUCwB180.js";import{s as $,a as h}from"../chunks/C5YqYP7P.js";import{s as u}from"../chunks/Db9w--lA.js";import{a as d,f as v}from"../chunks/pTMRHjpX.js";import{i as x}from"../chunks/eiK12uJk.js";import{p as G}from"../chunks/CGowzvwH.js";var b=v("<h1>Group Settings</h1> <p> </p>",1);function z(a,r){i(r,!1);const p=()=>h(G,"$page",o),[o,e]=$();x();var t=b(),s=f(n(t),2),m=l(s);_(s),c(()=>u(m,`Group ID: ${p().params.id??""}`)),d(a,t),g(),e()}export{z as component};

View File

@@ -1 +0,0 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{v as r}from"../chunks/CUCwB180.js";import{a as m,f as n}from"../chunks/pTMRHjpX.js";var p=n("<h1>Campaign Manager</h1> <p>Login — coming soon</p>",1);function g(o){var a=p();r(2),m(o,a)}export{g as component};

View File

@@ -1 +0,0 @@
{"version":"1773071061170"}

View File

@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
"preview": "node build",
"start": "node build"
},
"dependencies": {
"@sveltejs/kit": "^2.0.0",
@@ -14,7 +15,7 @@
"@campaign-manager/rulesets": "workspace:*"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"typescript": "^5.0.0",
"vite": "^6.0.0"

View File

@@ -1,4 +1,6 @@
export const BASE_URL = 'https://api.campaign-manager.example.com/v1';
const CAMPAIGN_BASE_URL = '/api/campaign';
const CONTENT_BASE_URL = '/api/content';
const AUTH_BASE_URL = '/api/auth';
// Access token held in memory only — never persisted to localStorage
let _accessToken: string | null = null;
@@ -15,9 +17,14 @@ interface RequestOptions extends RequestInit {
skipAuth?: boolean;
}
function joinUrl(base: string, path: string): string {
if (!path) return base;
return `${base}${path.startsWith('/') ? path : `/${path}`}`;
}
async function silentRefresh(): Promise<boolean> {
// Refresh token is in an HttpOnly cookie; no manual credential needed
const res = await fetch(`${BASE_URL}/auth/refresh`, {
const res = await fetch(joinUrl(AUTH_BASE_URL, '/refresh'), {
method: 'POST',
credentials: 'include',
});
@@ -32,7 +39,7 @@ async function silentRefresh(): Promise<boolean> {
return true;
}
async function apiFetch(path: string, options: RequestOptions = {}): Promise<Response> {
async function apiFetch(baseUrl: string, path: string, options: RequestOptions = {}): Promise<Response> {
const { skipAuth = false, ...fetchOptions } = options;
const headers = new Headers(fetchOptions.headers);
@@ -44,7 +51,9 @@ async function apiFetch(path: string, options: RequestOptions = {}): Promise<Res
headers.set('Content-Type', 'application/json');
}
let res = await fetch(`${BASE_URL}${path}`, {
const targetUrl = joinUrl(baseUrl, path);
let res = await fetch(targetUrl, {
...fetchOptions,
headers,
credentials: 'include',
@@ -54,7 +63,7 @@ async function apiFetch(path: string, options: RequestOptions = {}): Promise<Res
const refreshed = await silentRefresh();
if (refreshed && _accessToken) {
headers.set('Authorization', `Bearer ${_accessToken}`);
res = await fetch(`${BASE_URL}${path}`, { ...fetchOptions, headers, credentials: 'include' });
res = await fetch(targetUrl, { ...fetchOptions, headers, credentials: 'include' });
}
}
@@ -63,17 +72,39 @@ async function apiFetch(path: string, options: RequestOptions = {}): Promise<Res
export const api = {
get: (path: string, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'GET' }),
apiFetch(CAMPAIGN_BASE_URL, path, { ...options, method: 'GET' }),
post: (path: string, body?: unknown, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'POST', body: body ? JSON.stringify(body) : undefined }),
apiFetch(CAMPAIGN_BASE_URL, path, { ...options, method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: (path: string, body?: unknown, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
apiFetch(CAMPAIGN_BASE_URL, path, { ...options, method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
patch: (path: string, body?: unknown, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'PATCH', body: body ? JSON.stringify(body) : undefined }),
apiFetch(CAMPAIGN_BASE_URL, path, { ...options, method: 'PATCH', body: body ? JSON.stringify(body) : undefined }),
delete: (path: string, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'DELETE' }),
apiFetch(CAMPAIGN_BASE_URL, path, { ...options, method: 'DELETE' }),
};
export const contentApi = {
get: (path: string, options?: RequestOptions) =>
apiFetch(CONTENT_BASE_URL, path, { ...options, method: 'GET' }),
post: (path: string, body?: unknown, options?: RequestOptions) =>
apiFetch(CONTENT_BASE_URL, path, { ...options, method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: (path: string, body?: unknown, options?: RequestOptions) =>
apiFetch(CONTENT_BASE_URL, path, { ...options, method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
patch: (path: string, body?: unknown, options?: RequestOptions) =>
apiFetch(CONTENT_BASE_URL, path, { ...options, method: 'PATCH', body: body ? JSON.stringify(body) : undefined }),
delete: (path: string, options?: RequestOptions) =>
apiFetch(CONTENT_BASE_URL, path, { ...options, method: 'DELETE' }),
};
export const authApi = {
post: (path: string, body?: unknown, options?: RequestOptions) =>
apiFetch(AUTH_BASE_URL, path, { ...options, method: 'POST', body: body ? JSON.stringify(body) : undefined }),
};

View File

@@ -0,0 +1,79 @@
import { error } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
const FORWARDED_RESPONSE_HEADERS = new Set([
'cache-control',
'content-type',
'etag',
'last-modified',
'set-cookie',
'vary',
'www-authenticate',
]);
function resolveBaseUrl(raw: string | undefined, name: string): string {
if (!raw) {
throw error(500, `${name} is not configured`);
}
return raw.replace(/\/+$/, '');
}
function buildTargetUrl(baseUrl: string, path: string | undefined, search: string): string {
const normalizedPath = path ? `/${path}` : '';
return `${baseUrl}${normalizedPath}${search}`;
}
function copyRequestHeaders(headers: Headers): Headers {
const forwarded = new Headers();
for (const [key, value] of headers.entries()) {
const lowered = key.toLowerCase();
if (lowered === 'host') continue;
if (lowered === 'content-length') continue;
forwarded.set(key, value);
}
return forwarded;
}
function copyResponseHeaders(headers: Headers): Headers {
const forwarded = new Headers();
for (const [key, value] of headers.entries()) {
if (FORWARDED_RESPONSE_HEADERS.has(key.toLowerCase())) {
forwarded.append(key, value);
}
}
return forwarded;
}
export async function proxyToService(
event: RequestEvent,
rawBaseUrl: string | undefined,
baseName: string,
): Promise<Response> {
const baseUrl = resolveBaseUrl(rawBaseUrl, baseName);
const url = new URL(event.request.url);
const targetUrl = buildTargetUrl(baseUrl, event.params.path, url.search);
const init: RequestInit = {
method: event.request.method,
headers: copyRequestHeaders(event.request.headers),
redirect: 'manual',
};
if (!['GET', 'HEAD'].includes(event.request.method.toUpperCase())) {
init.body = await event.request.arrayBuffer();
}
const upstream = await fetch(targetUrl, init);
const headers = copyResponseHeaders(upstream.headers);
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers,
});
}

View File

@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { api, setAccessToken } from '$lib/api';
import { authApi, setAccessToken } from '$lib/api';
const PUBLIC_ROUTES = ['/login'];
@@ -11,7 +11,7 @@
// Attempt silent token refresh via HttpOnly cookie before rendering
try {
const res = await api.post('/auth/refresh', undefined, { skipAuth: true });
const res = await authApi.post('/refresh', undefined, { skipAuth: true });
if (res.ok) {
const data = await res.json() as { accessToken: string };
setAccessToken(data.accessToken);

View File

@@ -0,0 +1,19 @@
import { env } from '$env/dynamic/private';
import { proxyToService } from '$lib/server/proxy';
import type { RequestHandler } from './$types';
const CAMPAIGN_BASE = env.CAMPAIGN_API_BASE || 'http://localhost:3000/v1';
const handler: RequestHandler = (event) =>
proxyToService(
event,
`${CAMPAIGN_BASE.replace(/\/+$/, '')}/auth`,
'CAMPAIGN_API_BASE',
);
export const GET = handler;
export const POST = handler;
export const PUT = handler;
export const PATCH = handler;
export const DELETE = handler;
export const OPTIONS = handler;

View File

@@ -0,0 +1,15 @@
import { env } from '$env/dynamic/private';
import { proxyToService } from '$lib/server/proxy';
import type { RequestHandler } from './$types';
const CAMPAIGN_BASE = env.CAMPAIGN_API_BASE || 'http://localhost:3000/v1';
const handler: RequestHandler = (event) =>
proxyToService(event, CAMPAIGN_BASE, 'CAMPAIGN_API_BASE');
export const GET = handler;
export const POST = handler;
export const PUT = handler;
export const PATCH = handler;
export const DELETE = handler;
export const OPTIONS = handler;

View File

@@ -0,0 +1,15 @@
import { env } from '$env/dynamic/private';
import { proxyToService } from '$lib/server/proxy';
import type { RequestHandler } from './$types';
const CONTENT_BASE = env.CONTENT_API_BASE || 'http://localhost:3001/v1';
const handler: RequestHandler = (event) =>
proxyToService(event, CONTENT_BASE, 'CONTENT_API_BASE');
export const GET = handler;
export const POST = handler;
export const PUT = handler;
export const PATCH = handler;
export const DELETE = handler;
export const OPTIONS = handler;

View File

@@ -1,8 +1,8 @@
import adapter from '@sveltejs/adapter-static';
import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */
export default {
kit: {
adapter: adapter({ fallback: '404.html' }),
adapter: adapter(),
},
};