Compare commits

...

7 commits

20 changed files with 1147 additions and 74 deletions

View file

@ -0,0 +1,210 @@
# Likwid Stabilization Megaplan (6061c4)
Stabilize Likwid into a production-usable system by shipping a coherent admin modular-rule management UX (built-in + WASM), making WASM packages production-grade (including background jobs + SSRF-hardening), and converging authorization on roles/permissions.
## Decisions (confirmed)
- Git history: **linear `main` via squash merges**.
- Demo VPS: **tracks `main`**.
- Modular rules approach: **Hybrid** (built-in DB-backed settings + built-in modules + WASM plugin packages).
- Authz direction: **roles/permissions is authoritative**.
- Phase 1 default instance guidance: `instance_type=multi_community`, `platform_mode=admin_only`.
- `plugin_allow_background_jobs`: **full implementation** (end-to-end semantics, not just a stored flag).
- Registry SSRF hardening: **yes** (DNS-aware; do not rely only on host string / IP-literal checks).
- Community admin UX scope: **full** (plugin policy + WASM packages + built-in plugins in one coherent flow).
- Plan handling: **commit plan to `main`** once approved (plan-first discipline).
## Non-negotiable outcomes (Phase 1)
- Operational reliability + install/upgrade story.
- Admin modular rule management UX.
- WASM third-party plugin packages are production-grade.
- End-user UX consistency (avoid confusing partial configuration states).
## Current state (source-backed highlights)
- Backend exposes community endpoints:
- `GET/PUT /api/communities/{id}/plugin-policy`
- `GET/POST/PUT /api/communities/{id}/plugin-packages` (+ `install-registry`)
- WASM runtime exists (wasmtime; fuel + timeout + memory limits).
- WASM outbound HTTP is capability-gated and allowlisted.
- Registry allowlist currently:
- blocks `localhost`
- blocks IP-literal loopback/private/link-local/unspecified
- matches exact host or `*.suffix`
- **does not** do DNS resolution + post-resolution IP classification.
- Frontend currently does not call `plugin-policy` / `plugin-packages` (community UI covers built-in plugins only).
## Scope boundaries
- No architectural rewrite.
- Minimal, targeted changes per milestone.
- No dependency additions unless clearly required for an explicit acceptance criterion.
## Milestone discipline / verification
- Each milestone lands as a single squash-merge PR to `main`.
- Required verification per milestone:
- Backend: `cargo check` (and `cargo test` if stable)
- Frontend: `npm run build`
- Demo VPS: deploy `main` and run `./scripts/smoke-test.sh demo`
---
## Phase 0 — Baseline + operator invariants (gate)
### Deliverables
- Single authoritative operator workflow for:
- local dev start/stop
- demo deploy/update
- rollback
- smoke test
- Confirm demo systemd + compose wiring is consistent with docs and scripts.
### Acceptance criteria
- Demo VPS updated to latest `main` and `./scripts/smoke-test.sh demo` passes.
### Verification
- Run smoke test on VPS.
---
## Phase 1 — Admin modular rule management UX (hybrid)
### Goal
One coherent admin flow for:
- community built-in plugins (`plugins` + `community_plugins`)
- community WASM plugin packages (`plugin_packages` + `community_plugin_packages`)
- community plugin policy (`communities.settings` keys)
### Deliverables (frontend)
- Community admin UI adds a “Plugins / Rules” surface that includes:
- Built-in community plugins management (existing functionality retained).
- Plugin policy editor (read + update):
- trust policy
- install sources
- registry allowlist
- trusted publishers
- outbound HTTP toggle + allowlist
- background jobs toggle
- WASM package manager:
- list installed packages
- upload package
- install from registry URL
- activate/deactivate
- edit package settings (schema-driven when available; raw JSON fallback)
- Clear error messages for:
- policy forbids uploads / registry installs
- signature requirements fail
- registry allowlist blocks URL
### Deliverables (backend contract hardening)
- Ensure API error responses are stable and actionable for UI (status code + message consistency).
- Ensure event emission for key actions is consistent (`public_events`).
### Acceptance criteria
- As community admin/moderator:
- can view/update plugin policy
- can install a WASM package (upload + registry) when policy allows
- can activate/deactivate packages
- can edit package settings and receive server-side schema validation errors when invalid
### Verification
- Manual UI walkthrough covering:
- signed-only policy
- registry allowlist allow/deny
- outbound HTTP allowlist allow/deny
- background jobs on/off behavior (see Phase 2 definition)
---
## Phase 2 — WASM packages production-grade hardening
### 2.1 Background jobs: **full implementation**
#### Proposed semantics (must be implemented consistently)
- `plugin_allow_background_jobs=false` means:
- WASM plugins must **not** be invoked for cron hooks for that community.
- Any future “scheduled” behavior for WASM must be gated by the same setting.
- `plugin_allow_background_jobs=true` means:
- WASM plugins may receive cron hooks they declare in their manifest (e.g. `cron.minute`, `cron.hourly`, `cron.daily`, ...).
#### Implementation outline (expected code touchpoints)
- Resolve where WASM cron hooks are dispatched (currently cron loop exists in `backend/src/main.rs` and invokes `PluginManager::do_wasm_action_for_community`).
- Add a community-settings check (`communities.settings.plugin_allow_background_jobs`) in the WASM cron dispatch path.
- Ensure policy API default behavior is explicit and safe:
- confirm default is false (current parse default is false).
#### Acceptance criteria
- When `plugin_allow_background_jobs=false`, WASM cron hooks are not executed for that community.
- When `plugin_allow_background_jobs=true`, WASM cron hooks execute normally.
### 2.2 Registry install SSRF hardening (DNS-aware)
#### Goal
Registry install should not be able to reach internal/private addresses via DNS rebinding or private resolution.
#### Deliverables
- Extend registry allowlist enforcement to:
- resolve DNS for hostname-based registry URLs
- reject if any resolved IP is loopback/private/link-local/unspecified/unique-local (IPv6)
- Keep existing protections:
- reject `localhost`
- reject IP-literal private/loopback
- enforce allowlist patterns
#### Acceptance criteria
- Registry install is blocked when a hostname resolves to a private/loopback/link-local address.
### 2.3 Registry fetch hardening (timeouts/size caps)
#### Deliverables
- Add explicit timeout and size bounds to registry bundle fetch.
- Current code path uses `reqwest::get(...)` without explicit timeout/size cap.
#### Acceptance criteria
- Registry fetch cannot hang indefinitely.
- Registry fetch cannot load an unbounded payload.
### 2.4 Operator-visible metadata
#### Deliverables
- UI shows package metadata:
- publisher
- sha256
- signature present
- source (upload/registry)
- registry URL
- manifest-declared hooks + capabilities
- effective outbound HTTP permission status
---
## Phase 3 — Authz convergence (roles/permissions authoritative)
### Goal
Stop using `community_members.role` as the primary enforcement mechanism for privileged actions.
### Deliverables
- Inventory endpoints that currently use `ensure_admin_or_moderator` style gates.
- Introduce/confirm permissions for:
- managing plugin policy
- managing plugin packages
- managing community plugin settings
- Migrate gates to permission checks consistently.
### Acceptance criteria
- Plugin policy + package management endpoints authorize via roles/permissions.
---
## Phase 4 — Technical debt hotspot inventory + targeted fixes
### Deliverables
- Evidence-backed inventory (file/function-level) of:
- cross-layer coupling hotspots
- duplicated policy parsing/enforcement
- plugin plane confusion (instance defaults vs community plugins vs wasm packages)
- any unstable areas discovered during Phases 13
- Only fix hotspots that block Phase 13 acceptance criteria.
---
## Commit plan (after this plan is approved)
- Add this plan into the repo under `.windsurf/plans/` and commit to `main`.
- Implementation starts only after the plan commit lands.

