diff --git a/docs/infra/api_grensesnitt.md b/docs/infra/api_grensesnitt.md index 49a8836..8d68872 100644 --- a/docs/infra/api_grensesnitt.md +++ b/docs/infra/api_grensesnitt.md @@ -55,7 +55,20 @@ Tunge spørringer (søk, statistikk, graftraversering) går via maskinrommet → - **SvelteKit er et rent frontend-prosjekt.** Ingen server-side PG-tilgang. - **Bakgrunnsjobber** (Whisper, LLM, TTS) orkestreres av maskinrommet, aldri direkte fra frontend. -## 5. Instruks for Claude Code +## 5. Implementerte endepunkter + +### Offentlige +- `GET /health` — Helsesjekk. Verifiserer PG- og STDB-tilkobling. + +### Autentiserte (krever `Authorization: Bearer `) +- `GET /me` — Returnerer autentisert brukers `node_id` og `authentik_sub`. +- `POST /intentions/create_node` — Opprett node. Skriv til STDB (instant), + spawn async PG-skriving, returner `node_id` umiddelbart. + - Body (JSON): `{ node_kind?, title?, content?, visibility?, metadata? }` + - Defaults: `node_kind="content"`, `visibility="hidden"`, andre felter tomme + - Respons: `{ node_id: "" }` + +## 6. Instruks for Claude Code - Maskinrommet (`maskinrommet/`) er Rust-prosjektet med axum, tokio, sqlx. - Intensjoner fra frontend → `POST /intentions/*` endepunkter i maskinrommet. diff --git a/maskinrommet/Dockerfile b/maskinrommet/Dockerfile index 758aa17..3269a4c 100644 --- a/maskinrommet/Dockerfile +++ b/maskinrommet/Dockerfile @@ -1,7 +1,7 @@ # Maskinrommet — flertrinns Docker-bygg # Bygger Rust-binæren i et kompileringssteg, kopierer til minimal runtime. -FROM rust:1.86 AS builder +FROM rust:1.88 AS builder WORKDIR /app COPY Cargo.toml Cargo.lock* ./ diff --git a/maskinrommet/src/intentions.rs b/maskinrommet/src/intentions.rs new file mode 100644 index 0000000..c6583cd --- /dev/null +++ b/maskinrommet/src/intentions.rs @@ -0,0 +1,183 @@ +// Intensjoner — skrivestien i maskinrommet. +// +// Frontend sender intensjoner (ikke data). Maskinrommet validerer, +// skriver til SpacetimeDB først (instant feedback via WebSocket), +// deretter persisterer til PostgreSQL asynkront. +// +// Ref: docs/retninger/maskinrommet.md, docs/retninger/datalaget.md + +use axum::{extract::State, http::StatusCode, Json}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::AppState; + +// ============================================================================= +// create_node +// ============================================================================= + +/// Gyldige visibility-verdier (speiler PG enum). +const VALID_VISIBILITIES: &[&str] = &["hidden", "discoverable", "readable", "open"]; + +#[derive(Deserialize)] +pub struct CreateNodeRequest { + /// Hint om hva noden er. Default: "content". + pub node_kind: Option, + /// Visningstittel. Kan være null (f.eks. chatmeldinger). + pub title: Option, + /// Ren tekst-innhold. + pub content: Option, + /// Synlighet. Default: "hidden" (privat). + pub visibility: Option, + /// Typespesifikk metadata (JSON-objekt). + pub metadata: Option, +} + +#[derive(Serialize)] +pub struct CreateNodeResponse { + pub node_id: Uuid, +} + +#[derive(Serialize)] +pub struct ErrorResponse { + error: String, +} + +/// POST /intentions/create_node +/// +/// Validerer input, skriver til STDB (instant), spawner async PG-skriving. +/// Returnerer node_id umiddelbart. +pub async fn create_node( + State(state): State, + user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // -- Valider input -- + let node_kind = req.node_kind.unwrap_or_else(|| "content".to_string()); + if node_kind.is_empty() { + return Err(bad_request("node_kind kan ikke være tom")); + } + + let visibility = req.visibility.unwrap_or_else(|| "hidden".to_string()); + if !VALID_VISIBILITIES.contains(&visibility.as_str()) { + return Err(bad_request(&format!( + "Ugyldig visibility: '{visibility}'. Gyldige verdier: {VALID_VISIBILITIES:?}" + ))); + } + + let title = req.title.unwrap_or_default(); + let content = req.content.unwrap_or_default(); + let metadata = req + .metadata + .unwrap_or_else(|| serde_json::json!({})); + let metadata_str = metadata.to_string(); + + // -- Generer UUIDv7 (tidssortert) -- + let node_id = Uuid::now_v7(); + let node_id_str = node_id.to_string(); + let created_by_str = user.node_id.to_string(); + + // -- Skriv til SpacetimeDB (instant) -- + state + .stdb + .create_node( + &node_id_str, + &node_kind, + &title, + &content, + &visibility, + &metadata_str, + &created_by_str, + ) + .await + .map_err(|e| { + tracing::error!("STDB create_node feilet: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Kunne ikke skrive til SpacetimeDB: {e}"), + }), + ) + })?; + + tracing::info!( + node_id = %node_id, + node_kind = %node_kind, + created_by = %user.node_id, + "Node opprettet i STDB" + ); + + // -- Spawn async PG-skriving -- + spawn_pg_insert( + state.db.clone(), + node_id, + node_kind, + title, + content, + visibility, + metadata, + user.node_id, + ); + + // -- Returner node_id umiddelbart -- + Ok(Json(CreateNodeResponse { node_id })) +} + +/// Spawner en tokio-task som skriver noden til PostgreSQL i bakgrunnen. +/// Frontend får oppdatering via STDB WebSocket uavhengig av denne. +fn spawn_pg_insert( + db: PgPool, + node_id: Uuid, + node_kind: String, + title: String, + content: String, + visibility: String, + metadata: serde_json::Value, + created_by: Uuid, +) { + tokio::spawn(async move { + let result = sqlx::query( + r#" + INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by) + VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''), $5::visibility, $6, $7) + "#, + ) + .bind(node_id) + .bind(&node_kind) + .bind(&title) + .bind(&content) + .bind(&visibility) + .bind(&metadata) + .bind(created_by) + .execute(&db) + .await; + + match result { + Ok(_) => { + tracing::info!( + node_id = %node_id, + "Node persistert til PostgreSQL" + ); + } + Err(e) => { + // Logg feilen. I fremtiden: dead letter queue (fase 12.3). + tracing::error!( + node_id = %node_id, + error = %e, + "Kunne ikke persistere node til PostgreSQL" + ); + } + } + }); +} + +fn bad_request(msg: &str) -> (StatusCode, Json) { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: msg.to_string(), + }), + ) +} diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index b199fc5..1a73391 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -1,8 +1,9 @@ mod auth; +mod intentions; mod stdb; mod warmup; -use axum::{extract::State, http::StatusCode, routing::get, Json, Router}; +use axum::{extract::State, http::StatusCode, routing::{get, post}, Json, Router}; use serde::Serialize; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; @@ -111,6 +112,7 @@ async fn main() { let app = Router::new() .route("/health", get(health)) .route("/me", get(me)) + .route("/intentions/create_node", post(intentions::create_node)) .layer(TraceLayer::new_for_http()) .with_state(state); diff --git a/tasks.md b/tasks.md index 45f313b..f5c565b 100644 --- a/tasks.md +++ b/tasks.md @@ -54,8 +54,7 @@ Uavhengige faser kan fortsatt plukkes. - [x] 2.1 Rust-prosjekt: opprett `maskinrommet/` med axum, tokio, sqlx (PG), serde. Dockerfile. Kompilerer og starter. Ref: `docs/retninger/maskinrommet.md`. - [x] 2.2 Auth-middleware: valider Authentik JWT-tokens, slå opp `auth_identities` → node_id. Returner 401 for ugyldige tokens. - [x] 2.3 SpacetimeDB-klient i maskinrommet: koble til STDB, skriv noder og edges via reducers. -- [~] 2.4 Skrivestien: `POST /intentions/create_node` — valider, skriv STDB (instant), spawn async PG-skriving. Returner node_id umiddelbart. - > Påbegynt: 2026-03-17T12:52 +- [x] 2.4 Skrivestien: `POST /intentions/create_node` — valider, skriv STDB (instant), spawn async PG-skriving. Returner node_id umiddelbart. - [ ] 2.5 Flere intensjoner: `create_edge`, `update_node`, `delete_node`. Validering av tilgang (created_by eller owner/admin-edge). - [ ] 2.6 Docker Compose: legg maskinrommet inn i server-stacken. Intern nettverkstilgang til PG og STDB.