diff --git a/crates/assets/js/admin/src/App.tsx b/crates/assets/js/admin/src/App.tsx
index 334de320c..54b8a034d 100644
--- a/crates/assets/js/admin/src/App.tsx
+++ b/crates/assets/js/admin/src/App.tsx
@@ -6,6 +6,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import { TablePage } from "@/components/tables/TablesPage";
import { AccountsPage } from "@/components/accounts/AccountsPage";
+import { WasmModulesPage } from "@/components/wasm/WasmModulesPage";
+import { WasmModuleSettingsPage } from "@/components/wasm/WasmModuleSettingsPage";
import { LoginPage } from "@/components/auth/LoginPage";
import { SettingsPage } from "@/components/settings/SettingsPage";
import { IndexPage } from "@/components/IndexPage";
@@ -100,6 +102,8 @@ const App: Component = () => {
+
+
diff --git a/crates/assets/js/admin/src/components/IndexPage.tsx b/crates/assets/js/admin/src/components/IndexPage.tsx
index 1428d87f6..81a15b675 100644
--- a/crates/assets/js/admin/src/components/IndexPage.tsx
+++ b/crates/assets/js/admin/src/components/IndexPage.tsx
@@ -6,6 +6,7 @@ import {
TbOutlineEdit,
TbOutlineChartDots3,
TbOutlineUsers,
+ TbOutlinePackage,
TbOutlineTimeline,
TbOutlineSettings,
} from "solid-icons/tb";
@@ -88,6 +89,11 @@ const elements = [
content: "Browse and manage your application's user registry.",
href: `${BASE}/auth`,
},
+ {
+ icon: TbOutlinePackage,
+ content: "Loaded WASM modules",
+ href: `${BASE}/wasm-modules`,
+ },
{
icon: TbOutlineTimeline,
content: "Access logs for your application",
diff --git a/crates/assets/js/admin/src/components/Navbar.tsx b/crates/assets/js/admin/src/components/Navbar.tsx
index 3ef653c9d..1d9cfeeee 100644
--- a/crates/assets/js/admin/src/components/Navbar.tsx
+++ b/crates/assets/js/admin/src/components/Navbar.tsx
@@ -8,6 +8,7 @@ import {
TbOutlineChartDots3,
TbOutlineTimeline,
TbOutlineSettings,
+ TbOutlinePackage,
} from "solid-icons/tb";
import { AuthButton } from "@/components/auth/AuthButton";
@@ -36,6 +37,7 @@ const options = [
[`${BASE}/editor`, TbOutlineEdit, "SQL Editor"],
[`${BASE}/erd`, TbOutlineChartDots3, "Entity Relationship Diagram"],
[`${BASE}/auth`, TbOutlineUsers, "User Accounts"],
+ [`${BASE}/wasm-modules`, TbOutlinePackage, "WASM Modules"],
[`${BASE}/logs`, TbOutlineTimeline, "Logs & Metrics"],
[`${BASE}/settings/`, TbOutlineSettings, "Settings"],
] as const;
diff --git a/crates/assets/js/admin/src/components/wasm/WasmModuleSettingsPage.tsx b/crates/assets/js/admin/src/components/wasm/WasmModuleSettingsPage.tsx
new file mode 100644
index 000000000..1d0f7b2b8
--- /dev/null
+++ b/crates/assets/js/admin/src/components/wasm/WasmModuleSettingsPage.tsx
@@ -0,0 +1,147 @@
+import { createEffect, createSignal, Match, Show, Switch } from "solid-js";
+import { A, useParams } from "@solidjs/router";
+import { useQuery } from "@tanstack/solid-query";
+import { TbOutlineArrowLeft } from "solid-icons/tb";
+
+import { Header } from "@/components/Header";
+import { Spinner } from "@/components/Spinner";
+
+import { fetchWasmModules } from "@/lib/api/wasm-modules";
+
+// Injects an HTML fragment string into a container element.
+// Script tags in the fragment do not execute when set via innerHTML; this
+// function clones each script element so the browser treats it as new and
+// executes it. Non-script nodes are inserted as-is.
+function injectFragment(container: HTMLDivElement, html: string): void {
+ container.innerHTML = html;
+ container.querySelectorAll("script").forEach((old) => {
+ const next = document.createElement("script");
+ Array.from(old.attributes).forEach((attr) => {
+ next.setAttribute(attr.name, attr.value);
+ });
+ next.textContent = old.textContent;
+ old.parentNode?.replaceChild(next, old);
+ });
+}
+
+export function WasmModuleSettingsPage() {
+ const params = useParams<{ name: string }>();
+
+ const wasmModules = useQuery(() => ({
+ queryKey: ["wasm-modules"],
+ queryFn: fetchWasmModules,
+ // The module list doesn't change on its own during an admin session;
+ // refetching on window focus would re-inject the settings fragment and
+ // reset any unsaved state the user may have entered.
+ refetchOnWindowFocus: false,
+ }));
+
+ const module = () =>
+ wasmModules.data?.modules.find((m) => m.name === params.name);
+
+ const [fragmentState, setFragmentState] = createSignal<
+ | { status: "idle" }
+ | { status: "loading" }
+ | { status: "error"; message: string }
+ | { status: "ready" }
+ >({ status: "idle" });
+
+ let containerRef: HTMLDivElement | undefined;
+ // Tracks the config_path that was last successfully injected so that
+ // spurious re-runs of the effect (e.g. from an unrelated query refetch that
+ // produces a new object reference) don't wipe and re-inject the fragment.
+ let loadedConfigPath: string | undefined;
+
+ createEffect(() => {
+ const mod = module();
+ if (!mod) return;
+ const configPath = mod.config_path;
+ if (!configPath) return;
+ if (configPath === loadedConfigPath) return;
+
+ setFragmentState({ status: "loading" });
+
+ fetch(configPath, { credentials: "include" })
+ .then((res) => {
+ if (!res.ok) {
+ throw new Error(`HTTP ${res.status}`);
+ }
+ return res.text();
+ })
+ .then((html) => {
+ if (containerRef) {
+ injectFragment(containerRef, html);
+ loadedConfigPath = configPath;
+ setFragmentState({ status: "ready" });
+ }
+ })
+ .catch((err: unknown) => {
+ const message =
+ err instanceof Error ? err.message : "Failed to load settings";
+ setFragmentState({ status: "error", message });
+ });
+ });
+
+ const backLink = () => (
+
+
+
+ );
+
+ return (
+
+
+
+
+ }
+ >
+
+
+
+
+ No module named "{params.name}" is installed.
+
+
+
+
+
+
+ This module has no settings page.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Failed to load settings:{" "}
+ {(fragmentState() as { status: "error"; message: string }).message}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/crates/assets/js/admin/src/components/wasm/WasmModulesPage.tsx b/crates/assets/js/admin/src/components/wasm/WasmModulesPage.tsx
new file mode 100644
index 000000000..ce3aec2cc
--- /dev/null
+++ b/crates/assets/js/admin/src/components/wasm/WasmModulesPage.tsx
@@ -0,0 +1,120 @@
+import { createMemo, For, Show } from "solid-js";
+import { useQuery } from "@tanstack/solid-query";
+import { A } from "@solidjs/router";
+import {
+ TbOutlinePackage,
+ TbOutlinePuzzle,
+ TbOutlineSettings,
+} from "solid-icons/tb";
+
+import { Header } from "@/components/Header";
+import { Spinner } from "@/components/Spinner";
+
+import { fetchWasmModules } from "@/lib/api/wasm-modules";
+
+import type { WasmModuleEntry } from "@bindings/WasmModuleEntry";
+
+function ModuleIcon(props: { icon?: string }) {
+ const src = (): string | undefined => {
+ const icon = props.icon;
+ if (icon === undefined) {
+ return undefined;
+ }
+ if (icon.trimStart().startsWith("}
+ >
+
+
+ );
+}
+
+function ModuleCard(props: { module: WasmModuleEntry }) {
+ const hasManifest = createMemo(
+ () =>
+ props.module.display_name !== props.module.name ||
+ props.module.icon !== null,
+ );
+
+ return (
+
+
+
+
+
+
+
+
{props.module.display_name}
+
+
+ {props.module.name}
+
+
+
+
+
+ {props.module.description}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function WasmModulesPage() {
+ const wasmModules = useQuery(() => ({
+ queryKey: ["wasm-modules"],
+ queryFn: fetchWasmModules,
+ }));
+
+ const modules = createMemo(() => wasmModules.data?.modules ?? []);
+
+ return (
+
+
+
+
+
+
+
+ }
+ >
+
0}
+ fallback={
+
+
+
+ }
+ >
+
+ {(module) => }
+
+
+
+
+
+ );
+}
diff --git a/crates/assets/js/admin/src/lib/api/wasm-modules.ts b/crates/assets/js/admin/src/lib/api/wasm-modules.ts
new file mode 100644
index 000000000..4bdb280dd
--- /dev/null
+++ b/crates/assets/js/admin/src/lib/api/wasm-modules.ts
@@ -0,0 +1,8 @@
+import { adminFetch } from "@/lib/fetch";
+
+import type { ListWasmModulesResponse } from "@bindings/ListWasmModulesResponse";
+
+export async function fetchWasmModules(): Promise {
+ const response = await adminFetch("/wasm-modules");
+ return await response.json();
+}
diff --git a/crates/assets/js/bindings/ListWasmModulesResponse.ts b/crates/assets/js/bindings/ListWasmModulesResponse.ts
new file mode 100644
index 000000000..5c02392db
--- /dev/null
+++ b/crates/assets/js/bindings/ListWasmModulesResponse.ts
@@ -0,0 +1,4 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { WasmModuleEntry } from "./WasmModuleEntry";
+
+export type ListWasmModulesResponse = { modules: Array, };
diff --git a/crates/assets/js/bindings/WasmModuleEntry.ts b/crates/assets/js/bindings/WasmModuleEntry.ts
new file mode 100644
index 000000000..0d509142a
--- /dev/null
+++ b/crates/assets/js/bindings/WasmModuleEntry.ts
@@ -0,0 +1,9 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type WasmModuleEntry = {
+name: string,
+display_name: string,
+icon: string | null,
+config_path: string | null,
+description: string | null,
+};
diff --git a/crates/core/bindings/ListWasmModulesResponse.ts b/crates/core/bindings/ListWasmModulesResponse.ts
new file mode 100644
index 000000000..5c02392db
--- /dev/null
+++ b/crates/core/bindings/ListWasmModulesResponse.ts
@@ -0,0 +1,4 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { WasmModuleEntry } from "./WasmModuleEntry";
+
+export type ListWasmModulesResponse = { modules: Array, };
diff --git a/crates/core/bindings/WasmModuleEntry.ts b/crates/core/bindings/WasmModuleEntry.ts
new file mode 100644
index 000000000..0d509142a
--- /dev/null
+++ b/crates/core/bindings/WasmModuleEntry.ts
@@ -0,0 +1,9 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type WasmModuleEntry = {
+name: string,
+display_name: string,
+icon: string | null,
+config_path: string | null,
+description: string | null,
+};
diff --git a/crates/core/src/admin/mod.rs b/crates/core/src/admin/mod.rs
index 2d587f0c8..957e805cb 100644
--- a/crates/core/src/admin/mod.rs
+++ b/crates/core/src/admin/mod.rs
@@ -1,3 +1,4 @@
+mod wasm_modules;
mod config;
mod email;
mod error;
@@ -73,6 +74,7 @@ pub fn router() -> Router {
)
.route("/public_key", get(jwt::get_public_key))
.route("/info", get(info::info_handler))
+ .route("/wasm-modules", get(wasm_modules::list_wasm_modules_handler))
.route("/jobs", get(jobs::list_jobs_handler))
.route("/job/run", post(jobs::run_job_handler))
.route("/email/test", post(email::test_email_handler))
diff --git a/crates/core/src/admin/wasm_modules/list_wasm_modules.rs b/crates/core/src/admin/wasm_modules/list_wasm_modules.rs
new file mode 100644
index 000000000..69d8419e9
--- /dev/null
+++ b/crates/core/src/admin/wasm_modules/list_wasm_modules.rs
@@ -0,0 +1,113 @@
+use axum::{Json, extract::State};
+use serde::{Deserialize, Serialize};
+use ts_rs::TS;
+
+use crate::admin::AdminError as Error;
+use crate::app_state::AppState;
+
+#[derive(Debug, Deserialize, Serialize, TS)]
+#[ts(export)]
+pub struct WasmModuleEntry {
+ pub name: String,
+ pub display_name: String,
+ pub icon: Option,
+ pub config_path: Option,
+ pub description: Option,
+}
+
+#[derive(Debug, Deserialize, Serialize, TS)]
+#[ts(export)]
+pub struct ListWasmModulesResponse {
+ pub modules: Vec,
+}
+
+fn build_entry(
+ name: String,
+ manifest: Option<&crate::app_state::WasmManifest>,
+) -> WasmModuleEntry {
+ return WasmModuleEntry {
+ display_name: manifest
+ .map(|m| m.display_name.clone())
+ .unwrap_or_else(|| name.clone()),
+ icon: manifest.and_then(|m| m.icon.clone()),
+ config_path: manifest.and_then(|m| m.config_path.clone()),
+ description: manifest.and_then(|m| m.description.clone()),
+ name,
+ };
+}
+
+pub async fn list_wasm_modules_handler(
+ State(state): State,
+) -> Result, Error> {
+ let mut names: Vec = Vec::new();
+ for rt in state.wasm_runtimes() {
+ let path = rt.read().await.component_path().clone();
+ let name = path
+ .file_stem()
+ .and_then(|s| s.to_str())
+ .unwrap_or("unknown")
+ .to_string();
+ names.push(name);
+ }
+
+ let manifests = state.wasm_manifests().read().await;
+ let modules = names
+ .into_iter()
+ .map(|name| build_entry(name.clone(), manifests.get(&name)))
+ .collect();
+
+ return Ok(Json(ListWasmModulesResponse { modules }));
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::app_state::{WasmManifest, test_state};
+
+ #[test]
+ fn build_entry_without_manifest_uses_name_as_display_name() {
+ let entry = build_entry("my_component".to_string(), None);
+ assert_eq!(entry.name, "my_component");
+ assert_eq!(entry.display_name, "my_component");
+ assert!(entry.icon.is_none());
+ assert!(entry.config_path.is_none());
+ }
+
+ #[test]
+ fn build_entry_with_manifest_propagates_fields() {
+ let manifest = WasmManifest {
+ display_name: "My Component".to_string(),
+ icon: Some("".to_string()),
+ config_path: Some("/_/admin/my/config".to_string()),
+ description: Some("A test component".to_string()),
+ };
+ let entry = build_entry("my_component".to_string(), Some(&manifest));
+ assert_eq!(entry.name, "my_component");
+ assert_eq!(entry.display_name, "My Component");
+ assert_eq!(entry.icon.as_deref(), Some(""));
+ assert_eq!(entry.config_path.as_deref(), Some("/_/admin/my/config"));
+ assert_eq!(entry.description.as_deref(), Some("A test component"));
+ }
+
+ #[tokio::test]
+ async fn list_wasm_modules_handler_returns_empty_when_no_runtimes() {
+ let state = test_state(None).await.unwrap();
+
+ state
+ .wasm_manifests()
+ .write()
+ .await
+ .insert(
+ "phantom".to_string(),
+ WasmManifest {
+ display_name: "Phantom".to_string(),
+ icon: Some("".to_string()),
+ config_path: Some("/_/admin/phantom".to_string()),
+ description: None,
+ },
+ );
+
+ let response = list_wasm_modules_handler(State(state)).await.unwrap();
+ assert!(response.modules.is_empty());
+ }
+}
diff --git a/crates/core/src/admin/wasm_modules/mod.rs b/crates/core/src/admin/wasm_modules/mod.rs
new file mode 100644
index 000000000..a54f1afc9
--- /dev/null
+++ b/crates/core/src/admin/wasm_modules/mod.rs
@@ -0,0 +1,3 @@
+mod list_wasm_modules;
+
+pub use list_wasm_modules::list_wasm_modules_handler;
diff --git a/crates/core/src/app_state.rs b/crates/core/src/app_state.rs
index f8ed2bb01..b0effe407 100644
--- a/crates/core/src/app_state.rs
+++ b/crates/core/src/app_state.rs
@@ -22,6 +22,14 @@ use crate::records::subscribe::manager::SubscriptionManager;
use crate::scheduler::{JobRegistry, build_job_registry_from_config};
use crate::wasm::Runtime;
+#[derive(Clone, Debug, serde::Deserialize)]
+pub struct WasmManifest {
+ pub display_name: String,
+ pub icon: Option,
+ pub config_path: Option,
+ pub description: Option,
+}
+
/// The app's internal state. AppState needs to be clonable which puts unnecessary constraints on
/// the internals. Thus rather arc once than many times.
struct InternalState {
@@ -57,6 +65,7 @@ struct InternalState {
wasm_runtimes: Vec>>,
/// WASM runtime builders needed to rebuild above runtimes, e.g. when hot-reloading.
wasm_runtimes_builder: crate::wasm::WasmRuntimeBuilder,
+ wasm_manifests: Arc>>,
#[cfg(test)]
#[allow(unused)]
@@ -203,8 +212,9 @@ impl AppState {
.map(|rt| Arc::new(RwLock::new(rt)))
.collect(),
wasm_runtimes_builder,
- #[cfg(test)]
- pg_uri: None,
+ wasm_manifests: Arc::new(RwLock::new(HashMap::new())),
+ #[cfg(test)]
+ pg_uri: None,
#[cfg(test)]
test_cleanup: vec![],
}),
@@ -401,6 +411,10 @@ impl AppState {
return &self.state.wasm_runtimes;
}
+ pub(crate) fn wasm_manifests(&self) -> &Arc>> {
+ return &self.state.wasm_manifests;
+ }
+
pub(crate) async fn reload_wasm_runtimes(&self) -> Result<(), crate::wasm::AnyError> {
let mut new_runtimes = (self.state.wasm_runtimes_builder)()?;
if new_runtimes.is_empty() {
@@ -867,6 +881,7 @@ mod test_utils {
object_store,
wasm_runtimes: vec![],
wasm_runtimes_builder: Box::new(|| Ok(vec![])),
+ wasm_manifests: Arc::new(RwLock::new(HashMap::new())),
pg_uri,
// NOTE: We gotta make sure `pg_db` is destroyed before the temp dir, otherwise it will
// write new artifacts to the already deleted dir.
diff --git a/crates/core/src/wasm/mod.rs b/crates/core/src/wasm/mod.rs
index d4d897b8e..81f61dada 100644
--- a/crates/core/src/wasm/mod.rs
+++ b/crates/core/src/wasm/mod.rs
@@ -115,6 +115,49 @@ pub(crate) fn wasm_runtimes_builder(
}));
}
+/// Probe a WASM component's manifest endpoint by calling it directly
+/// through the runtime's HTTP store. Returns the parsed manifest or None
+/// if the component doesn't expose one or the response is invalid.
+pub(crate) async fn probe_manifest(
+ runtime: &Runtime,
+ manifest_path: &str,
+) -> Option {
+ let manifest_uri = format!("http://localhost{manifest_path}");
+
+ let manifest_store = HttpStore::new(runtime).await.ok()?;
+ let context_header = to_header_value(&HttpContext {
+ kind: HttpContextKind::Http,
+ registered_path: manifest_path.to_string(),
+ path_params: vec![],
+ user: None,
+ })
+ .ok()?;
+
+ let probe_req = hyper::Request::builder()
+ .method(hyper::Method::GET)
+ .uri(manifest_uri)
+ .header("__context", context_header)
+ .body(empty())
+ .ok()?;
+
+ let resp = manifest_store.call_incoming_http_handler(probe_req).await.ok()?;
+
+ if resp.status() != hyper::StatusCode::OK {
+ return None;
+ }
+
+ let (_, body) = resp.into_parts();
+ let collected = body.collect().await.ok()?;
+
+ match serde_json::from_slice::(&collected.to_bytes()) {
+ Ok(manifest) => Some(manifest),
+ Err(err) => {
+ warn!("Manifest at '{manifest_path}' returned invalid JSON: {err}");
+ None
+ }
+ }
+}
+
pub(crate) async fn install_routes_and_jobs(
state: &AppState,
runtime: Arc>,
@@ -130,6 +173,27 @@ pub(crate) async fn install_routes_and_jobs(
.await?
};
+ let component_name = runtime
+ .read()
+ .await
+ .component_path()
+ .file_stem()
+ .and_then(|s| s.to_str())
+ .unwrap_or("unknown")
+ .to_string();
+
+ // Convention: manifest is always at /_/wasm//manifest.
+ // Components must register their routes under the same prefix as their file name.
+ let manifest_path = format!("/_/wasm/{component_name}/manifest");
+ if let Some(manifest) = probe_manifest(&*runtime.read().await, &manifest_path).await {
+ info!("Registering manifest for WASM component '{component_name}'");
+ state
+ .wasm_manifests()
+ .write()
+ .await
+ .insert(component_name, manifest);
+ }
+
for (name, spec) in init_result.job_handlers {
let schedule = cron::Schedule::from_str(&spec)?;
let store = HttpStore::new(&*runtime.read().await).await?;
@@ -199,7 +263,10 @@ pub(crate) async fn install_routes_and_jobs(
.map(|(name, value)| (name.to_string(), value.to_string()))
.collect(),
user: user.map(|u| HttpContextUser {
- id: u.id,
+ // The host encodes user IDs with BASE64_URL_SAFE (with padding), but the
+ // wasm-runtime-guest's is_admin() decodes with URL_SAFE_NO_PAD. Strip the
+ // padding here so the guest receives the format it expects.
+ id: u.id.trim_end_matches('=').to_string(),
email: u.email,
username: u.username,
csrf_token: u.csrf_token,
diff --git a/crates/wasm-runtime-guest/src/auth.rs b/crates/wasm-runtime-guest/src/auth.rs
new file mode 100644
index 000000000..41f9c9b1e
--- /dev/null
+++ b/crates/wasm-runtime-guest/src/auth.rs
@@ -0,0 +1,61 @@
+use base64::Engine;
+use http::StatusCode;
+
+use crate::db::{self, Value};
+use crate::http::{HttpError, Request};
+
+const CSRF_HEADER: &str = "CSRF-Token";
+
+pub async fn require_admin(req: &Request) -> Result<(), HttpError> {
+ let Some(user) = req.user() else {
+ return Err(HttpError::status(StatusCode::UNAUTHORIZED));
+ };
+
+ if !is_admin(&user.id).await? {
+ return Err(HttpError::status(StatusCode::FORBIDDEN));
+ }
+
+ if *req.method() != http::Method::GET {
+ let received = req.header(CSRF_HEADER).and_then(|v| v.to_str().ok());
+ if received != Some(user.csrf_token.as_str()) {
+ return Err(HttpError::status(StatusCode::FORBIDDEN));
+ }
+ }
+
+ Ok(())
+}
+
+pub async fn is_admin(user_id: &str) -> Result {
+ let id_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
+ .decode(user_id.as_bytes())
+ .map_err(|err| {
+ log::warn!("require_admin: invalid user id encoding: {err}");
+ HttpError::status(StatusCode::INTERNAL_SERVER_ERROR)
+ })?;
+
+ let rows = db::query(
+ r#"SELECT admin FROM "_user" WHERE id = ?"#,
+ vec![Value::Blob(id_bytes)],
+ )
+ .await
+ .map_err(|err| {
+ log::warn!("require_admin: db query failed: {err}");
+ HttpError::status(StatusCode::INTERNAL_SERVER_ERROR)
+ })?;
+
+ match rows.first().and_then(|row| row.first()) {
+ Some(Value::Integer(1)) => Ok(true),
+ Some(Value::Integer(0)) => Ok(false),
+ _ => Err(HttpError::status(StatusCode::FORBIDDEN)),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn csrf_header_constant_matches_host() {
+ assert_eq!(CSRF_HEADER, "CSRF-Token");
+ }
+}
diff --git a/crates/wasm-runtime-guest/src/http.rs b/crates/wasm-runtime-guest/src/http.rs
index 49710a4b7..57623c8c4 100644
--- a/crates/wasm-runtime-guest/src/http.rs
+++ b/crates/wasm-runtime-guest/src/http.rs
@@ -96,6 +96,61 @@ impl HttpRoute {
),
};
}
+
+ pub fn require_admin(self) -> HttpRoute {
+ let Self {
+ method,
+ path,
+ handler: original,
+ } = self;
+
+ let wrapped: HttpHandler = Box::new(
+ move |context: HttpContext,
+ req: http::Request,
+ responder: Responder| {
+ Box::pin(async move {
+ let Some(user) = context.user.as_ref() else {
+ return responder
+ .respond(empty_error_response(StatusCode::UNAUTHORIZED))
+ .await;
+ };
+
+ if req.method() != Method::GET {
+ let received = req
+ .headers()
+ .get("CSRF-Token")
+ .and_then(|v| v.to_str().ok());
+ if received != Some(user.csrf_token.as_str()) {
+ return responder
+ .respond(empty_error_response(StatusCode::FORBIDDEN))
+ .await;
+ }
+ }
+
+ let user_id = user.id.clone();
+ match crate::auth::is_admin(&user_id).await {
+ Ok(true) => original(context, req, responder).await,
+ Ok(false) => {
+ responder
+ .respond(empty_error_response(StatusCode::FORBIDDEN))
+ .await
+ }
+ Err(_err) => {
+ responder
+ .respond(empty_error_response(StatusCode::INTERNAL_SERVER_ERROR))
+ .await
+ }
+ }
+ })
+ },
+ );
+
+ return HttpRoute {
+ method,
+ path,
+ handler: wrapped,
+ };
+ }
}
pub mod routing {
diff --git a/crates/wasm-runtime-guest/src/lib.rs b/crates/wasm-runtime-guest/src/lib.rs
index b82bd853f..a3f484b17 100644
--- a/crates/wasm-runtime-guest/src/lib.rs
+++ b/crates/wasm-runtime-guest/src/lib.rs
@@ -26,6 +26,7 @@ pub mod wit {
});
}
+pub mod auth;
pub mod db;
pub mod fetch;
pub mod fs;