View file

@ -26,6 +26,12 @@ JWT_SECRET=change-me-in-production
# =============================================================================
# The backend applies a global fixed-window rate limiter (60s window).
#
# If you run the backend behind a reverse proxy, you may want to configure trusted proxy IPs
# so the backend can safely use X-Forwarded-For / X-Real-IP for rate limiting.
#
# Comma-separated IP allowlist (examples: 127.0.0.1,::1,10.0.0.10)
TRUSTED_PROXY_IPS=
#
# - Set RATE_LIMIT_ENABLED=false to disable all rate limiting.
# - Set any *_RPM variable to 0 to disable that specific limiter.
#

View file

@ -63,6 +63,42 @@ async fn register(
Extension(config): Extension<Arc<Config>>,
Json(req): Json<RegisterRequest>,
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
if req.username.trim().len() < 3 {
return Err((
StatusCode::BAD_REQUEST,
"Username must be at least 3 characters".to_string(),
));
}
if req.username.len() > 50 {
return Err((
StatusCode::BAD_REQUEST,
"Username must be at most 50 characters".to_string(),
));
}
if req.email.trim().is_empty() {
return Err((StatusCode::BAD_REQUEST, "Email is required".to_string()));
}
if req.email.len() > 255 {
return Err((
StatusCode::BAD_REQUEST,
"Email must be at most 255 characters".to_string(),
));
}
if req.password.len() < 8 {
return Err((
StatusCode::BAD_REQUEST,
"Password must be at least 8 characters".to_string(),
));
}
if let Some(display_name) = &req.display_name {
if display_name.len() > 100 {
return Err((
StatusCode::BAD_REQUEST,
"Display name must be at most 100 characters".to_string(),
));
}
}
// Check registration settings
let settings = sqlx::query!(
"SELECT registration_enabled, registration_mode FROM instance_settings LIMIT 1"

View file

@ -16,6 +16,8 @@ use sqlx::PgPool;
use sqlx::Row;
use std::net::IpAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::lookup_host;
use uuid::Uuid;
use crate::auth::AuthUser;
@ -443,7 +445,10 @@ fn verify_signature_if_required(
Ok(Some(sig_arr.to_vec()))
}
fn enforce_registry_allowlist(url: &Url, allowlist: &[String]) -> Result<(), (StatusCode, String)> {
async fn enforce_registry_allowlist(
url: &Url,
allowlist: &[String],
) -> Result<(), (StatusCode, String)> {
let host = url.host_str().ok_or((
StatusCode::BAD_REQUEST,
"Registry URL must include host".to_string(),
@ -477,11 +482,66 @@ fn enforce_registry_allowlist(url: &Url, allowlist: &[String]) -> Result<(), (St
));
}
if !allowlist.is_empty() && !allowlist.iter().any(|h| h == host) {
return Err((
StatusCode::FORBIDDEN,
"Registry host not in allowlist".to_string(),
));
if !allowlist.is_empty() {
let allowed = allowlist.iter().any(|pattern| {
if pattern == "*" {
return true;
}
if let Some(stripped) = pattern.strip_prefix("*.") {
let suffix = format!(".{stripped}");
host.ends_with(&suffix) || host.eq_ignore_ascii_case(stripped)
} else {
host.eq_ignore_ascii_case(pattern)
}
});
if !allowed {
return Err((
StatusCode::FORBIDDEN,
"Registry host not in allowlist".to_string(),
));
}
}
let port = url.port_or_known_default().unwrap_or(443);
let lookup = tokio::time::timeout(Duration::from_secs(3), lookup_host((host, port)))
.await
.map_err(|_| {
(
StatusCode::BAD_GATEWAY,
"Registry host DNS lookup timed out".to_string(),
)
})
.and_then(|res| {
res.map_err(|_| {
(
StatusCode::BAD_GATEWAY,
"Registry host DNS lookup failed".to_string(),
)
})
})?;
for addr in lookup {
let ip = addr.ip();
let is_disallowed = match ip {
IpAddr::V4(v4) => {
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
}
IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unique_local()
|| v6.is_unicast_link_local()
|| v6.is_unspecified()
}
};
if is_disallowed {
return Err((
StatusCode::FORBIDDEN,
"Registry host is not allowed".to_string(),
));
}
}
Ok(())
@ -1049,9 +1109,18 @@ async fn install_registry_plugin_package(
}
}
enforce_registry_allowlist(&url, &registry_allowlist)?;
enforce_registry_allowlist(&url, &registry_allowlist).await?;
let res = reqwest::get(url.clone())
const MAX_REGISTRY_BUNDLE_BYTES: u64 = 2 * 1024 * 1024;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut res = client
.get(url.clone())
.send()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
@ -1059,7 +1128,31 @@ async fn install_registry_plugin_package(
return Err((StatusCode::BAD_GATEWAY, "Registry fetch failed".to_string()));
}
let bundle: UploadPluginPackageRequest = res.json().await.map_err(|_| {
if let Some(len) = res.content_length() {
if len > MAX_REGISTRY_BUNDLE_BYTES {
return Err((
StatusCode::BAD_GATEWAY,
"Registry response too large".to_string(),
));
}
}
let mut body: Vec<u8> = Vec::new();
while let Some(chunk) = res
.chunk()
.await
.map_err(|_| (StatusCode::BAD_GATEWAY, "Registry fetch failed".to_string()))?
{
body.extend_from_slice(&chunk);
if body.len() as u64 > MAX_REGISTRY_BUNDLE_BYTES {
return Err((
StatusCode::BAD_GATEWAY,
"Registry response too large".to_string(),
));
}
}
let bundle: UploadPluginPackageRequest = serde_json::from_slice(&body).map_err(|_| {
(
StatusCode::BAD_GATEWAY,
"Invalid registry response".to_string(),

View file

@ -13,8 +13,8 @@ use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::api::permissions::{perms, require_permission};
use crate::auth::AuthUser;
// ============================================================================
// Types

View file

@ -277,7 +277,8 @@ async fn complete_setup(
if req.instance_type != "single_community" && req.platform_mode == "single_community" {
return Err((
StatusCode::BAD_REQUEST,
"Platform mode 'single_community' requires instance type 'single_community'".to_string(),
"Platform mode 'single_community' requires instance type 'single_community'"
.to_string(),
));
}
if req.platform_mode == "single_community" {

View file

@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::api::permissions::{perms, require_permission};
use crate::auth::AuthUser;
use crate::models::user::UserResponse;
pub fn router(pool: PgPool) -> Router {

View file

@ -10,6 +10,8 @@ pub struct Config {
pub server_port: u16,
#[serde(default)]
pub cors_allowed_origins: Option<String>,
#[serde(default)]
pub trusted_proxy_ips: Option<String>,
/// Enable demo mode - restricts destructive actions and enables demo accounts
#[serde(default)]
pub demo_mode: bool,
@ -77,6 +79,7 @@ impl Default for Config {
server_host: "127.0.0.1".to_string(),
server_port: 3000,
cors_allowed_origins: None,
trusted_proxy_ips: None,
demo_mode: false,
jwt_secret: default_jwt_secret(),
rate_limit_enabled: default_rate_limit_enabled(),

View file

@ -334,7 +334,9 @@ async fn run() -> Result<(), StartupError> {
// WASM plugins need per-community context.
let community_ids: Vec<Uuid> =
match sqlx::query_scalar("SELECT id FROM communities WHERE is_active = true")
match sqlx::query_scalar(
"SELECT id FROM communities WHERE is_active = true AND settings->>'plugin_allow_background_jobs' = 'true'",
)
.fetch_all(&cron_pool)
.await
{

View file

@ -17,6 +17,7 @@ use crate::config::Config;
#[derive(Clone)]
pub struct RateLimitState {
config: Arc<Config>,
trusted_proxies: Vec<IpAddr>,
ip: Arc<FixedWindowLimiter>,
user: Arc<FixedWindowLimiter>,
auth: Arc<FixedWindowLimiter>,
@ -26,6 +27,7 @@ impl RateLimitState {
pub fn new(config: Arc<Config>) -> Self {
let window = Duration::from_secs(60);
Self {
trusted_proxies: parse_trusted_proxy_ips(&config),
ip: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_ip_rpm)),
user: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_user_rpm)),
auth: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_auth_rpm)),
@ -121,6 +123,43 @@ fn parse_ip_from_connect_info<B>(request: &Request<B>) -> Option<IpAddr> {
.map(|ci| ci.0.ip())
}
fn parse_trusted_proxy_ips(config: &Config) -> Vec<IpAddr> {
let mut out: Vec<IpAddr> = Vec::new();
let raw = config
.trusted_proxy_ips
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty());
let Some(raw) = raw else {
return out;
};
for part in raw.split(',').map(|v| v.trim()).filter(|v| !v.is_empty()) {
match part.parse::<IpAddr>() {
Ok(ip) => out.push(ip),
Err(_) => {
tracing::warn!(value = part, "Invalid TRUSTED_PROXY_IPS entry; ignoring");
}
}
}
out
}
fn is_trusted_proxy(peer_ip: Option<IpAddr>, extra_trusted: &[IpAddr]) -> bool {
let Some(peer_ip) = peer_ip else {
return false;
};
if peer_ip.is_loopback() {
return true;
}
extra_trusted.iter().any(|ip| ip == &peer_ip)
}
fn parse_user_from_auth(headers: &HeaderMap, secret: &str) -> Option<Uuid> {
let auth = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
let mut pieces = auth.split_whitespace();
@ -162,7 +201,12 @@ pub async fn rate_limit_middleware(
}
let headers = request.headers();
let ip = parse_ip_from_headers(headers).or_else(|| parse_ip_from_connect_info(&request));
let peer_ip = parse_ip_from_connect_info(&request);
let ip = if is_trusted_proxy(peer_ip, &state.trusted_proxies) {
parse_ip_from_headers(headers).or(peer_ip)
} else {
peer_ip
};
let ip_key = ip
.map(|v| format!("ip:{}", v))
@ -212,9 +256,11 @@ pub async fn rate_limit_middleware(
mod tests {
use super::{rate_limit_middleware, FixedWindowLimiter, RateLimitState};
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::{header, Request, StatusCode};
use axum::routing::get;
use axum::Router;
use std::net::SocketAddr;
use std::sync::Arc;
use tower::ServiceExt;
use uuid::Uuid;
@ -295,22 +341,26 @@ mod tests {
));
for _ in 0..2 {
let req = Request::builder()
let mut req = Request::builder()
.method("GET")
.uri("/api/ping")
.header("x-forwarded-for", "1.2.3.4")
.body(Body::empty())
.unwrap();
req.extensions_mut()
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
let req = Request::builder()
let mut req = Request::builder()
.method("GET")
.uri("/api/ping")
.header("x-forwarded-for", "1.2.3.4")
.body(Body::empty())
.unwrap();
req.extensions_mut()
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(res.headers().get(header::RETRY_AFTER).is_some());
@ -333,21 +383,25 @@ mod tests {
rate_limit_middleware,
));
let req = Request::builder()
let mut req = Request::builder()
.method("GET")
.uri("/api/auth/login")
.header("x-forwarded-for", "1.2.3.4")
.body(Body::empty())
.unwrap();
req.extensions_mut()
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let req = Request::builder()
let mut req = Request::builder()
.method("GET")
.uri("/api/auth/login")
.header("x-forwarded-for", "1.2.3.4")
.body(Body::empty())
.unwrap();
req.extensions_mut()
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(res.headers().get(header::RETRY_AFTER).is_some());
@ -374,23 +428,108 @@ mod tests {
rate_limit_middleware,
));
let req = Request::builder()
let mut req = Request::builder()
.method("GET")
.uri("/api/ping")
.header("x-forwarded-for", "1.2.3.4")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
req.extensions_mut()
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let req = Request::builder()
let mut req = Request::builder()
.method("GET")
.uri("/api/ping")
.header("x-forwarded-for", "1.2.3.4")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
req.extensions_mut()
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(res.headers().get(header::RETRY_AFTER).is_some());
}
#[tokio::test]
async fn forwarded_ip_respected_when_peer_is_trusted_proxy() {
let cfg = Arc::new(Config {
rate_limit_enabled: true,
rate_limit_ip_rpm: 1,
rate_limit_user_rpm: 0,
rate_limit_auth_rpm: 0,
..Default::default()
});
let app = Router::new()
.route("/api/ping", get(|| async { "ok" }))
.layer(axum::middleware::from_fn_with_state(
RateLimitState::new(cfg.clone()),
rate_limit_middleware,
));
let mut req = Request::builder()
.method("GET")
.uri("/api/ping")
.header("x-forwarded-for", "1.1.1.1")
.body(Body::empty())
.unwrap();
req.extensions_mut()
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let mut req = Request::builder()
.method("GET")
.uri("/api/ping")
.header("x-forwarded-for", "2.2.2.2")
.body(Body::empty())
.unwrap();
req.extensions_mut()
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn forwarded_ip_ignored_when_peer_is_not_trusted_proxy() {
let cfg = Arc::new(Config {
rate_limit_enabled: true,
rate_limit_ip_rpm: 1,
rate_limit_user_rpm: 0,
rate_limit_auth_rpm: 0,
..Default::default()
});
let app = Router::new()
.route("/api/ping", get(|| async { "ok" }))
.layer(axum::middleware::from_fn_with_state(
RateLimitState::new(cfg.clone()),
rate_limit_middleware,
));
let mut req = Request::builder()
.method("GET")
.uri("/api/ping")
.header("x-forwarded-for", "1.1.1.1")
.body(Body::empty())
.unwrap();
req.extensions_mut()
.insert(ConnectInfo(SocketAddr::from(([10, 0, 0, 2], 12345))));
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let mut req = Request::builder()
.method("GET")
.uri("/api/ping")
.header("x-forwarded-for", "2.2.2.2")
.body(Body::empty())
.unwrap();
req.extensions_mut()
.insert(ConnectInfo(SocketAddr::from(([10, 0, 0, 2], 12345))));
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(res.headers().get(header::RETRY_AFTER).is_some());

View file

@ -11,6 +11,7 @@ DB_PORT=5433
JWT_SECRET=demo_jwt_secret_not_for_production
BACKEND_PORT=3001
BACKEND_BIND_HOST=0.0.0.0
TRUSTED_PROXY_IPS=
# Frontend
FRONTEND_PORT=4322

View file

@ -9,6 +9,7 @@ DB_PORT=5432
# Backend
JWT_SECRET=CHANGE_THIS_TO_RANDOM_64_CHAR_STRING
TRUSTED_PROXY_IPS=
BACKEND_PORT=3000
# Frontend

View file

@ -32,6 +32,12 @@ services:
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-likwid_demo}:${POSTGRES_PASSWORD:-demo_secret_change_me}@postgres:5432/${POSTGRES_DB:-likwid_demo}
JWT_SECRET: ${JWT_SECRET:-demo_jwt_secret_not_for_production}
TRUSTED_PROXY_IPS: ${TRUSTED_PROXY_IPS:-}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true}
RATE_LIMIT_IP_RPM: ${RATE_LIMIT_IP_RPM:-300}
RATE_LIMIT_USER_RPM: ${RATE_LIMIT_USER_RPM:-1200}
RATE_LIMIT_AUTH_RPM: ${RATE_LIMIT_AUTH_RPM:-30}
SERVER_HOST: 0.0.0.0
SERVER_PORT: 3000
DEMO_MODE: "true"

View file

@ -33,6 +33,12 @@ services:
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-likwid}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-likwid_prod}
JWT_SECRET: ${JWT_SECRET}
TRUSTED_PROXY_IPS: ${TRUSTED_PROXY_IPS:-}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true}
RATE_LIMIT_IP_RPM: ${RATE_LIMIT_IP_RPM:-300}
RATE_LIMIT_USER_RPM: ${RATE_LIMIT_USER_RPM:-1200}
RATE_LIMIT_AUTH_RPM: ${RATE_LIMIT_AUTH_RPM:-30}
SERVER_HOST: 0.0.0.0
SERVER_PORT: 3000
DEMO_MODE: "false"

View file

@ -14,6 +14,7 @@ Likwid is configured through environment variables and database settings.
| `SERVER_PORT` | No | `3000` | HTTP port |
| `DEMO_MODE` | No | `false` | Enable demo features |
| `CORS_ALLOWED_ORIGINS` | No | - | Comma-separated allowlist of CORS origins for browsers (recommended in production) |
| `TRUSTED_PROXY_IPS` | No | - | Comma-separated allowlist of reverse proxy IPs whose `X-Forwarded-For` / `X-Real-IP` headers should be trusted |
| `RATE_LIMIT_ENABLED` | No | `true` | Enable API rate limiting |
| `RATE_LIMIT_IP_RPM` | No | `300` | Requests per minute per IP |
| `RATE_LIMIT_USER_RPM` | No | `1200` | Requests per minute per authenticated user |
@ -102,9 +103,12 @@ Behavior:
IP detection order:
- `x-forwarded-for` (first IP in list)
- `x-real-ip`
- TCP peer address (Axum `ConnectInfo`)
- If the TCP peer is a trusted proxy (loopback by default, plus `TRUSTED_PROXY_IPS`):
- `x-forwarded-for` (first IP in list)
- `x-real-ip`
- TCP peer address (Axum `ConnectInfo`)
- Otherwise:
- TCP peer address (Axum `ConnectInfo`)
Responses when limited:

View file

@ -93,6 +93,8 @@ For production, use a reverse proxy (nginx, Caddy) with:
- Proper headers
- HSTS (set on the reverse proxy)
If you want per-IP rate limiting to use `X-Forwarded-For` / `X-Real-IP`, set `TRUSTED_PROXY_IPS` to the reverse proxy IP(s) (loopback is trusted by default).
Example nginx config:
```nginx

View file

@ -5,7 +5,7 @@ This guide describes a practical, operator-first way to run Likwid on openSUSE L
## Assumptions
- You have an SSH-accessible server running openSUSE Leap.
- You run Likwid as a dedicated non-root user (recommended: `deploy`).
- You run Likwid as a dedicated non-root user (example: `deploy`).
- A reverse proxy (Caddy/nginx) terminates TLS and forwards:
- `/` to the frontend
- `/api` to the backend
@ -55,6 +55,7 @@ cp ~/likwid/compose/.env.production.example ~/likwid/compose/.env.production
- `POSTGRES_PASSWORD`
- `JWT_SECRET`
- `API_BASE` (should be your public URL, e.g. `https://your.domain`)
- `TRUSTED_PROXY_IPS` (if your reverse proxy does not connect from loopback)
1. Start services:

View file

@ -12,17 +12,10 @@ Securing your Likwid instance.
### Password Policy
- Minimum 8 characters (configurable)
- Bcrypt hashing with cost factor 12
- Minimum 8 characters
- Argon2 password hashing (server-side)
- No password in logs or error messages
### Two-Factor Authentication
Enable 2FA support for users:
- TOTP (Time-based One-Time Password)
- Backup codes for recovery
## Network Security
### HTTPS
@ -67,6 +60,8 @@ Protect against abuse:
- 1200 requests/minute per authenticated user
- 30 requests/minute per IP for auth endpoints
If you run the backend behind a reverse proxy, configure `TRUSTED_PROXY_IPS` so the backend can safely use `X-Forwarded-For` / `X-Real-IP` when applying per-IP limits.
## Database Security
### Connection
@ -104,15 +99,13 @@ All inputs are validated:
## Moderation Audit Trail
All moderation actions are logged:
Moderation actions are recorded:
- Who performed the action
- What action was taken
- Why (reason required)
- When it happened
Logs are immutable and tamper-evident.
## Updates
Keep Likwid updated:

View file

@ -17,6 +17,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
const url = new URL(context.request.url);
const path = url.pathname;
const forwardedProto = context.request.headers.get('x-forwarded-proto')?.trim().toLowerCase();
const isHttps = url.protocol === 'https:' || forwardedProto === 'https';
if (path === '/demo' && url.searchParams.get('enter') === '1') {
let nextPath = url.searchParams.get('next') || '/communities';
if (!nextPath.startsWith('/')) {
@ -27,6 +30,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: isHttps,
maxAge: 60 * 60 * 12,
});

View file

@ -117,6 +117,277 @@ const { slug } = Astro.params;
return res.json();
}
async function fetchPluginPolicy(communityId) {
const res = await fetch(`${apiBase}/api/communities/${communityId}/plugin-policy`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) {
const err = await res.text();
throw new Error(err || 'Failed to load plugin policy');
}
return res.json();
}
async function fetchPluginPackages(communityId) {
const res = await fetch(`${apiBase}/api/communities/${communityId}/plugin-packages`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) {
const err = await res.text();
throw new Error(err || 'Failed to load plugin packages');
}
return res.json();
}
function normalizeStringList(text) {
return String(text || '')
.split(/\r?\n/)
.map(v => v.trim())
.filter(v => v.length > 0);
}
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
let binary = '';
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode.apply(null, chunk);
}
return btoa(binary);
}
function renderPolicyEditor(policy) {
const trust = policy?.trust_policy || 'signed_only';
const installSources = new Set(policy?.install_sources || []);
const allowOutbound = !!policy?.allow_outbound_http;
const httpAllowlist = (policy?.http_egress_allowlist || []).join('\n');
const registryAllowlist = (policy?.registry_allowlist || []).join('\n');
const allowBg = !!policy?.allow_background_jobs;
const publishers = (policy?.trusted_publishers || []).join('\n');
return `
<section class="ui-card policy-card">
<div class="policy-head">
<h2>Plugin Policy</h2>
<div class="policy-actions">
<button type="button" class="ui-btn ui-btn-primary" id="save-policy">Save Policy</button>
<span class="status" id="policy-status"></span>
</div>
</div>
<div class="ui-form" style="--ui-form-control-max-width: 560px; --ui-form-group-mb: 1rem;">
<div class="form-group">
<label for="policy-trust">Trust policy</label>
<select id="policy-trust">
<option value="signed_only" ${trust === 'signed_only' ? 'selected' : ''}>Signed only</option>
<option value="unsigned_allowed" ${trust === 'unsigned_allowed' ? 'selected' : ''}>Unsigned allowed</option>
</select>
</div>
<div class="form-group">
<label>Install sources</label>
<div class="policy-checkboxes">
<label class="checkbox-label">
<input type="checkbox" id="policy-source-upload" ${installSources.has('upload') ? 'checked' : ''} />
<span>Upload</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="policy-source-registry" ${installSources.has('registry') ? 'checked' : ''} />
<span>Registry</span>
</label>
</div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="policy-allow-outbound" ${allowOutbound ? 'checked' : ''} />
<span>Allow outbound HTTP (WASM)</span>
</label>
</div>
<div class="form-group">
<label for="policy-http-allowlist">HTTP egress allowlist (one per line)</label>
<textarea id="policy-http-allowlist" spellcheck="false" rows="4">${escapeHtml(httpAllowlist)}</textarea>
</div>
<div class="form-group">
<label for="policy-registry-allowlist">Registry allowlist (one host per line; exact or *.suffix)</label>
<textarea id="policy-registry-allowlist" spellcheck="false" rows="4">${escapeHtml(registryAllowlist)}</textarea>
</div>
<div class="form-group">
<label for="policy-trusted-publishers">Trusted publishers (one Ed25519 key per line)</label>
<textarea id="policy-trusted-publishers" spellcheck="false" rows="4">${escapeHtml(publishers)}</textarea>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="policy-allow-background" ${allowBg ? 'checked' : ''} />
<span>Allow background jobs (WASM cron hooks)</span>
</label>
</div>
</div>
</section>
`;
}
function packageSettingsSchema(pkg) {
const schema = pkg?.manifest?.settings_schema;
if (schema && schema.properties) return schema;
return null;
}
function renderPackageSettingsForm(pkg) {
const schema = packageSettingsSchema(pkg);
const name = String(pkg?.package_id || '');
const settings = pkg?.settings || {};
if (!schema) {
const settingsText = JSON.stringify(settings ?? {}, null, 2);
return `
<div class="settings-body ui-form" style="--ui-form-group-mb: 0.75rem;">
<p class="settings-hint">No schema defined. Edit raw JSON:</p>
<textarea class="settings-json" spellcheck="false" data-package-id="${escapeHtml(name)}">${escapeHtml(settingsText)}</textarea>
<div class="settings-actions">
<button class="ui-btn ui-btn-primary js-save-package-json" data-package-id="${escapeHtml(name)}">Save Settings</button>
<span class="status" id="pkg-status-${escapeHtml(name)}"></span>
</div>
</div>
`;
}
const fields = Object.entries(schema.properties).map(([key, propSchema]) => {
return renderSchemaField(`pkg-${name}`, key, propSchema, settings?.[key]);
}).join('');
return `
<form class="settings-form ui-form pkg-settings-form" data-package-id="${escapeHtml(name)}" style="--ui-form-group-mb: 1rem;">
${fields}
<div class="settings-actions">
<button type="submit" class="ui-btn ui-btn-primary">Save Settings</button>
<span class="status" id="pkg-status-${escapeHtml(name)}"></span>
</div>
</form>
`;
}
function renderPackages(packages) {
const list = Array.isArray(packages) ? packages : [];
return `
<section class="ui-card packages-card">
<div class="packages-head">
<h2>WASM Plugin Packages</h2>
<div class="packages-actions">
<button type="button" class="ui-btn ui-btn-secondary" id="refresh-packages">Refresh</button>
</div>
</div>
<div class="ui-form" style="--ui-form-control-max-width: 560px; --ui-form-group-mb: 1rem;">
<div class="form-group">
<label for="registry-url">Install from registry URL</label>
<div class="inline-row">
<input type="url" id="registry-url" placeholder="https://registry.example/plugin-bundle.json" />
<button type="button" class="ui-btn ui-btn-primary" id="install-registry">Install</button>
</div>
<span class="status" id="registry-status"></span>
</div>
<details class="panel ui-card" open>
<summary class="panel-summary">
<span class="panel-title">Upload package</span>
<span class="panel-meta"><span class="ui-badge">WASM</span></span>
</summary>
<div class="panel-body">
<form id="upload-form" class="ui-form" style="--ui-form-group-mb: 0.75rem;">
<div class="form-group">
<label for="upload-name">Name</label>
<input id="upload-name" type="text" required />
</div>
<div class="form-group">
<label for="upload-version">Version</label>
<input id="upload-version" type="text" required />
</div>
<div class="form-group">
<label for="upload-description">Description</label>
<input id="upload-description" type="text" />
</div>
<div class="form-group">
<label for="upload-publisher">Publisher (Ed25519 public key, base64)</label>
<input id="upload-publisher" type="text" />
</div>
<div class="form-group">
<label for="upload-signature">Signature (base64, optional)</label>
<input id="upload-signature" type="text" />
</div>
<div class="form-group">
<label for="upload-manifest">Manifest (JSON)</label>
<textarea id="upload-manifest" spellcheck="false" rows="6" required>{\n "name": "",\n "version": "",\n "description": "",\n "hooks": [],\n "capabilities": []\n}</textarea>
</div>
<div class="form-group">
<label for="upload-wasm">WASM file</label>
<input id="upload-wasm" type="file" accept=".wasm,application/wasm" required />
</div>
<div class="settings-actions">
<button type="submit" class="ui-btn ui-btn-primary" id="upload-btn">Upload</button>
<span class="status" id="upload-status"></span>
</div>
</form>
</div>
</details>
</div>
<div class="packages-list">
${list.length === 0 ? `
<div class="state-card ui-card">
<p class="empty">No packages installed.</p>
<p class="hint">Install from registry or upload a WASM bundle.</p>
</div>
` : list.map(pkg => {
const pkgId = String(pkg.package_id);
const title = `${escapeHtml(pkg.name)} <span class="version">v${escapeHtml(pkg.version)}</span>`;
return `
<div class="plugin-card ui-card" data-package-id="${escapeHtml(pkgId)}">
<div class="plugin-head">
<div class="plugin-title">
<h3>${title}</h3>
${pkg.signature_present ? `<span class="ui-pill ui-pill-core">signed</span>` : `<span class="ui-pill ui-pill-disabled">unsigned</span>`}
${pkg.source ? `<span class="ui-pill">${escapeHtml(pkg.source)}</span>` : ''}
</div>
<div class="plugin-controls">
<label class="switch" title="Activate/deactivate">
<input class="package-toggle" type="checkbox" data-package-id="${escapeHtml(pkgId)}" ${pkg.is_active ? 'checked' : ''} />
<span class="slider"></span>
</label>
</div>
</div>
<p class="desc">${escapeHtml(pkg.description || '')}</p>
<div class="pkg-meta">
<div><span class="k">publisher</span> <span class="v">${escapeHtml(pkg.publisher || '')}</span></div>
<div><span class="k">sha256</span> <span class="v">${escapeHtml(pkg.wasm_sha256 || '')}</span></div>
${pkg.registry_url ? `<div><span class="k">registry</span> <span class="v">${escapeHtml(pkg.registry_url)}</span></div>` : ''}
</div>
<details class="panel ui-card settings-panel">
<summary class="panel-summary">
<span class="panel-title">Settings</span>
<span class="panel-meta">${packageSettingsSchema(pkg) ? 'Schema' : '<span class="ui-badge">JSON</span>'}</span>
</summary>
<div class="panel-body settings-body-wrap">
${renderPackageSettingsForm(pkg)}
</div>
</details>
</div>
`;
}).join('')}
</div>
</section>
`;
}
async function fetchPlugins(communityId) {
const res = await fetch(`${apiBase}/api/communities/${communityId}/plugins`, {
headers: { 'Authorization': `Bearer ${token}` },
@ -207,7 +478,7 @@ const { slug } = Astro.params;
`;
}
function renderPlugins(community, membership, plugins) {
function renderPlugins(community, membership, policy, packages, plugins) {
const container = document.getElementById('plugins-content');
if (!container) return;
@ -218,21 +489,7 @@ const { slug } = Astro.params;
return;
}
if (!plugins || plugins.length === 0) {
container.innerHTML = `
<div class="state-card ui-card">
<p class="empty">No plugins available.</p>
<p class="hint">If this seems wrong, try reloading the page.</p>
<div class="state-actions">
<button type="button" class="ui-btn ui-btn-primary" id="retry-plugins-empty">Retry</button>
<a class="ui-btn ui-btn-secondary" href="/communities/${encodeURIComponent(String(slug || ''))}">Back to community</a>
</div>
</div>
`;
document.getElementById('retry-plugins-empty')?.addEventListener('click', load);
return;
}
const safePlugins = Array.isArray(plugins) ? plugins : [];
container.innerHTML = `
<div class="community-badge ui-card">
@ -243,8 +500,16 @@ const { slug } = Astro.params;
<div class="community-badge-subtitle">Community plugin settings</div>
</div>
${renderPolicyEditor(policy)}
${renderPackages(packages)}
<div class="plugins-list">
${plugins.map(p => {
${safePlugins.length === 0 ? `
<div class="state-card ui-card">
<p class="empty">No built-in plugins available.</p>
</div>
` : safePlugins.map(p => {
const disabledGlobally = !p.global_is_active;
const isCore = !!p.is_core;
const checked = !!p.community_is_active;
@ -286,6 +551,296 @@ const { slug } = Astro.params;
</div>
`;
document.getElementById('refresh-packages')?.addEventListener('click', load);
document.getElementById('save-policy')?.addEventListener('click', async () => {
const statusEl = document.getElementById('policy-status');
const btn = document.getElementById('save-policy');
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = 'Saving...';
try {
const current = {
trust_policy: (document.getElementById('policy-trust'))?.value,
install_sources: [
(document.getElementById('policy-source-upload'))?.checked ? 'upload' : null,
(document.getElementById('policy-source-registry'))?.checked ? 'registry' : null,
].filter(Boolean),
allow_outbound_http: (document.getElementById('policy-allow-outbound'))?.checked,
http_egress_allowlist: normalizeStringList((document.getElementById('policy-http-allowlist'))?.value),
registry_allowlist: normalizeStringList((document.getElementById('policy-registry-allowlist'))?.value),
trusted_publishers: normalizeStringList((document.getElementById('policy-trusted-publishers'))?.value),
allow_background_jobs: (document.getElementById('policy-allow-background'))?.checked,
};
const patch = {};
if (!policy || current.trust_policy !== policy.trust_policy) patch.trust_policy = current.trust_policy;
if (!policy || JSON.stringify(current.install_sources) !== JSON.stringify(policy.install_sources || [])) patch.install_sources = current.install_sources;
if (!policy || current.allow_outbound_http !== !!policy.allow_outbound_http) patch.allow_outbound_http = current.allow_outbound_http;
if (!policy || JSON.stringify(current.http_egress_allowlist) !== JSON.stringify(policy.http_egress_allowlist || [])) patch.http_egress_allowlist = current.http_egress_allowlist;
if (!policy || JSON.stringify(current.registry_allowlist) !== JSON.stringify(policy.registry_allowlist || [])) patch.registry_allowlist = current.registry_allowlist;
if (!policy || JSON.stringify(current.trusted_publishers) !== JSON.stringify(policy.trusted_publishers || [])) patch.trusted_publishers = current.trusted_publishers;
if (!policy || current.allow_background_jobs !== !!policy.allow_background_jobs) patch.allow_background_jobs = current.allow_background_jobs;
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-policy`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(patch),
});
if (!res.ok) {
throw new Error(await res.text());
}
if (statusEl) statusEl.textContent = 'Saved';
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
await load();
} catch (err) {
if (statusEl) statusEl.textContent = 'Error';
alert(err?.message || 'Failed to save policy');
} finally {
if (btn) btn.disabled = false;
}
});
document.getElementById('install-registry')?.addEventListener('click', async () => {
const input = document.getElementById('registry-url');
const statusEl = document.getElementById('registry-status');
const btn = document.getElementById('install-registry');
const url = input?.value;
if (!url) return;
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = 'Installing...';
try {
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/install-registry`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ url }),
});
if (!res.ok) {
throw new Error(await res.text());
}
if (statusEl) statusEl.textContent = 'Installed';
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
if (input) input.value = '';
await load();
} catch (err) {
if (statusEl) statusEl.textContent = 'Error';
alert(err?.message || 'Failed to install from registry');
} finally {
if (btn) btn.disabled = false;
}
});
document.getElementById('upload-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('upload-btn');
const statusEl = document.getElementById('upload-status');
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = 'Uploading...';
try {
const name = (document.getElementById('upload-name'))?.value;
const version = (document.getElementById('upload-version'))?.value;
const description = (document.getElementById('upload-description'))?.value || null;
const publisher = (document.getElementById('upload-publisher'))?.value || null;
const signature_base64 = (document.getElementById('upload-signature'))?.value || null;
const manifestText = (document.getElementById('upload-manifest'))?.value;
const wasmFile = (document.getElementById('upload-wasm'))?.files?.[0];
if (!name || !version || !manifestText || !wasmFile) {
throw new Error('Missing required fields');
}
let manifest;
try {
manifest = JSON.parse(manifestText);
} catch {
throw new Error('Invalid manifest JSON');
}
const wasmBuf = await wasmFile.arrayBuffer();
const wasm_base64 = arrayBufferToBase64(wasmBuf);
const body = {
name,
version,
description,
publisher,
manifest,
wasm_base64,
signature_base64,
};
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(await res.text());
}
if (statusEl) statusEl.textContent = 'Uploaded';
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
await load();
} catch (err) {
if (statusEl) statusEl.textContent = 'Error';
alert(err?.message || 'Failed to upload package');
} finally {
if (btn) btn.disabled = false;
}
});
document.querySelectorAll('.package-toggle').forEach(el => {
el.addEventListener('change', async (e) => {
const input = e.target;
const packageId = input.dataset.packageId;
const next = input.checked;
input.disabled = true;
try {
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/${packageId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ is_active: next }),
});
if (!res.ok) {
const err = await res.text();
input.checked = !next;
alert(err || 'Failed to update package');
} else {
await load();
}
} catch (err) {
input.checked = !next;
alert('Failed to update package');
} finally {
input.disabled = false;
}
});
});
document.querySelectorAll('.js-save-package-json').forEach(el => {
el.addEventListener('click', async (e) => {
const btn = e.target;
const packageId = btn.dataset.packageId;
const textarea = document.querySelector(`textarea.settings-json[data-package-id="${packageId}"]`);
const statusEl = document.getElementById(`pkg-status-${packageId}`);
if (!textarea) return;
let settings;
try {
settings = JSON.parse(textarea.value || '{}');
} catch {
if (statusEl) statusEl.textContent = 'Invalid JSON';
return;
}
btn.disabled = true;
if (statusEl) statusEl.textContent = 'Saving...';
try {
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/${packageId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ settings }),
});
if (res.ok) {
if (statusEl) statusEl.textContent = 'Saved';
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
await load();
} else {
const err = await res.text();
if (statusEl) statusEl.textContent = 'Error';
alert(err || 'Failed to save settings');
}
} catch {
if (statusEl) statusEl.textContent = 'Error';
alert('Failed to save settings');
} finally {
btn.disabled = false;
}
});
});
document.querySelectorAll('.pkg-settings-form').forEach(form => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const packageId = form.dataset.packageId;
const statusEl = document.getElementById(`pkg-status-${packageId}`);
const btn = form.querySelector('button[type="submit"]');
const settings = {};
form.querySelectorAll('[data-key]').forEach(input => {
const key = input.dataset.key;
const type = input.dataset.type;
if (type === 'boolean') {
settings[key] = input.checked;
} else if (type === 'integer') {
settings[key] = input.value ? parseInt(input.value, 10) : null;
} else if (type === 'number') {
settings[key] = input.value ? parseFloat(input.value) : null;
} else {
settings[key] = input.value;
}
});
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = 'Saving...';
try {
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/${packageId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ settings }),
});
if (res.ok) {
if (statusEl) statusEl.textContent = 'Saved';
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
await load();
} else {
const err = await res.text();
if (statusEl) statusEl.textContent = 'Error';
alert(err || 'Failed to save settings');
}
} catch {
if (statusEl) statusEl.textContent = 'Error';
alert('Failed to save settings');
} finally {
if (btn) btn.disabled = false;
}
});
});
document.querySelectorAll('.plugin-toggle').forEach(el => {
el.addEventListener('change', async (e) => {
const input = e.target;
@ -436,9 +991,19 @@ const { slug } = Astro.params;
}
const membership = await fetchMembership(community.id);
const plugins = await fetchPlugins(community.id);
const allowed = membership?.role === 'admin' || membership?.role === 'moderator';
if (!allowed) {
renderForbiddenState(community?.name || 'this community');
return;
}
renderPlugins(community, membership, plugins);
const [policy, packages, plugins] = await Promise.all([
fetchPluginPolicy(community.id),
fetchPluginPackages(community.id),
fetchPlugins(community.id),
]);
renderPlugins(community, membership, policy, packages, plugins);
} catch (err) {
const msg = err?.message || 'Failed to load plugins.';