setup local dev env
This commit is contained in:
22
.env.example
22
.env.example
@@ -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
2
.gitignore
vendored
@@ -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
29
.woodpecker.yml
Normal 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
2794
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
[workspace]
|
||||
members = ["backend"]
|
||||
resolver = "2"
|
||||
139
README.md
139
README.md
@@ -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
2842
backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
18
backend/campaign-service/Cargo.toml
Normal file
18
backend/campaign-service/Cargo.toml
Normal 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 }
|
||||
14
backend/campaign-service/Dockerfile
Normal file
14
backend/campaign-service/Dockerfile
Normal 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"]
|
||||
15
backend/campaign-service/migrations/campaign/0001_init.sql
Normal file
15
backend/campaign-service/migrations/campaign/0001_init.sql
Normal 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)
|
||||
);
|
||||
15
backend/campaign-service/migrations/users/0001_init.sql
Normal file
15
backend/campaign-service/migrations/users/0001_init.sql
Normal 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()
|
||||
);
|
||||
118
backend/campaign-service/src/main.rs
Normal file
118
backend/campaign-service/src/main.rs
Normal 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
12
backend/common/Cargo.toml
Normal 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 }
|
||||
40
backend/common/src/error.rs
Normal file
40
backend/common/src/error.rs
Normal 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
49
backend/common/src/jwt.rs
Normal 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}")))
|
||||
}
|
||||
5
backend/common/src/lib.rs
Normal file
5
backend/common/src/lib.rs
Normal 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};
|
||||
16
backend/content-service/Cargo.toml
Normal file
16
backend/content-service/Cargo.toml
Normal 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 }
|
||||
14
backend/content-service/Dockerfile
Normal file
14
backend/content-service/Dockerfile
Normal 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"]
|
||||
7
backend/content-service/migrations/content/0001_init.sql
Normal file
7
backend/content-service/migrations/content/0001_init.sql
Normal 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()
|
||||
);
|
||||
124
backend/content-service/src/main.rs
Normal file
124
backend/content-service/src/main.rs
Normal 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")
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
// Module entrypoint — reserved for integration tests and shared types.
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
160
pnpm-lock.yaml
generated
@@ -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
15
scripts/ci-local.sh
Executable 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."
|
||||
@@ -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
22
web/Dockerfile
Normal 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"]
|
||||
@@ -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>
|
||||
@@ -1 +0,0 @@
|
||||
export const env={}
|
||||
@@ -1 +0,0 @@
|
||||
var e;typeof window<"u"&&((e=window.__svelte??(window.__svelte={})).v??(e.v=new Set)).add("5");
|
||||
@@ -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};
|
||||
@@ -1 +0,0 @@
|
||||
import{w as a}from"./CUCwB180.js";a();
|
||||
@@ -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
@@ -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
@@ -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};
|
||||
@@ -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
@@ -1 +0,0 @@
|
||||
import{l as o,a as r}from"../chunks/YzYuob9f.js";export{o as load_css,r as start};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -1 +0,0 @@
|
||||
{"version":"1773071061170"}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
79
web/src/lib/server/proxy.ts
Normal file
79
web/src/lib/server/proxy.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
19
web/src/routes/api/auth/[...path]/+server.ts
Normal file
19
web/src/routes/api/auth/[...path]/+server.ts
Normal 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;
|
||||
15
web/src/routes/api/campaign/[...path]/+server.ts
Normal file
15
web/src/routes/api/campaign/[...path]/+server.ts
Normal 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;
|
||||
15
web/src/routes/api/content/[...path]/+server.ts
Normal file
15
web/src/routes/api/content/[...path]/+server.ts
Normal 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;
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user