feat(manager-core,orchestrator-core): multi-app scoping (Phase 3b)
Apps become the isolation boundary for scripts, routes, domains, and
later data. Doing this now — while the surface is small — avoids
several migrations on populated tables once v1.1 data-plane services
ship.
Schema (migration 0005_apps.sql):
- New tables: apps, app_domains (with shape_key UNIQUE for collision
detection), app_slug_history (for permanent slug-rename redirects).
- app_id added to scripts, routes, execution_logs (non-null, cascading
rules per row).
- Script-name uniqueness becomes per-app; the route unique index is
swapped for an app-scoped version.
- The "default" app is seeded unconditionally with a localhost claim;
existing scripts/routes backfill into it. Fresh installs additionally
get the Hello World seed via seed_hello_world_if_fresh after
migrations run (idempotent — only fires when the default app has no
scripts).
Orchestrator dispatch is two-phase: AppDomainTable resolves Host →
app_id (most-specific match wins, exact beats wildcard), then the
existing route matcher runs against that app's partitioned slice via
RouteTable. Unknown hosts return 404 at the app layer with a clear
message; /api/v1/execute/{id} still works as the implicit
__internal__ claim, decoupled from any public domain.
Manager API: full CRUD for /api/v1/admin/apps/* and
/api/v1/admin/apps/{id_or_slug}/domains/*, with slug:check + force
takeover semantics implementing the rename-history flow (two-step
check → confirm, never a single endpoint). Script create requires
app_id; list accepts ?app= filter. Route create validates host
against the parent app's claims; conflict detection stays strictly
intra-app.
Dashboard: /admin/apps and /admin/apps/{slug} (overview + scripts +
domains + settings tabs, with slug-history-aware redirects). Root
path redirects to the apps list. Script detail page gains an app
breadcrumb and threads app_id into the route preview.
Deferred per design: per-app admin roles. The require_admin middleware
remains the seam where role checks will slot in later.
Blueprint §11.5 and roadmap updated to reflect what shipped; docs/
versioning.md notes the schema 3 → 5 bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
|
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
|
||||||
|
|
||||||
**Current focus (Phase 3, pre-v1.1):** admin auth gate, then multi-app scoping. The latter introduces `apps` as the top-level isolation boundary for scripts, routes, domains, and (later) data. See blueprint §11.5 for the design. Every v1.1+ feature must assume `app_id` exists as a scoping dimension.
|
**Current focus (Phase 4, v1.1):** data-plane SDKs — KV store, then document store, then HTTP client, then cron triggers. See blueprint §12. Phase 3 (admin auth + multi-app scoping) shipped; every v1.1+ table starts with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE` and every Rhai SDK call resolves its app from the execution context.
|
||||||
|
|
||||||
## Three-Service Architecture
|
## Three-Service Architecture
|
||||||
|
|
||||||
|
|||||||
117
crates/manager-core/migrations/0005_apps.sql
Normal file
117
crates/manager-core/migrations/0005_apps.sql
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
-- Phase 3b multi-app scoping — see blueprint §11.5.
|
||||||
|
--
|
||||||
|
-- Apps are the top-level isolation boundary for scripts, routes, domain
|
||||||
|
-- claims and (forward) data. The orchestrator dispatches Host → app_id →
|
||||||
|
-- route trie; cross-app resource access is not possible.
|
||||||
|
--
|
||||||
|
-- This migration is unconditional:
|
||||||
|
-- 1. Creates the three new tables (apps, app_domains, app_slug_history).
|
||||||
|
-- 2. Always inserts a "default" app claiming `localhost` so existing
|
||||||
|
-- installs get a usable home for their pre-existing scripts/routes.
|
||||||
|
-- 3. Backfills app_id on scripts, routes, execution_logs from the
|
||||||
|
-- default app row, then promotes the columns to NOT NULL + FK.
|
||||||
|
--
|
||||||
|
-- Fresh installs get the same "default" app row; an in-Rust bootstrap
|
||||||
|
-- step (manager-core::app_bootstrap) decides whether to seed a Hello
|
||||||
|
-- World script into it. Doing the seed in Rust keeps it testable and
|
||||||
|
-- lets the script source live in a real .rhai file.
|
||||||
|
|
||||||
|
CREATE TABLE apps (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
-- URL-safe identifier; mutable via the rename flow which records
|
||||||
|
-- the prior slug in app_slug_history for permanent 301 redirects.
|
||||||
|
-- Format validation (`^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
|
||||||
|
-- check) lives in Rust handlers, not SQL.
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Domain claims. Most-specific wins at request time; same-shape
|
||||||
|
-- collisions are rejected at claim time via the UNIQUE(shape_key).
|
||||||
|
-- shape_key encoding:
|
||||||
|
-- exact:<lowercased-host> for shape='exact'
|
||||||
|
-- wildcard:<lowercased-suffix> for shape='wildcard' AND 'parameterized'
|
||||||
|
-- (parameterized is the same shape as wildcard for collision — the
|
||||||
|
-- parameter name is a binding, not a discriminator. See blueprint §11.5.)
|
||||||
|
CREATE TABLE app_domains (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
pattern TEXT NOT NULL,
|
||||||
|
shape TEXT NOT NULL CHECK (shape IN ('exact', 'wildcard', 'parameterized')),
|
||||||
|
shape_key TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX app_domains_app_id_idx ON app_domains (app_id);
|
||||||
|
|
||||||
|
-- Permanent 301 redirects after a slug rename. A row dies only when
|
||||||
|
-- another app explicitly claims the retired slug (with confirmation in
|
||||||
|
-- the UI). On_delete cascade: if the owning app is deleted, its history
|
||||||
|
-- row goes too (otherwise the redirect would point at a dead app).
|
||||||
|
CREATE TABLE app_slug_history (
|
||||||
|
slug TEXT PRIMARY KEY,
|
||||||
|
current_app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
retired_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed the default app + a localhost claim. Used by both upgrade and
|
||||||
|
-- fresh-install paths; the Rust bootstrap layers Hello World on top
|
||||||
|
-- only when the install was fresh.
|
||||||
|
WITH default_app AS (
|
||||||
|
INSERT INTO apps (slug, name, description)
|
||||||
|
VALUES ('default', 'Default', 'The default application — assigned to all pre-existing scripts and routes during the multi-app migration.')
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
INSERT INTO app_domains (app_id, pattern, shape, shape_key)
|
||||||
|
SELECT id, 'localhost', 'exact', 'exact:localhost' FROM default_app;
|
||||||
|
|
||||||
|
-- Add app_id to scripts. The default app already exists (above), so
|
||||||
|
-- there is exactly one row to look up.
|
||||||
|
ALTER TABLE scripts ADD COLUMN app_id UUID;
|
||||||
|
UPDATE scripts SET app_id = (SELECT id FROM apps WHERE slug = 'default');
|
||||||
|
ALTER TABLE scripts ALTER COLUMN app_id SET NOT NULL;
|
||||||
|
ALTER TABLE scripts
|
||||||
|
ADD CONSTRAINT scripts_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
-- Per-app name uniqueness. Two apps can each have a script called
|
||||||
|
-- "echo"; previously they could not.
|
||||||
|
DROP INDEX scripts_name_uidx;
|
||||||
|
CREATE UNIQUE INDEX scripts_name_uidx ON scripts (app_id, LOWER(name));
|
||||||
|
|
||||||
|
CREATE INDEX scripts_app_id_idx ON scripts (app_id);
|
||||||
|
|
||||||
|
-- Add app_id to routes, mirroring the script's app.
|
||||||
|
ALTER TABLE routes ADD COLUMN app_id UUID;
|
||||||
|
UPDATE routes
|
||||||
|
SET app_id = scripts.app_id
|
||||||
|
FROM scripts
|
||||||
|
WHERE routes.script_id = scripts.id;
|
||||||
|
ALTER TABLE routes ALTER COLUMN app_id SET NOT NULL;
|
||||||
|
ALTER TABLE routes
|
||||||
|
ADD CONSTRAINT routes_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Replace the route uniqueness index so two apps can claim identical
|
||||||
|
-- (host_kind, host, path_kind, path, method) tuples — they live in
|
||||||
|
-- separate route trees and never see each other.
|
||||||
|
DROP INDEX routes_unique_binding_idx;
|
||||||
|
CREATE UNIQUE INDEX routes_unique_binding_idx
|
||||||
|
ON routes (app_id, host_kind, host, path_kind, path, COALESCE(method, ''));
|
||||||
|
|
||||||
|
CREATE INDEX routes_app_id_idx ON routes (app_id);
|
||||||
|
|
||||||
|
-- Add app_id to execution_logs. Materialized at write time so future
|
||||||
|
-- script-moves (or eventual export/import) don't silently retag history.
|
||||||
|
ALTER TABLE execution_logs ADD COLUMN app_id UUID;
|
||||||
|
UPDATE execution_logs
|
||||||
|
SET app_id = scripts.app_id
|
||||||
|
FROM scripts
|
||||||
|
WHERE execution_logs.script_id = scripts.id;
|
||||||
|
ALTER TABLE execution_logs ALTER COLUMN app_id SET NOT NULL;
|
||||||
|
ALTER TABLE execution_logs
|
||||||
|
ADD CONSTRAINT execution_logs_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX execution_logs_app_id_created_at_idx
|
||||||
|
ON execution_logs (app_id, created_at DESC);
|
||||||
15
crates/manager-core/seeds/hello.rhai
Normal file
15
crates/manager-core/seeds/hello.rhai
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Hello World — the reference example seeded into the default app on
|
||||||
|
// fresh installs. Bound to GET /hello.
|
||||||
|
|
||||||
|
let who = ctx.request.body;
|
||||||
|
let name = if who != () && type_of(who) == "map" && who.contains("name") {
|
||||||
|
who.name
|
||||||
|
} else {
|
||||||
|
"world"
|
||||||
|
};
|
||||||
|
|
||||||
|
return #{
|
||||||
|
statusCode: 200,
|
||||||
|
headers: #{ "Content-Type": "application/json" },
|
||||||
|
body: #{ message: `Hello, ${name}!` }
|
||||||
|
};
|
||||||
@@ -5,17 +5,18 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
routing::get,
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
|
AppId, ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
use crate::repo::{
|
use crate::repo::{
|
||||||
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
||||||
};
|
};
|
||||||
@@ -27,6 +28,9 @@ use crate::sandbox::{CeilingError, SandboxCeiling};
|
|||||||
pub struct AdminState<R, L> {
|
pub struct AdminState<R, L> {
|
||||||
pub repo: Arc<R>,
|
pub repo: Arc<R>,
|
||||||
pub logs: Arc<L>,
|
pub logs: Arc<L>,
|
||||||
|
/// App lookups: validates `app_id` on create, resolves `?app=<slug>`
|
||||||
|
/// filter on list. Trait-object so apps_repo can stay separate.
|
||||||
|
pub apps: Arc<dyn AppRepository>,
|
||||||
pub validator: Arc<dyn ScriptValidator>,
|
pub validator: Arc<dyn ScriptValidator>,
|
||||||
pub sandbox_ceiling: SandboxCeiling,
|
pub sandbox_ceiling: SandboxCeiling,
|
||||||
}
|
}
|
||||||
@@ -36,6 +40,7 @@ impl<R, L> Clone for AdminState<R, L> {
|
|||||||
Self {
|
Self {
|
||||||
repo: self.repo.clone(),
|
repo: self.repo.clone(),
|
||||||
logs: self.logs.clone(),
|
logs: self.logs.clone(),
|
||||||
|
apps: self.apps.clone(),
|
||||||
validator: self.validator.clone(),
|
validator: self.validator.clone(),
|
||||||
sandbox_ceiling: self.sandbox_ceiling,
|
sandbox_ceiling: self.sandbox_ceiling,
|
||||||
}
|
}
|
||||||
@@ -70,6 +75,9 @@ where
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateScriptRequest {
|
pub struct CreateScriptRequest {
|
||||||
|
/// Owning app. Required since Phase 3b — scripts cannot exist
|
||||||
|
/// outside an app. Use `/api/v1/admin/apps` to list known ids.
|
||||||
|
pub app_id: AppId,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub source: String,
|
pub source: String,
|
||||||
@@ -82,6 +90,14 @@ pub struct CreateScriptRequest {
|
|||||||
pub sandbox: ScriptSandbox,
|
pub sandbox: ScriptSandbox,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListScriptsQuery {
|
||||||
|
/// Optional filter: list scripts belonging to a single app, by id
|
||||||
|
/// or slug. Absent = all scripts across all apps (admin-global view).
|
||||||
|
#[serde(default)]
|
||||||
|
pub app: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateScriptRequest {
|
pub struct UpdateScriptRequest {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
@@ -113,8 +129,32 @@ where
|
|||||||
|
|
||||||
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Query(q): Query<ListScriptsQuery>,
|
||||||
) -> Result<Json<Vec<Script>>, ApiError> {
|
) -> Result<Json<Vec<Script>>, ApiError> {
|
||||||
|
if let Some(ident) = q.app {
|
||||||
|
let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
|
||||||
|
Ok(Json(state.repo.list_for_app(app).await?))
|
||||||
|
} else {
|
||||||
Ok(Json(state.repo.list().await?))
|
Ok(Json(state.repo.list().await?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept `?app=<uuid>` OR `?app=<slug>`. Slugs route through history
|
||||||
|
/// for redirects, but here we just need the live current id; if a
|
||||||
|
/// retired slug is given, we follow it to the current app silently.
|
||||||
|
async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApiError> {
|
||||||
|
if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||||
|
let id = AppId::from(uuid);
|
||||||
|
apps.get_by_id(id)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
let lookup = apps
|
||||||
|
.get_by_slug_or_history(ident)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
|
||||||
|
Ok(lookup.app.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
@@ -135,9 +175,15 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
) -> Result<(StatusCode, Json<Script>), ApiError> {
|
) -> Result<(StatusCode, Json<Script>), ApiError> {
|
||||||
state.validator.validate(&input.source)?;
|
state.validator.validate(&input.source)?;
|
||||||
state.sandbox_ceiling.check(&input.sandbox)?;
|
state.sandbox_ceiling.check(&input.sandbox)?;
|
||||||
|
// Refuse early if the app_id doesn't exist — a clean 422 beats a
|
||||||
|
// raw FK violation surfacing as 500.
|
||||||
|
if state.apps.get_by_id(input.app_id).await?.is_none() {
|
||||||
|
return Err(ApiError::AppNotFound(input.app_id.to_string()));
|
||||||
|
}
|
||||||
let created = state
|
let created = state
|
||||||
.repo
|
.repo
|
||||||
.create(NewScript {
|
.create(NewScript {
|
||||||
|
app_id: input.app_id,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
source: input.source,
|
source: input.source,
|
||||||
@@ -223,6 +269,9 @@ pub enum ApiError {
|
|||||||
#[error("script not found: {0}")]
|
#[error("script not found: {0}")]
|
||||||
NotFound(ScriptId),
|
NotFound(ScriptId),
|
||||||
|
|
||||||
|
#[error("app not found: {0}")]
|
||||||
|
AppNotFound(String),
|
||||||
|
|
||||||
#[error("conflict: {0}")]
|
#[error("conflict: {0}")]
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
|
|
||||||
@@ -240,6 +289,7 @@ impl IntoResponse for ApiError {
|
|||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, message) = match &self {
|
let (status, message) = match &self {
|
||||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||||
|
Self::AppNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
|
||||||
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
||||||
Self::Invalid(_) | Self::Ceiling(_) => {
|
Self::Invalid(_) | Self::Ceiling(_) => {
|
||||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||||
|
|||||||
92
crates/manager-core/src/app_bootstrap.rs
Normal file
92
crates/manager-core/src/app_bootstrap.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
//! Hello-World seed for fresh installs.
|
||||||
|
//!
|
||||||
|
//! Idempotent. Runs after migrations and after admin bootstrap. Only
|
||||||
|
//! seeds when the default app is empty (no scripts, no routes); on
|
||||||
|
//! upgrades it does nothing so existing content isn't polluted.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use picloud_shared::{App, AppId, HostKind, PathKind};
|
||||||
|
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError};
|
||||||
|
use crate::route_repo::{NewRoute, RouteRepository};
|
||||||
|
|
||||||
|
const HELLO_RHAI_SOURCE: &str = include_str!("../seeds/hello.rhai");
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum HelloWorldOutcome {
|
||||||
|
/// Default app already has scripts (or doesn't exist) — left alone.
|
||||||
|
SkippedExisting,
|
||||||
|
/// Inserted the hello.rhai script and the `/hello` route.
|
||||||
|
Seeded,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SeedError {
|
||||||
|
#[error("default app not found — did the migration run?")]
|
||||||
|
MissingDefaultApp,
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Repo(#[from] ScriptRepositoryError),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn seed_hello_world_if_fresh(
|
||||||
|
apps: Arc<dyn AppRepository>,
|
||||||
|
scripts: Arc<dyn ScriptRepository>,
|
||||||
|
routes: Arc<dyn RouteRepository>,
|
||||||
|
) -> Result<HelloWorldOutcome, SeedError> {
|
||||||
|
let default = apps
|
||||||
|
.get_by_slug("default")
|
||||||
|
.await?
|
||||||
|
.ok_or(SeedError::MissingDefaultApp)?;
|
||||||
|
|
||||||
|
// Idempotence: only seed when both scripts AND routes are empty.
|
||||||
|
// (Either alone is suspicious enough to skip — the operator may have
|
||||||
|
// already started shaping the default app.)
|
||||||
|
let existing_scripts = scripts.list_for_app(default.id).await?;
|
||||||
|
let existing_routes = routes.list_for_app(default.id).await?;
|
||||||
|
if !existing_scripts.is_empty() || !existing_routes.is_empty() {
|
||||||
|
return Ok(HelloWorldOutcome::SkippedExisting);
|
||||||
|
}
|
||||||
|
|
||||||
|
seed_into(&*scripts, &*routes, &default).await?;
|
||||||
|
Ok(HelloWorldOutcome::Seeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_into(
|
||||||
|
scripts: &dyn ScriptRepository,
|
||||||
|
routes: &dyn RouteRepository,
|
||||||
|
default: &App,
|
||||||
|
) -> Result<(), ScriptRepositoryError> {
|
||||||
|
let script = scripts
|
||||||
|
.create(NewScript {
|
||||||
|
app_id: default.id,
|
||||||
|
name: "hello".to_string(),
|
||||||
|
description: Some("Reference example: returns a greeting at GET /hello.".to_string()),
|
||||||
|
source: HELLO_RHAI_SOURCE.to_string(),
|
||||||
|
timeout_seconds: Some(5),
|
||||||
|
memory_limit_mb: None,
|
||||||
|
sandbox: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
routes
|
||||||
|
.create(NewRoute {
|
||||||
|
app_id: default.id,
|
||||||
|
script_id: script.id,
|
||||||
|
host_kind: HostKind::Any,
|
||||||
|
host: String::new(),
|
||||||
|
host_param_name: None,
|
||||||
|
path_kind: PathKind::Exact,
|
||||||
|
path: "/hello".to_string(),
|
||||||
|
// Accept any method so both `curl /hello` and
|
||||||
|
// `curl -d '{"name":"X"}' /hello` work out of the box.
|
||||||
|
method: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn _typecheck(_id: AppId) {} // suppress unused-import warnings if reshuffled
|
||||||
152
crates/manager-core/src/app_domain_repo.rs
Normal file
152
crates/manager-core/src/app_domain_repo.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
//! CRUD over the `app_domains` table.
|
||||||
|
//!
|
||||||
|
//! Parsing + shape_key derivation live in `orchestrator-core`'s
|
||||||
|
//! `routing::pattern::parse_app_domain` — this repo just stores what
|
||||||
|
//! the API handler hands it. Same-shape collisions surface as a unique
|
||||||
|
//! constraint violation on `shape_key`, mapped here to a clean error.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_shared::{AppDomain, AppId, DomainShape};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::repo::ScriptRepositoryError;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NewAppDomain {
|
||||||
|
pub app_id: AppId,
|
||||||
|
pub pattern: String,
|
||||||
|
pub shape: DomainShape,
|
||||||
|
pub shape_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AppDomainRepository: Send + Sync {
|
||||||
|
/// All domain claims across all apps — used by the orchestrator's
|
||||||
|
/// `AppDomainTable` to build its lookup cache at startup and after
|
||||||
|
/// every write.
|
||||||
|
async fn list_all(&self) -> Result<Vec<AppDomain>, ScriptRepositoryError>;
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<AppDomain>, ScriptRepositoryError>;
|
||||||
|
async fn get(&self, domain_id: Uuid) -> Result<Option<AppDomain>, ScriptRepositoryError>;
|
||||||
|
async fn create(&self, input: NewAppDomain) -> Result<AppDomain, ScriptRepositoryError>;
|
||||||
|
async fn delete(&self, domain_id: Uuid) -> Result<(), ScriptRepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresAppDomainRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresAppDomainRepository {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AppDomainRepository for PostgresAppDomainRepository {
|
||||||
|
async fn list_all(&self) -> Result<Vec<AppDomain>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, DomainRow>(
|
||||||
|
"SELECT id, app_id, pattern, shape, shape_key, created_at \
|
||||||
|
FROM app_domains ORDER BY pattern",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<AppDomain>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, DomainRow>(
|
||||||
|
"SELECT id, app_id, pattern, shape, shape_key, created_at \
|
||||||
|
FROM app_domains WHERE app_id = $1 ORDER BY pattern",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, domain_id: Uuid) -> Result<Option<AppDomain>, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, DomainRow>(
|
||||||
|
"SELECT id, app_id, pattern, shape, shape_key, created_at \
|
||||||
|
FROM app_domains WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(domain_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(&self, input: NewAppDomain) -> Result<AppDomain, ScriptRepositoryError> {
|
||||||
|
let res = sqlx::query_as::<_, DomainRow>(
|
||||||
|
"INSERT INTO app_domains (app_id, pattern, shape, shape_key) \
|
||||||
|
VALUES ($1, $2, $3, $4) \
|
||||||
|
RETURNING id, app_id, pattern, shape, shape_key, created_at",
|
||||||
|
)
|
||||||
|
.bind(input.app_id.into_inner())
|
||||||
|
.bind(&input.pattern)
|
||||||
|
.bind(shape_str(input.shape))
|
||||||
|
.bind(&input.shape_key)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(row) => Ok(row.into()),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"domain {:?} (or another claim of the same shape) is already claimed",
|
||||||
|
input.pattern
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, domain_id: Uuid) -> Result<(), ScriptRepositoryError> {
|
||||||
|
let res = sqlx::query("DELETE FROM app_domains WHERE id = $1")
|
||||||
|
.bind(domain_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"domain {domain_id} not found"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn shape_str(s: DomainShape) -> &'static str {
|
||||||
|
match s {
|
||||||
|
DomainShape::Exact => "exact",
|
||||||
|
DomainShape::Wildcard => "wildcard",
|
||||||
|
DomainShape::Parameterized => "parameterized",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct DomainRow {
|
||||||
|
id: Uuid,
|
||||||
|
app_id: Uuid,
|
||||||
|
pattern: String,
|
||||||
|
shape: String,
|
||||||
|
shape_key: String,
|
||||||
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DomainRow> for AppDomain {
|
||||||
|
fn from(r: DomainRow) -> Self {
|
||||||
|
Self {
|
||||||
|
id: r.id,
|
||||||
|
app_id: r.app_id.into(),
|
||||||
|
pattern: r.pattern,
|
||||||
|
shape: match r.shape.as_str() {
|
||||||
|
"wildcard" => DomainShape::Wildcard,
|
||||||
|
"parameterized" => DomainShape::Parameterized,
|
||||||
|
_ => DomainShape::Exact,
|
||||||
|
},
|
||||||
|
shape_key: r.shape_key,
|
||||||
|
created_at: r.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
380
crates/manager-core/src/app_repo.rs
Normal file
380
crates/manager-core/src/app_repo.rs
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
//! CRUD over the `apps` and `app_slug_history` tables.
|
||||||
|
//!
|
||||||
|
//! Slug validation (regex, reserved-word check) lives in the API
|
||||||
|
//! handler; this repo enforces only what Postgres enforces (uniqueness,
|
||||||
|
//! FK). The slug-rename flow is exposed as a single `rename_slug` call
|
||||||
|
//! that writes the history row in the same transaction.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_shared::{App, AppId};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::repo::ScriptRepositoryError;
|
||||||
|
|
||||||
|
/// Result of looking up an app by slug or via the redirect history.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppLookup {
|
||||||
|
pub app: App,
|
||||||
|
/// `true` when the slug was found in `app_slug_history` rather than
|
||||||
|
/// directly on `apps`. Dashboards should issue a redirect.
|
||||||
|
pub redirected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AppRepository: Send + Sync {
|
||||||
|
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||||
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
|
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
|
async fn get_by_slug_or_history(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<Option<AppLookup>, ScriptRepositoryError>;
|
||||||
|
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
|
/// Create that also consumes a matching `app_slug_history` row, if
|
||||||
|
/// any. Used after the operator has confirmed they want to break old
|
||||||
|
/// redirects.
|
||||||
|
async fn create_with_takeover(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
id: AppId,
|
||||||
|
name: Option<&str>,
|
||||||
|
description: Option<Option<&str>>,
|
||||||
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
|
/// Rename and record the old slug in `app_slug_history` (so
|
||||||
|
/// retired URLs keep redirecting). If `take_over_history` is true,
|
||||||
|
/// any existing history row for `new_slug` is consumed.
|
||||||
|
async fn rename_slug(
|
||||||
|
&self,
|
||||||
|
id: AppId,
|
||||||
|
new_slug: &str,
|
||||||
|
take_over_history: bool,
|
||||||
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
|
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
|
||||||
|
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresAppRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresAppRepository {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AppRepository for PostgresAppRepository {
|
||||||
|
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT id, slug, name, description, created_at, updated_at \
|
||||||
|
FROM apps ORDER BY name",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT id, slug, name, description, created_at, updated_at \
|
||||||
|
FROM apps WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT id, slug, name, description, created_at, updated_at \
|
||||||
|
FROM apps WHERE slug = $1",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_slug_or_history(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<Option<AppLookup>, ScriptRepositoryError> {
|
||||||
|
if let Some(app) = self.get_by_slug(slug).await? {
|
||||||
|
return Ok(Some(AppLookup {
|
||||||
|
app,
|
||||||
|
redirected: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if let Some(app) = self.slug_in_history(slug).await? {
|
||||||
|
return Ok(Some(AppLookup {
|
||||||
|
app,
|
||||||
|
redirected: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||||
|
FROM app_slug_history h \
|
||||||
|
JOIN apps a ON a.id = h.current_app_id \
|
||||||
|
WHERE h.slug = $1",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
let res = sqlx::query_as::<_, AppRow>(
|
||||||
|
"INSERT INTO apps (slug, name, description) \
|
||||||
|
VALUES ($1, $2, $3) \
|
||||||
|
RETURNING id, slug, name, description, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.bind(name)
|
||||||
|
.bind(description)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(row) => Ok(row.into()),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||||
|
ScriptRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
|
||||||
|
),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_with_takeover(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||||
|
.bind(slug)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"INSERT INTO apps (slug, name, description) \
|
||||||
|
VALUES ($1, $2, $3) \
|
||||||
|
RETURNING id, slug, name, description, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.bind(name)
|
||||||
|
.bind(description)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await;
|
||||||
|
let row = match row {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"slug {slug:?} is already in use"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(row.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
id: AppId,
|
||||||
|
name: Option<&str>,
|
||||||
|
description: Option<Option<&str>>,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"UPDATE apps SET \
|
||||||
|
name = COALESCE($2, name), \
|
||||||
|
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||||
|
updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, slug, name, description, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(name)
|
||||||
|
.bind(description.is_some())
|
||||||
|
.bind(description.and_then(|d| d))
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(Into::into)
|
||||||
|
.ok_or_else(|| ScriptRepositoryError::Conflict(format!("app {id} not found")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rename_slug(
|
||||||
|
&self,
|
||||||
|
id: AppId,
|
||||||
|
new_slug: &str,
|
||||||
|
take_over_history: bool,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
|
||||||
|
// 1. Read the current slug (so we can record it in history).
|
||||||
|
let current: Option<(String,)> = sqlx::query_as("SELECT slug FROM apps WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let Some((current_slug,)) = current else {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"app {id} not found"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
if current_slug == new_slug {
|
||||||
|
// No-op rename; just return the row.
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT id, slug, name, description, created_at, updated_at \
|
||||||
|
FROM apps WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
return Ok(row.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If renaming back to this app's own retired slug, just
|
||||||
|
// consume the history row silently (no warning, no takeover
|
||||||
|
// flag required).
|
||||||
|
let owns_history: Option<(uuid::Uuid,)> =
|
||||||
|
sqlx::query_as("SELECT current_app_id FROM app_slug_history WHERE slug = $1")
|
||||||
|
.bind(new_slug)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match owns_history {
|
||||||
|
Some((owner,)) if owner == id.into_inner() => {
|
||||||
|
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||||
|
.bind(new_slug)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Some(_) if take_over_history => {
|
||||||
|
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||||
|
.bind(new_slug)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"slug {new_slug:?} is in history; rename with takeover to claim it"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Record the current slug in history (replacing any older
|
||||||
|
// entry — the same slug can pass through history multiple
|
||||||
|
// times across many renames).
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO app_slug_history (slug, current_app_id) \
|
||||||
|
VALUES ($1, $2) \
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET current_app_id = EXCLUDED.current_app_id, \
|
||||||
|
retired_at = NOW()",
|
||||||
|
)
|
||||||
|
.bind(¤t_slug)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 4. Apply the rename. Unique violation = another live app
|
||||||
|
// already holds this slug.
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"UPDATE apps SET slug = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, slug, name, description, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(new_slug)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await;
|
||||||
|
let row = match row {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"slug {new_slug:?} is already in use by another app"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(row.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
|
||||||
|
let res = sqlx::query("DELETE FROM apps WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await;
|
||||||
|
match res {
|
||||||
|
Ok(r) if r.rows_affected() == 0 => Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"app {id} not found"
|
||||||
|
))),
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||||
|
// ON DELETE RESTRICT on scripts.app_id — surface a clean
|
||||||
|
// "has dependents" error rather than a raw SQL message.
|
||||||
|
Err(ScriptRepositoryError::Conflict(
|
||||||
|
"app still contains scripts; delete or move them first".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError> {
|
||||||
|
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scripts WHERE app_id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AppRow {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
slug: String,
|
||||||
|
name: String,
|
||||||
|
description: Option<String>,
|
||||||
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppRow> for App {
|
||||||
|
fn from(r: AppRow) -> Self {
|
||||||
|
Self {
|
||||||
|
id: r.id.into(),
|
||||||
|
slug: r.slug,
|
||||||
|
name: r.name,
|
||||||
|
description: r.description,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
510
crates/manager-core/src/apps_api.rs
Normal file
510
crates/manager-core/src/apps_api.rs
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
//! `/api/v1/admin/apps/*` — app + domain claim CRUD.
|
||||||
|
//!
|
||||||
|
//! All endpoints are guarded by `require_admin`. Per-app permissions
|
||||||
|
//! are deferred (every authenticated admin can act on every app); the
|
||||||
|
//! middleware seam exists for when that lands.
|
||||||
|
//!
|
||||||
|
//! Slug validation: regex `^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
|
||||||
|
//! list rejected. Slug renames record the old slug in
|
||||||
|
//! `app_slug_history` for permanent 301 redirects; reclaiming a
|
||||||
|
//! historical slug requires `"force_takeover": true` in the request.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::{delete, get, post};
|
||||||
|
use axum::Router;
|
||||||
|
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
||||||
|
use picloud_shared::{App, AppDomain, AppId};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::repo::ScriptRepositoryError;
|
||||||
|
use crate::route_repo::RouteRepository;
|
||||||
|
|
||||||
|
const SLUG_MIN: usize = 1;
|
||||||
|
const SLUG_MAX: usize = 63;
|
||||||
|
const RESERVED_SLUGS: &[&str] = &[
|
||||||
|
"new", "api", "admin", "admins", "healthz", "version", "login", "logout", "apps",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppsState {
|
||||||
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
pub domains: Arc<dyn AppDomainRepository>,
|
||||||
|
pub routes: Arc<dyn RouteRepository>,
|
||||||
|
/// Cached host → app_id lookup; replaced after every domain CRUD
|
||||||
|
/// operation so the orchestrator sees changes immediately.
|
||||||
|
pub domain_table: Arc<AppDomainTable>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apps_router(state: AppsState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/apps", get(list_apps).post(create_app))
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}",
|
||||||
|
get(get_app).patch(patch_app).delete(delete_app),
|
||||||
|
)
|
||||||
|
.route("/apps/{id_or_slug}/slug:check", post(slug_check))
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}/domains",
|
||||||
|
get(list_domains).post(create_domain),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}/domains/{domain_id}",
|
||||||
|
delete(delete_domain),
|
||||||
|
)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// DTOs
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AppDto {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub app: App,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateAppRequest {
|
||||||
|
pub slug: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Set to `true` to consume an existing `app_slug_history` row for
|
||||||
|
/// the requested slug (breaking old redirects).
|
||||||
|
#[serde(default)]
|
||||||
|
pub force_takeover: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PatchAppRequest {
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_optional")]
|
||||||
|
#[allow(clippy::option_option)]
|
||||||
|
pub description: Option<Option<String>>,
|
||||||
|
pub slug: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub force_takeover: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::option_option)]
|
||||||
|
fn deserialize_optional_optional<'de, D>(d: D) -> Result<Option<Option<String>>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Option::<String>::deserialize(d).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SlugCheckRequest {
|
||||||
|
pub new_slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SlugCheckResponse {
|
||||||
|
pub ok: bool,
|
||||||
|
pub conflict_kind: Option<&'static str>,
|
||||||
|
pub current_app: Option<App>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateDomainRequest {
|
||||||
|
pub pattern: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AppLookupResponse {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub app: App,
|
||||||
|
/// When the operator hits the API with a retired slug, this points
|
||||||
|
/// at the live slug so dashboards can redirect.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub redirect_to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_apps(State(s): State<AppsState>) -> Result<Json<Vec<App>>, AppsApiError> {
|
||||||
|
Ok(Json(s.apps.list().await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_app(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Json(input): Json<CreateAppRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<App>), AppsApiError> {
|
||||||
|
validate_slug(&input.slug)?;
|
||||||
|
|
||||||
|
// Historical-slug check before insert: if the slug is in history
|
||||||
|
// and the caller hasn't asked to force takeover, surface a clean
|
||||||
|
// 409 so the dashboard can present a "this will break old links"
|
||||||
|
// confirmation.
|
||||||
|
if !input.force_takeover {
|
||||||
|
if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
|
||||||
|
return Err(AppsApiError::SlugInHistory(current));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = if input.force_takeover {
|
||||||
|
s.apps
|
||||||
|
.create_with_takeover(&input.slug, &input.name, input.description.as_deref())
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
s.apps
|
||||||
|
.create(&input.slug, &input.name, input.description.as_deref())
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_app(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<AppLookupResponse>, AppsApiError> {
|
||||||
|
let lookup = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
let redirect_to = if lookup.redirected {
|
||||||
|
Some(lookup.app.slug.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(Json(AppLookupResponse {
|
||||||
|
app: lookup.app,
|
||||||
|
redirect_to,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_app(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<PatchAppRequest>,
|
||||||
|
) -> Result<Json<App>, AppsApiError> {
|
||||||
|
let current = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
|
||||||
|
// Edits to name/description go first (separate from rename so we
|
||||||
|
// don't conflate the two errors).
|
||||||
|
let after_meta = if input.name.is_some() || input.description.is_some() {
|
||||||
|
s.apps
|
||||||
|
.update(
|
||||||
|
current.id,
|
||||||
|
input.name.as_deref(),
|
||||||
|
input.description.as_ref().map(|d| d.as_deref()),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
current
|
||||||
|
};
|
||||||
|
|
||||||
|
// Slug rename is a separate operation; the rename method does its
|
||||||
|
// own history bookkeeping in a transaction.
|
||||||
|
let after_rename = if let Some(new_slug) = input.slug.as_deref() {
|
||||||
|
validate_slug(new_slug)?;
|
||||||
|
match s
|
||||||
|
.apps
|
||||||
|
.rename_slug(after_meta.id, new_slug, input.force_takeover)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(app) => app,
|
||||||
|
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
|
||||||
|
if let Some(current) = s.apps.slug_in_history(new_slug).await? {
|
||||||
|
return Err(AppsApiError::SlugInHistory(current));
|
||||||
|
}
|
||||||
|
return Err(AppsApiError::Conflict(msg));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
after_meta
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(after_rename))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_app(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<StatusCode, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
|
||||||
|
// Soft pre-check for a clean error; the DB FK is the real guard
|
||||||
|
// (ON DELETE RESTRICT on scripts.app_id).
|
||||||
|
let n_scripts = s.apps.count_scripts_in_app(app.id).await?;
|
||||||
|
if n_scripts > 0 {
|
||||||
|
return Err(AppsApiError::HasScripts(n_scripts));
|
||||||
|
}
|
||||||
|
|
||||||
|
s.apps.delete(app.id).await?;
|
||||||
|
refresh_domain_cache(&s).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn slug_check(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Path(_id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<SlugCheckRequest>,
|
||||||
|
) -> Result<Json<SlugCheckResponse>, AppsApiError> {
|
||||||
|
match validate_slug(&input.new_slug) {
|
||||||
|
Err(AppsApiError::InvalidSlug(reason)) => {
|
||||||
|
return Ok(Json(SlugCheckResponse {
|
||||||
|
ok: false,
|
||||||
|
conflict_kind: Some("invalid"),
|
||||||
|
current_app: None,
|
||||||
|
reason: Some(reason),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Err(other) => return Err(other),
|
||||||
|
Ok(()) => {}
|
||||||
|
}
|
||||||
|
if let Some(app) = s.apps.get_by_slug(&input.new_slug).await? {
|
||||||
|
return Ok(Json(SlugCheckResponse {
|
||||||
|
ok: false,
|
||||||
|
conflict_kind: Some("current"),
|
||||||
|
current_app: Some(app),
|
||||||
|
reason: Some("another app currently uses this slug".into()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if let Some(app) = s.apps.slug_in_history(&input.new_slug).await? {
|
||||||
|
return Ok(Json(SlugCheckResponse {
|
||||||
|
ok: false,
|
||||||
|
conflict_kind: Some("historical"),
|
||||||
|
current_app: Some(app),
|
||||||
|
reason: Some("slug is a retired redirect; using it will break old links".into()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(Json(SlugCheckResponse {
|
||||||
|
ok: true,
|
||||||
|
conflict_kind: None,
|
||||||
|
current_app: None,
|
||||||
|
reason: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_domains(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<AppDomain>>, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
Ok(Json(s.domains.list_for_app(app.id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_domain(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<CreateDomainRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
let parsed = pattern::parse_app_domain(&input.pattern)?;
|
||||||
|
let created = s
|
||||||
|
.domains
|
||||||
|
.create(NewAppDomain {
|
||||||
|
app_id: app.id,
|
||||||
|
pattern: input.pattern,
|
||||||
|
shape: parsed.shape,
|
||||||
|
shape_key: parsed.shape_key,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
refresh_domain_cache(&s).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_domain(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Path((id_or_slug, domain_id)): Path<(String, Uuid)>,
|
||||||
|
) -> Result<StatusCode, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
let Some(domain) = s.domains.get(domain_id).await? else {
|
||||||
|
return Err(AppsApiError::DomainNotFound(domain_id));
|
||||||
|
};
|
||||||
|
if domain.app_id != app.id {
|
||||||
|
return Err(AppsApiError::DomainNotFound(domain_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: routes inside this app may reference this exact host
|
||||||
|
// pattern. The host-kind on the route is `strict` or `wildcard`
|
||||||
|
// (Any routes don't pin a specific host). We block deletion in
|
||||||
|
// either case and let the operator clean up first.
|
||||||
|
let strict = s
|
||||||
|
.routes
|
||||||
|
.count_for_app_host(app.id, picloud_shared::HostKind::Strict, &domain.pattern)
|
||||||
|
.await?;
|
||||||
|
let wild_suffix = domain
|
||||||
|
.pattern
|
||||||
|
.split_once('.')
|
||||||
|
.map(|(_, s)| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let wild = if wild_suffix.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
s.routes
|
||||||
|
.count_for_app_host(app.id, picloud_shared::HostKind::Wildcard, &wild_suffix)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
if strict + wild > 0 {
|
||||||
|
return Err(AppsApiError::DomainHasRoutes(strict + wild));
|
||||||
|
}
|
||||||
|
|
||||||
|
s.domains.delete(domain_id).await?;
|
||||||
|
refresh_domain_cache(&s).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn resolve_app(
|
||||||
|
apps: &dyn AppRepository,
|
||||||
|
ident: &str,
|
||||||
|
) -> Result<crate::app_repo::AppLookup, AppsApiError> {
|
||||||
|
if let Ok(uuid) = ident.parse::<Uuid>() {
|
||||||
|
if let Some(app) = apps.get_by_id(AppId::from(uuid)).await? {
|
||||||
|
return Ok(crate::app_repo::AppLookup {
|
||||||
|
app,
|
||||||
|
redirected: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Err(AppsApiError::AppNotFound(ident.to_string()));
|
||||||
|
}
|
||||||
|
apps.get_by_slug_or_history(ident)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppsApiError::AppNotFound(ident.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_slug(slug: &str) -> Result<(), AppsApiError> {
|
||||||
|
if slug.len() < SLUG_MIN || slug.len() > SLUG_MAX {
|
||||||
|
return Err(AppsApiError::InvalidSlug(format!(
|
||||||
|
"slug length must be between {SLUG_MIN} and {SLUG_MAX}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !slug
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.is_some_and(|c| c.is_ascii_alphanumeric())
|
||||||
|
{
|
||||||
|
return Err(AppsApiError::InvalidSlug(
|
||||||
|
"slug must start with [a-z0-9]".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for c in slug.chars() {
|
||||||
|
if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
|
||||||
|
return Err(AppsApiError::InvalidSlug(
|
||||||
|
"slug may only contain lowercase letters, digits, and '-'".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if RESERVED_SLUGS.contains(&slug) {
|
||||||
|
return Err(AppsApiError::InvalidSlug(format!(
|
||||||
|
"slug {slug:?} is reserved for system use"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the in-memory host → app_id cache used by the orchestrator.
|
||||||
|
/// Called after every domain CRUD operation.
|
||||||
|
pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError> {
|
||||||
|
let all = state.domains.list_all().await?;
|
||||||
|
let compiled = all
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|d| {
|
||||||
|
// Parse the stored pattern; skip on parse error rather than
|
||||||
|
// poisoning the entire cache. The handlers reject bad input,
|
||||||
|
// so this is purely defensive against a future migration
|
||||||
|
// that loosens the constraints.
|
||||||
|
pattern::parse_app_domain(&d.pattern)
|
||||||
|
.ok()
|
||||||
|
.map(|p| CompiledAppDomain {
|
||||||
|
app_id: d.app_id,
|
||||||
|
pattern: p.pattern,
|
||||||
|
shape_key: p.shape_key,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
state.domain_table.replace(compiled);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppsApiError {
|
||||||
|
#[error("app not found: {0}")]
|
||||||
|
AppNotFound(String),
|
||||||
|
|
||||||
|
#[error("domain not found: {0}")]
|
||||||
|
DomainNotFound(Uuid),
|
||||||
|
|
||||||
|
#[error("invalid slug: {0}")]
|
||||||
|
InvalidSlug(String),
|
||||||
|
|
||||||
|
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
|
||||||
|
SlugInHistory(App),
|
||||||
|
|
||||||
|
#[error("app still contains {0} script(s); delete or move them first")]
|
||||||
|
HasScripts(i64),
|
||||||
|
|
||||||
|
#[error("domain has {0} route(s) bound to it; delete the routes first")]
|
||||||
|
DomainHasRoutes(i64),
|
||||||
|
|
||||||
|
#[error("invalid pattern: {0}")]
|
||||||
|
Pattern(#[from] pattern::ParseError),
|
||||||
|
|
||||||
|
#[error("conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
|
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Repo(#[from] ScriptRepositoryError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppsApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, body) = match &self {
|
||||||
|
Self::AppNotFound(_)
|
||||||
|
| Self::DomainNotFound(_)
|
||||||
|
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||||
|
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
|
Self::InvalidSlug(_) | Self::Pattern(_) => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
json!({ "error": self.to_string() }),
|
||||||
|
),
|
||||||
|
Self::SlugInHistory(current) => (
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
json!({
|
||||||
|
"error": self.to_string(),
|
||||||
|
"conflict_kind": "historical",
|
||||||
|
"current_app": current,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Self::HasScripts(n) => (
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
json!({ "error": self.to_string(), "script_count": n }),
|
||||||
|
),
|
||||||
|
Self::DomainHasRoutes(n) => (
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
json!({ "error": self.to_string(), "route_count": n }),
|
||||||
|
),
|
||||||
|
Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => {
|
||||||
|
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
|
Self::Repo(ScriptRepositoryError::Db(e)) => {
|
||||||
|
tracing::error!(error = %e, "apps api db error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ pub mod admin_session_repo;
|
|||||||
pub mod admin_user_repo;
|
pub mod admin_user_repo;
|
||||||
pub mod admin_users_api;
|
pub mod admin_users_api;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
|
pub mod app_bootstrap;
|
||||||
|
pub mod app_domain_repo;
|
||||||
|
pub mod app_repo;
|
||||||
|
pub mod apps_api;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod auth_api;
|
pub mod auth_api;
|
||||||
pub mod auth_bootstrap;
|
pub mod auth_bootstrap;
|
||||||
@@ -30,6 +34,10 @@ pub use admin_user_repo::{
|
|||||||
};
|
};
|
||||||
pub use admin_users_api::{admins_router, AdminsState};
|
pub use admin_users_api::{admins_router, AdminsState};
|
||||||
pub use api::{admin_router, AdminState};
|
pub use api::{admin_router, AdminState};
|
||||||
|
pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
|
||||||
|
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
|
||||||
|
pub use app_repo::{AppLookup, AppRepository, PostgresAppRepository};
|
||||||
|
pub use apps_api::{apps_router, AppsState};
|
||||||
pub use auth_api::auth_router;
|
pub use auth_api::auth_router;
|
||||||
pub use auth_bootstrap::{
|
pub use auth_bootstrap::{
|
||||||
bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError,
|
bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError,
|
||||||
|
|||||||
@@ -28,15 +28,16 @@ impl ExecutionLogSink for PostgresExecutionLogSink {
|
|||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO execution_logs ( \
|
"INSERT INTO execution_logs ( \
|
||||||
id, script_id, request_id, \
|
id, app_id, script_id, request_id, \
|
||||||
request_path, request_headers, request_body, \
|
request_path, request_headers, request_body, \
|
||||||
response_code, response_body, \
|
response_code, response_body, \
|
||||||
logs, duration_ms, status, created_at \
|
logs, duration_ms, status, created_at \
|
||||||
) VALUES ( \
|
) VALUES ( \
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 \
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 \
|
||||||
)",
|
)",
|
||||||
)
|
)
|
||||||
.bind(log.id)
|
.bind(log.id)
|
||||||
|
.bind(log.app_id.into_inner())
|
||||||
.bind(log.script_id.into_inner())
|
.bind(log.script_id.into_inner())
|
||||||
.bind(log.request_id.into_inner())
|
.bind(log.request_id.into_inner())
|
||||||
.bind(&log.request_path)
|
.bind(&log.request_path)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ use std::collections::BTreeMap;
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
||||||
use picloud_shared::{ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox};
|
use picloud_shared::{
|
||||||
|
AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
|
||||||
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -21,7 +23,10 @@ pub enum ScriptRepositoryError {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ScriptRepository: Send + Sync {
|
pub trait ScriptRepository: Send + Sync {
|
||||||
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError>;
|
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError>;
|
||||||
|
/// Every script across all apps. Mostly for tests and admin
|
||||||
|
/// "global" views; the dashboard reaches scripts via `list_for_app`.
|
||||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
|
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
|
||||||
async fn update(
|
async fn update(
|
||||||
&self,
|
&self,
|
||||||
@@ -35,6 +40,7 @@ pub trait ScriptRepository: Send + Sync {
|
|||||||
/// constraints; the repo enforces them in the DB regardless.
|
/// constraints; the repo enforces them in the DB regardless.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NewScript {
|
pub struct NewScript {
|
||||||
|
pub app_id: AppId,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub source: String,
|
pub source: String,
|
||||||
@@ -78,7 +84,7 @@ impl PostgresScriptRepository {
|
|||||||
impl ScriptRepository for PostgresScriptRepository {
|
impl ScriptRepository for PostgresScriptRepository {
|
||||||
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||||
let row = sqlx::query_as::<_, ScriptRow>(
|
let row = sqlx::query_as::<_, ScriptRow>(
|
||||||
"SELECT id, name, description, version, source, \
|
"SELECT id, app_id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||||
FROM scripts WHERE id = $1",
|
FROM scripts WHERE id = $1",
|
||||||
)
|
)
|
||||||
@@ -90,7 +96,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
|
|
||||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||||
"SELECT id, name, description, version, source, \
|
"SELECT id, app_id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||||
FROM scripts ORDER BY name",
|
FROM scripts ORDER BY name",
|
||||||
)
|
)
|
||||||
@@ -99,17 +105,30 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
Ok(rows.into_iter().map(Into::into).collect())
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||||
|
"SELECT id, app_id, name, description, version, source, \
|
||||||
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||||
|
FROM scripts WHERE app_id = $1 ORDER BY name",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
||||||
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
||||||
.unwrap_or_else(|_| serde_json::json!({}));
|
.unwrap_or_else(|_| serde_json::json!({}));
|
||||||
let res = sqlx::query_as::<_, ScriptRow>(
|
let res = sqlx::query_as::<_, ScriptRow>(
|
||||||
"INSERT INTO scripts ( \
|
"INSERT INTO scripts ( \
|
||||||
name, description, source, \
|
app_id, name, description, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox \
|
timeout_seconds, memory_limit_mb, sandbox \
|
||||||
) VALUES ($1, $2, $3, COALESCE($4, 30), COALESCE($5, 256), $6) \
|
) VALUES ($1, $2, $3, $4, COALESCE($5, 30), COALESCE($6, 256), $7) \
|
||||||
RETURNING id, name, description, version, source, \
|
RETURNING id, app_id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||||
)
|
)
|
||||||
|
.bind(input.app_id.into_inner())
|
||||||
.bind(&input.name)
|
.bind(&input.name)
|
||||||
.bind(input.description.as_deref())
|
.bind(input.description.as_deref())
|
||||||
.bind(&input.source)
|
.bind(&input.source)
|
||||||
@@ -123,7 +142,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
Ok(row) => Ok(row.into()),
|
Ok(row) => Ok(row.into()),
|
||||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
Err(ScriptRepositoryError::Conflict(format!(
|
Err(ScriptRepositoryError::Conflict(format!(
|
||||||
"a script named {:?} already exists",
|
"a script named {:?} already exists in this app",
|
||||||
input.name
|
input.name
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
@@ -141,12 +160,13 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
|
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
|
||||||
// Sandbox is replaced wholesale when present; per-field merging
|
// Sandbox is replaced wholesale when present; per-field merging
|
||||||
// happens in the API layer (clearer semantics for a "PUT a new
|
// happens in the API layer (clearer semantics for a "PUT a new
|
||||||
// sandbox config" call).
|
// sandbox config" call). app_id is immutable — moving a script
|
||||||
|
// to another app is a copy-and-delete, not an in-place edit.
|
||||||
let sandbox_json = patch
|
let sandbox_json = patch
|
||||||
.sandbox
|
.sandbox
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
||||||
let row = sqlx::query_as::<_, ScriptRow>(
|
let res = sqlx::query_as::<_, ScriptRow>(
|
||||||
"UPDATE scripts SET \
|
"UPDATE scripts SET \
|
||||||
name = COALESCE($2, name), \
|
name = COALESCE($2, name), \
|
||||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||||
@@ -157,7 +177,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
version = version + 1, \
|
version = version + 1, \
|
||||||
updated_at = NOW() \
|
updated_at = NOW() \
|
||||||
WHERE id = $1 \
|
WHERE id = $1 \
|
||||||
RETURNING id, name, description, version, source, \
|
RETURNING id, app_id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
@@ -169,10 +189,18 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
.bind(patch.memory_limit_mb)
|
.bind(patch.memory_limit_mb)
|
||||||
.bind(sandbox_json)
|
.bind(sandbox_json)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?;
|
.await;
|
||||||
|
|
||||||
row.map(Into::into)
|
match res {
|
||||||
.ok_or(ScriptRepositoryError::NotFound(id))
|
Ok(Some(row)) => Ok(row.into()),
|
||||||
|
Ok(None) => Err(ScriptRepositoryError::NotFound(id)),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
Err(ScriptRepositoryError::Conflict(
|
||||||
|
"a script with that name already exists in this app".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError> {
|
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError> {
|
||||||
@@ -191,6 +219,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct ScriptRow {
|
struct ScriptRow {
|
||||||
id: uuid::Uuid,
|
id: uuid::Uuid,
|
||||||
|
app_id: uuid::Uuid,
|
||||||
name: String,
|
name: String,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
version: i32,
|
version: i32,
|
||||||
@@ -211,6 +240,7 @@ impl From<ScriptRow> for Script {
|
|||||||
let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default();
|
let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default();
|
||||||
Self {
|
Self {
|
||||||
id: r.id.into(),
|
id: r.id.into(),
|
||||||
|
app_id: r.app_id.into(),
|
||||||
name: r.name,
|
name: r.name,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
version: r.version,
|
version: r.version,
|
||||||
@@ -284,7 +314,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
|
|||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> {
|
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, ExecutionLogRow>(
|
let rows = sqlx::query_as::<_, ExecutionLogRow>(
|
||||||
"SELECT id, script_id, request_id, \
|
"SELECT id, app_id, script_id, request_id, \
|
||||||
request_path, request_headers, request_body, \
|
request_path, request_headers, request_body, \
|
||||||
response_code, response_body, \
|
response_code, response_body, \
|
||||||
logs, duration_ms, status, created_at \
|
logs, duration_ms, status, created_at \
|
||||||
@@ -306,6 +336,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
|
|||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct ExecutionLogRow {
|
struct ExecutionLogRow {
|
||||||
id: uuid::Uuid,
|
id: uuid::Uuid,
|
||||||
|
app_id: uuid::Uuid,
|
||||||
script_id: uuid::Uuid,
|
script_id: uuid::Uuid,
|
||||||
request_id: uuid::Uuid,
|
request_id: uuid::Uuid,
|
||||||
request_path: Option<String>,
|
request_path: Option<String>,
|
||||||
@@ -331,6 +362,7 @@ impl From<ExecutionLogRow> for ExecutionLog {
|
|||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
app_id: r.app_id.into(),
|
||||||
script_id: r.script_id.into(),
|
script_id: r.script_id.into(),
|
||||||
request_id: RequestId::from(r.request_id),
|
request_id: RequestId::from(r.request_id),
|
||||||
request_path: r.request_path.unwrap_or_default(),
|
request_path: r.request_path.unwrap_or_default(),
|
||||||
|
|||||||
@@ -13,39 +13,49 @@ use axum::{
|
|||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
|
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
|
||||||
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
|
use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::repo::ScriptRepositoryError;
|
use crate::app_domain_repo::AppDomainRepository;
|
||||||
|
use crate::repo::{ScriptRepository, ScriptRepositoryError};
|
||||||
use crate::route_repo::{NewRoute, RouteRepository};
|
use crate::route_repo::{NewRoute, RouteRepository};
|
||||||
|
|
||||||
pub struct RouteAdminState<RR> {
|
pub struct RouteAdminState<RR, SR> {
|
||||||
pub routes: Arc<RR>,
|
pub routes: Arc<RR>,
|
||||||
|
/// Used to resolve `script_id → app_id` when creating routes (the
|
||||||
|
/// route inherits the script's app) and to scope conflict checks.
|
||||||
|
pub scripts: Arc<SR>,
|
||||||
|
/// Used to validate the route's host against the parent app's
|
||||||
|
/// declared domain claims.
|
||||||
|
pub domains: Arc<dyn AppDomainRepository>,
|
||||||
pub table: Arc<RouteTable>,
|
pub table: Arc<RouteTable>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<RR> Clone for RouteAdminState<RR> {
|
impl<RR, SR> Clone for RouteAdminState<RR, SR> {
|
||||||
fn clone(&self) -> Self {
|
fn clone(&self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
routes: self.routes.clone(),
|
routes: self.routes.clone(),
|
||||||
|
scripts: self.scripts.clone(),
|
||||||
|
domains: self.domains.clone(),
|
||||||
table: self.table.clone(),
|
table: self.table.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn route_admin_router<RR>(state: RouteAdminState<RR>) -> Router
|
pub fn route_admin_router<RR, SR>(state: RouteAdminState<RR, SR>) -> Router
|
||||||
where
|
where
|
||||||
RR: RouteRepository + 'static,
|
RR: RouteRepository + 'static,
|
||||||
|
SR: ScriptRepository + 'static,
|
||||||
{
|
{
|
||||||
Router::new()
|
Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/scripts/{id}/routes",
|
"/scripts/{id}/routes",
|
||||||
get(list_routes::<RR>).post(create_route::<RR>),
|
get(list_routes::<RR, SR>).post(create_route::<RR, SR>),
|
||||||
)
|
)
|
||||||
.route("/routes/{route_id}", delete(delete_route::<RR>))
|
.route("/routes/{route_id}", delete(delete_route::<RR, SR>))
|
||||||
.route("/routes:check", post(check_route::<RR>))
|
.route("/routes:check", post(check_route::<RR, SR>))
|
||||||
.route("/routes:match", post(match_route::<RR>))
|
.route("/routes:match", post(match_route::<RR, SR>))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +77,10 @@ pub struct CreateRouteRequest {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CheckRouteRequest {
|
pub struct CheckRouteRequest {
|
||||||
|
/// Required: which app's route table this hypothetical route would
|
||||||
|
/// join. Conflict checks are strictly intra-app (cross-app route
|
||||||
|
/// errors would leak tenant info — see blueprint §11.5).
|
||||||
|
pub app_id: AppId,
|
||||||
pub host_kind: HostKind,
|
pub host_kind: HostKind,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub host: String,
|
pub host: String,
|
||||||
@@ -84,6 +98,9 @@ pub struct CheckRouteResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct MatchRouteRequest {
|
pub struct MatchRouteRequest {
|
||||||
|
/// Which app's route table to dispatch against. The dashboard's
|
||||||
|
/// route-preview tester always knows the current app context.
|
||||||
|
pub app_id: AppId,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
#[serde(default = "default_method")]
|
#[serde(default = "default_method")]
|
||||||
pub method: String,
|
pub method: String,
|
||||||
@@ -111,15 +128,15 @@ pub struct MatchedRoute {
|
|||||||
// Handlers
|
// Handlers
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
async fn list_routes<RR: RouteRepository>(
|
async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
Path(script_id): Path<ScriptId>,
|
Path(script_id): Path<ScriptId>,
|
||||||
) -> Result<Json<Vec<Route>>, RouteApiError> {
|
) -> Result<Json<Vec<Route>>, RouteApiError> {
|
||||||
Ok(Json(state.routes.list_for_script(script_id).await?))
|
Ok(Json(state.routes.list_for_script(script_id).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_route<RR: RouteRepository>(
|
async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
Path(script_id): Path<ScriptId>,
|
Path(script_id): Path<ScriptId>,
|
||||||
Json(input): Json<CreateRouteRequest>,
|
Json(input): Json<CreateRouteRequest>,
|
||||||
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
|
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
|
||||||
@@ -130,8 +147,22 @@ async fn create_route<RR: RouteRepository>(
|
|||||||
input.host_param_name.as_deref(),
|
input.host_param_name.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Within-kind conflict check against existing routes.
|
// Look up the script's owning app — every route inherits it.
|
||||||
let existing = state.routes.list_all().await?;
|
let script = state
|
||||||
|
.scripts
|
||||||
|
.get(script_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||||
|
let app_id = script.app_id;
|
||||||
|
|
||||||
|
// Validate the route's host is consistent with one of the app's
|
||||||
|
// domain claims. `HostKind::Any` is always permitted (catches every
|
||||||
|
// host the app already owns). Specific hosts must match a claim.
|
||||||
|
validate_route_host_against_app(state.domains.as_ref(), app_id, input.host_kind, &input.host)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Within-app conflict check (cross-app is impossible by construction).
|
||||||
|
let existing = state.routes.list_for_app(app_id).await?;
|
||||||
if let Some((conflicting, reason)) = first_conflict(
|
if let Some((conflicting, reason)) = first_conflict(
|
||||||
&existing,
|
&existing,
|
||||||
input.host_kind,
|
input.host_kind,
|
||||||
@@ -149,6 +180,7 @@ async fn create_route<RR: RouteRepository>(
|
|||||||
let created = state
|
let created = state
|
||||||
.routes
|
.routes
|
||||||
.create(NewRoute {
|
.create(NewRoute {
|
||||||
|
app_id,
|
||||||
script_id,
|
script_id,
|
||||||
host_kind: input.host_kind,
|
host_kind: input.host_kind,
|
||||||
host: input.host,
|
host: input.host,
|
||||||
@@ -162,8 +194,8 @@ async fn create_route<RR: RouteRepository>(
|
|||||||
Ok((StatusCode::CREATED, Json(created)))
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_route<RR: RouteRepository>(
|
async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
Path(route_id): Path<Uuid>,
|
Path(route_id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, RouteApiError> {
|
) -> Result<StatusCode, RouteApiError> {
|
||||||
state.routes.delete(route_id).await?;
|
state.routes.delete(route_id).await?;
|
||||||
@@ -171,14 +203,14 @@ async fn delete_route<RR: RouteRepository>(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_route<RR: RouteRepository>(
|
async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
Json(input): Json<CheckRouteRequest>,
|
Json(input): Json<CheckRouteRequest>,
|
||||||
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
|
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
|
||||||
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
|
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
|
||||||
pattern::parse_host(input.host_kind, &input.host, None)?;
|
pattern::parse_host(input.host_kind, &input.host, None)?;
|
||||||
|
|
||||||
let existing = state.routes.list_all().await?;
|
let existing = state.routes.list_for_app(input.app_id).await?;
|
||||||
let conflict = first_conflict(
|
let conflict = first_conflict(
|
||||||
&existing,
|
&existing,
|
||||||
input.host_kind,
|
input.host_kind,
|
||||||
@@ -201,8 +233,8 @@ async fn check_route<RR: RouteRepository>(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn match_route<RR: RouteRepository>(
|
async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
Json(input): Json<MatchRouteRequest>,
|
Json(input): Json<MatchRouteRequest>,
|
||||||
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
|
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
|
||||||
let parsed = url::Url::parse(&input.url)
|
let parsed = url::Url::parse(&input.url)
|
||||||
@@ -210,7 +242,9 @@ async fn match_route<RR: RouteRepository>(
|
|||||||
let host = parsed.host_str().unwrap_or("").to_string();
|
let host = parsed.host_str().unwrap_or("").to_string();
|
||||||
let path = parsed.path().to_string();
|
let path = parsed.path().to_string();
|
||||||
|
|
||||||
let result = state.table.match_request(&host, &input.method, &path);
|
let result = state
|
||||||
|
.table
|
||||||
|
.match_request_for_app(input.app_id, &host, &input.method, &path);
|
||||||
Ok(Json(MatchRouteResponse {
|
Ok(Json(MatchRouteResponse {
|
||||||
matched: result.map(|r| MatchedRoute {
|
matched: result.map(|r| MatchedRoute {
|
||||||
route_id: r.matched.route_id,
|
route_id: r.matched.route_id,
|
||||||
@@ -263,12 +297,12 @@ fn first_conflict(
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn refresh_table<RR: RouteRepository>(
|
async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
state: &RouteAdminState<RR>,
|
state: &RouteAdminState<RR, SR>,
|
||||||
) -> Result<(), RouteApiError> {
|
) -> Result<(), RouteApiError> {
|
||||||
let rows = state.routes.list_all().await?;
|
let rows = state.routes.list_all().await?;
|
||||||
let compiled = compile_routes(&rows)?;
|
let compiled = compile_routes(&rows)?;
|
||||||
state.table.replace(compiled);
|
state.table.replace_all(compiled);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +311,7 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
|
|||||||
.map(|r| {
|
.map(|r| {
|
||||||
Ok(CompiledRoute {
|
Ok(CompiledRoute {
|
||||||
route_id: r.id,
|
route_id: r.id,
|
||||||
|
app_id: r.app_id,
|
||||||
script_id: r.script_id,
|
script_id: r.script_id,
|
||||||
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
|
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
|
||||||
path: pattern::parse_path(r.path_kind, &r.path)?,
|
path: pattern::parse_path(r.path_kind, &r.path)?,
|
||||||
@@ -286,6 +321,79 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate that a new route's (host_kind, host) is consistent with at
|
||||||
|
/// least one of the parent app's domain claims. `HostKind::Any` is
|
||||||
|
/// always permitted — it catches every host the app already owns.
|
||||||
|
async fn validate_route_host_against_app(
|
||||||
|
domains: &dyn AppDomainRepository,
|
||||||
|
app_id: AppId,
|
||||||
|
host_kind: HostKind,
|
||||||
|
host: &str,
|
||||||
|
) -> Result<(), RouteApiError> {
|
||||||
|
if matches!(host_kind, HostKind::Any) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let claims = domains.list_for_app(app_id).await?;
|
||||||
|
if claims.is_empty() {
|
||||||
|
return Err(RouteApiError::HostNotClaimed {
|
||||||
|
host: host.to_string(),
|
||||||
|
available_claims: vec![],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let host_lower = host.to_ascii_lowercase();
|
||||||
|
for claim in &claims {
|
||||||
|
let claim_lower = claim.pattern.to_ascii_lowercase();
|
||||||
|
match (host_kind, claim.shape) {
|
||||||
|
// Strict route under exact claim: must match exactly.
|
||||||
|
(HostKind::Strict, picloud_shared::DomainShape::Exact) => {
|
||||||
|
if host_lower == claim_lower {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strict route under wildcard/parameterized: must end with
|
||||||
|
// ".<suffix>" where the claim's suffix is the part after
|
||||||
|
// `*.` or `{...}.`.
|
||||||
|
(
|
||||||
|
HostKind::Strict,
|
||||||
|
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
|
||||||
|
) => {
|
||||||
|
let suffix = claim_lower
|
||||||
|
.split_once('.')
|
||||||
|
.map(|(_, s)| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let needle = format!(".{suffix}");
|
||||||
|
if !suffix.is_empty() && host_lower.ends_with(&needle) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wildcard route: must match a wildcard or parameterized
|
||||||
|
// claim with identical suffix.
|
||||||
|
(
|
||||||
|
HostKind::Wildcard,
|
||||||
|
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
|
||||||
|
) => {
|
||||||
|
let claim_suffix = claim_lower
|
||||||
|
.split_once('.')
|
||||||
|
.map(|(_, s)| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if claim_suffix == host_lower {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wildcard route under exact claim: not allowed (would
|
||||||
|
// shadow other apps' subdomains the operator didn't claim).
|
||||||
|
(HostKind::Wildcard, picloud_shared::DomainShape::Exact) => {}
|
||||||
|
(HostKind::Any, _) => unreachable!("handled above"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(RouteApiError::HostNotClaimed {
|
||||||
|
host: host.to_string(),
|
||||||
|
available_claims: claims.into_iter().map(|c| c.pattern).collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Errors
|
// Errors
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -304,6 +412,15 @@ pub enum RouteApiError {
|
|||||||
#[error("bad request: {0}")]
|
#[error("bad request: {0}")]
|
||||||
BadRequest(String),
|
BadRequest(String),
|
||||||
|
|
||||||
|
#[error("script not found: {0}")]
|
||||||
|
ScriptNotFound(ScriptId),
|
||||||
|
|
||||||
|
#[error("host {host:?} is not claimed by this app")]
|
||||||
|
HostNotClaimed {
|
||||||
|
host: String,
|
||||||
|
available_claims: Vec<String>,
|
||||||
|
},
|
||||||
|
|
||||||
#[error("repository error: {0}")]
|
#[error("repository error: {0}")]
|
||||||
Repo(#[from] ScriptRepositoryError),
|
Repo(#[from] ScriptRepositoryError),
|
||||||
}
|
}
|
||||||
@@ -326,10 +443,21 @@ impl IntoResponse for RouteApiError {
|
|||||||
StatusCode::UNPROCESSABLE_ENTITY,
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
serde_json::json!({ "error": self.to_string() }),
|
serde_json::json!({ "error": self.to_string() }),
|
||||||
),
|
),
|
||||||
Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
Self::ScriptNotFound(_) | Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
serde_json::json!({ "error": self.to_string() }),
|
serde_json::json!({ "error": self.to_string() }),
|
||||||
),
|
),
|
||||||
|
Self::HostNotClaimed {
|
||||||
|
host,
|
||||||
|
available_claims,
|
||||||
|
} => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
serde_json::json!({
|
||||||
|
"error": self.to_string(),
|
||||||
|
"host": host,
|
||||||
|
"available_claims": available_claims,
|
||||||
|
}),
|
||||||
|
),
|
||||||
Self::Repo(ScriptRepositoryError::Conflict(_)) => (
|
Self::Repo(ScriptRepositoryError::Conflict(_)) => (
|
||||||
StatusCode::CONFLICT,
|
StatusCode::CONFLICT,
|
||||||
serde_json::json!({ "error": self.to_string() }),
|
serde_json::json!({ "error": self.to_string() }),
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
//! CRUD over the `routes` table.
|
//! CRUD over the `routes` table.
|
||||||
//!
|
//!
|
||||||
//! The orchestrator's `RouteTable` is repopulated from this repo after
|
//! The orchestrator's `AppRouteTables` is repopulated from this repo
|
||||||
//! every write — see the route_admin module for the binding.
|
//! after every write — see the route_admin module for the binding.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
|
use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ use crate::repo::ScriptRepositoryError;
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NewRoute {
|
pub struct NewRoute {
|
||||||
|
pub app_id: AppId,
|
||||||
pub script_id: ScriptId,
|
pub script_id: ScriptId,
|
||||||
pub host_kind: HostKind,
|
pub host_kind: HostKind,
|
||||||
pub host: String,
|
pub host: String,
|
||||||
@@ -24,12 +25,21 @@ pub struct NewRoute {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait RouteRepository: Send + Sync {
|
pub trait RouteRepository: Send + Sync {
|
||||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
|
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||||
async fn list_for_script(
|
async fn list_for_script(
|
||||||
&self,
|
&self,
|
||||||
script_id: ScriptId,
|
script_id: ScriptId,
|
||||||
) -> Result<Vec<Route>, ScriptRepositoryError>;
|
) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError>;
|
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError>;
|
||||||
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError>;
|
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError>;
|
||||||
|
/// Count routes whose host_kind/host pair matches a pattern in
|
||||||
|
/// `app_id`. Used by the domain-claim delete guard.
|
||||||
|
async fn count_for_app_host(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
host_kind: HostKind,
|
||||||
|
host: &str,
|
||||||
|
) -> Result<i64, ScriptRepositoryError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PostgresRouteRepository {
|
pub struct PostgresRouteRepository {
|
||||||
@@ -47,7 +57,7 @@ impl PostgresRouteRepository {
|
|||||||
impl RouteRepository for PostgresRouteRepository {
|
impl RouteRepository for PostgresRouteRepository {
|
||||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
|
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, RouteRow>(
|
let rows = sqlx::query_as::<_, RouteRow>(
|
||||||
"SELECT id, script_id, host_kind, host, host_param_name, \
|
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method, created_at \
|
path_kind, path, method, created_at \
|
||||||
FROM routes ORDER BY created_at",
|
FROM routes ORDER BY created_at",
|
||||||
)
|
)
|
||||||
@@ -56,12 +66,24 @@ impl RouteRepository for PostgresRouteRepository {
|
|||||||
Ok(rows.into_iter().map(Into::into).collect())
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, RouteRow>(
|
||||||
|
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
|
path_kind, path, method, created_at \
|
||||||
|
FROM routes WHERE app_id = $1 ORDER BY created_at",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_for_script(
|
async fn list_for_script(
|
||||||
&self,
|
&self,
|
||||||
script_id: ScriptId,
|
script_id: ScriptId,
|
||||||
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, RouteRow>(
|
let rows = sqlx::query_as::<_, RouteRow>(
|
||||||
"SELECT id, script_id, host_kind, host, host_param_name, \
|
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method, created_at \
|
path_kind, path, method, created_at \
|
||||||
FROM routes WHERE script_id = $1 ORDER BY created_at",
|
FROM routes WHERE script_id = $1 ORDER BY created_at",
|
||||||
)
|
)
|
||||||
@@ -74,12 +96,13 @@ impl RouteRepository for PostgresRouteRepository {
|
|||||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
|
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
|
||||||
let res = sqlx::query_as::<_, RouteRow>(
|
let res = sqlx::query_as::<_, RouteRow>(
|
||||||
"INSERT INTO routes ( \
|
"INSERT INTO routes ( \
|
||||||
script_id, host_kind, host, host_param_name, \
|
app_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method \
|
path_kind, path, method \
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
|
||||||
RETURNING id, script_id, host_kind, host, host_param_name, \
|
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method, created_at",
|
path_kind, path, method, created_at",
|
||||||
)
|
)
|
||||||
|
.bind(input.app_id.into_inner())
|
||||||
.bind(input.script_id.into_inner())
|
.bind(input.script_id.into_inner())
|
||||||
.bind(host_kind_str(input.host_kind))
|
.bind(host_kind_str(input.host_kind))
|
||||||
.bind(&input.host)
|
.bind(&input.host)
|
||||||
@@ -112,6 +135,24 @@ impl RouteRepository for PostgresRouteRepository {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn count_for_app_host(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
host_kind: HostKind,
|
||||||
|
host: &str,
|
||||||
|
) -> Result<i64, ScriptRepositoryError> {
|
||||||
|
let count: (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*) FROM routes \
|
||||||
|
WHERE app_id = $1 AND host_kind = $2 AND host = $3",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(host_kind_str(host_kind))
|
||||||
|
.bind(host)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count.0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn host_kind_str(k: HostKind) -> &'static str {
|
const fn host_kind_str(k: HostKind) -> &'static str {
|
||||||
@@ -133,6 +174,7 @@ const fn path_kind_str(k: PathKind) -> &'static str {
|
|||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct RouteRow {
|
struct RouteRow {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
|
app_id: Uuid,
|
||||||
script_id: Uuid,
|
script_id: Uuid,
|
||||||
host_kind: String,
|
host_kind: String,
|
||||||
host: String,
|
host: String,
|
||||||
@@ -147,6 +189,7 @@ impl From<RouteRow> for Route {
|
|||||||
fn from(r: RouteRow) -> Self {
|
fn from(r: RouteRow) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
app_id: r.app_id.into(),
|
||||||
script_id: r.script_id.into(),
|
script_id: r.script_id.into(),
|
||||||
host_kind: match r.host_kind.as_str() {
|
host_kind: match r.host_kind.as_str() {
|
||||||
"strict" => HostKind::Strict,
|
"strict" => HostKind::Strict,
|
||||||
|
|||||||
@@ -3,6 +3,43 @@
|
|||||||
|
|
||||||
## tables
|
## tables
|
||||||
|
|
||||||
|
table: admin_sessions
|
||||||
|
token_hash: text NOT NULL
|
||||||
|
user_id: uuid NOT NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
expires_at: timestamp with time zone NOT NULL
|
||||||
|
last_used_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: admin_users
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
username: text NOT NULL
|
||||||
|
password_hash: text NOT NULL
|
||||||
|
is_active: boolean NOT NULL default=true
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
last_login_at: timestamp with time zone NULL
|
||||||
|
|
||||||
|
table: app_domains
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
pattern: text NOT NULL
|
||||||
|
shape: text NOT NULL
|
||||||
|
shape_key: text NOT NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: app_slug_history
|
||||||
|
slug: text NOT NULL
|
||||||
|
current_app_id: uuid NOT NULL
|
||||||
|
retired_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: apps
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
slug: text NOT NULL
|
||||||
|
name: text NOT NULL
|
||||||
|
description: text NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
table: execution_logs
|
table: execution_logs
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
script_id: uuid NOT NULL
|
script_id: uuid NOT NULL
|
||||||
@@ -16,6 +53,7 @@ table: execution_logs
|
|||||||
duration_ms: integer NOT NULL default=0
|
duration_ms: integer NOT NULL default=0
|
||||||
status: text NOT NULL
|
status: text NOT NULL
|
||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
|
||||||
table: routes
|
table: routes
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
@@ -27,6 +65,7 @@ table: routes
|
|||||||
path: text NOT NULL
|
path: text NOT NULL
|
||||||
method: text NULL
|
method: text NULL
|
||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
|
||||||
table: scripts
|
table: scripts
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
@@ -39,42 +78,94 @@ table: scripts
|
|||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
updated_at: timestamp with time zone NOT NULL default=now()
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
sandbox: jsonb NOT NULL default='{}'::jsonb
|
sandbox: jsonb NOT NULL default='{}'::jsonb
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
|
||||||
## indexes
|
## indexes
|
||||||
|
|
||||||
|
indexes on admin_sessions:
|
||||||
|
admin_sessions_expiry_idx: public.admin_sessions USING btree (expires_at)
|
||||||
|
admin_sessions_pkey: public.admin_sessions USING btree (token_hash)
|
||||||
|
admin_sessions_user_idx: public.admin_sessions USING btree (user_id)
|
||||||
|
|
||||||
|
indexes on admin_users:
|
||||||
|
admin_users_pkey: public.admin_users USING btree (id)
|
||||||
|
admin_users_username_key: public.admin_users USING btree (username)
|
||||||
|
|
||||||
|
indexes on app_domains:
|
||||||
|
app_domains_app_id_idx: public.app_domains USING btree (app_id)
|
||||||
|
app_domains_pkey: public.app_domains USING btree (id)
|
||||||
|
app_domains_shape_key_key: public.app_domains USING btree (shape_key)
|
||||||
|
|
||||||
|
indexes on app_slug_history:
|
||||||
|
app_slug_history_pkey: public.app_slug_history USING btree (slug)
|
||||||
|
|
||||||
|
indexes on apps:
|
||||||
|
apps_pkey: public.apps USING btree (id)
|
||||||
|
apps_slug_key: public.apps USING btree (slug)
|
||||||
|
|
||||||
indexes on execution_logs:
|
indexes on execution_logs:
|
||||||
|
execution_logs_app_id_created_at_idx: public.execution_logs USING btree (app_id, created_at DESC)
|
||||||
execution_logs_pkey: public.execution_logs USING btree (id)
|
execution_logs_pkey: public.execution_logs USING btree (id)
|
||||||
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
|
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
|
||||||
|
|
||||||
indexes on routes:
|
indexes on routes:
|
||||||
|
routes_app_id_idx: public.routes USING btree (app_id)
|
||||||
routes_lookup_idx: public.routes USING btree (host_kind, host)
|
routes_lookup_idx: public.routes USING btree (host_kind, host)
|
||||||
routes_pkey: public.routes USING btree (id)
|
routes_pkey: public.routes USING btree (id)
|
||||||
routes_script_id_idx: public.routes USING btree (script_id)
|
routes_script_id_idx: public.routes USING btree (script_id)
|
||||||
routes_unique_binding_idx: public.routes USING btree (host_kind, host, path_kind, path, COALESCE(method, ''::text))
|
routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text))
|
||||||
|
|
||||||
indexes on scripts:
|
indexes on scripts:
|
||||||
scripts_name_uidx: public.scripts USING btree (lower(name))
|
scripts_app_id_idx: public.scripts USING btree (app_id)
|
||||||
|
scripts_name_uidx: public.scripts USING btree (app_id, lower(name))
|
||||||
scripts_pkey: public.scripts USING btree (id)
|
scripts_pkey: public.scripts USING btree (id)
|
||||||
|
|
||||||
## constraints
|
## constraints
|
||||||
|
|
||||||
|
constraints on admin_sessions:
|
||||||
|
[FOREIGN KEY] admin_sessions_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] admin_sessions_pkey: PRIMARY KEY (token_hash)
|
||||||
|
|
||||||
|
constraints on admin_users:
|
||||||
|
[PRIMARY KEY] admin_users_pkey: PRIMARY KEY (id)
|
||||||
|
[UNIQUE] admin_users_username_key: UNIQUE (username)
|
||||||
|
|
||||||
|
constraints on app_domains:
|
||||||
|
[CHECK] app_domains_shape_check: CHECK ((shape = ANY (ARRAY['exact'::text, 'wildcard'::text, 'parameterized'::text])))
|
||||||
|
[FOREIGN KEY] app_domains_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] app_domains_pkey: PRIMARY KEY (id)
|
||||||
|
[UNIQUE] app_domains_shape_key_key: UNIQUE (shape_key)
|
||||||
|
|
||||||
|
constraints on app_slug_history:
|
||||||
|
[FOREIGN KEY] app_slug_history_current_app_id_fkey: FOREIGN KEY (current_app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] app_slug_history_pkey: PRIMARY KEY (slug)
|
||||||
|
|
||||||
|
constraints on apps:
|
||||||
|
[PRIMARY KEY] apps_pkey: PRIMARY KEY (id)
|
||||||
|
[UNIQUE] apps_slug_key: UNIQUE (slug)
|
||||||
|
|
||||||
constraints on execution_logs:
|
constraints on execution_logs:
|
||||||
[CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text])))
|
[CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text])))
|
||||||
|
[FOREIGN KEY] execution_logs_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
constraints on routes:
|
constraints on routes:
|
||||||
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
||||||
[CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
|
[CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
|
||||||
|
[FOREIGN KEY] routes_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
[FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
[FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
constraints on scripts:
|
constraints on scripts:
|
||||||
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
|
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
|
||||||
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
|
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
|
||||||
|
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
|
||||||
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
## applied migrations
|
## applied migrations
|
||||||
0001: init
|
0001: init
|
||||||
0002: sandbox
|
0002: sandbox
|
||||||
0003: routes
|
0003: routes
|
||||||
|
0004: admin auth
|
||||||
|
0005: apps
|
||||||
|
|||||||
@@ -17,22 +17,26 @@ use axum::{
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
|
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
|
AppId, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
|
||||||
};
|
};
|
||||||
use serde_json::Value as Json_;
|
use serde_json::Value as Json_;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::client::ExecutorClient;
|
use crate::client::ExecutorClient;
|
||||||
use crate::resolver::{ResolverError, ScriptResolver};
|
use crate::resolver::{ResolverError, ScriptResolver};
|
||||||
use crate::routing::RouteTable;
|
use crate::routing::{AppDomainTable, RouteTable};
|
||||||
|
|
||||||
/// State shared by data-plane handlers.
|
/// State shared by data-plane handlers.
|
||||||
pub struct DataPlaneState<E, R> {
|
pub struct DataPlaneState<E, R> {
|
||||||
pub executor: Arc<E>,
|
pub executor: Arc<E>,
|
||||||
pub resolver: Arc<R>,
|
pub resolver: Arc<R>,
|
||||||
pub log_sink: Arc<dyn ExecutionLogSink>,
|
pub log_sink: Arc<dyn ExecutionLogSink>,
|
||||||
/// Routing table for user-defined paths. Shared with the manager
|
/// Host → app_id resolver. Run before `routes` to filter to the
|
||||||
/// (admin router writes; this side reads).
|
/// owning app's slice. Shared with the manager (writes invalidate
|
||||||
|
/// the cache by replacing the table).
|
||||||
|
pub app_domains: Arc<AppDomainTable>,
|
||||||
|
/// Routing table for user-defined paths, partitioned per app.
|
||||||
|
/// Shared with the manager (admin router writes; this side reads).
|
||||||
pub routes: Arc<RouteTable>,
|
pub routes: Arc<RouteTable>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +46,7 @@ impl<E, R> Clone for DataPlaneState<E, R> {
|
|||||||
executor: self.executor.clone(),
|
executor: self.executor.clone(),
|
||||||
resolver: self.resolver.clone(),
|
resolver: self.resolver.clone(),
|
||||||
log_sink: self.log_sink.clone(),
|
log_sink: self.log_sink.clone(),
|
||||||
|
app_domains: self.app_domains.clone(),
|
||||||
routes: self.routes.clone(),
|
routes: self.routes.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +114,7 @@ where
|
|||||||
// audit-visible platform — but a sink failure must not mask the
|
// audit-visible platform — but a sink failure must not mask the
|
||||||
// user-facing result, so we only log a warning if it fails.
|
// user-facing result, so we only log a warning if it fails.
|
||||||
let log = build_execution_log(
|
let log = build_execution_log(
|
||||||
|
script.app_id,
|
||||||
id,
|
id,
|
||||||
request_id,
|
request_id,
|
||||||
request_path,
|
request_path,
|
||||||
@@ -145,7 +151,23 @@ where
|
|||||||
.to_string();
|
.to_string();
|
||||||
let headers = request.headers().clone();
|
let headers = request.headers().clone();
|
||||||
|
|
||||||
let Some(matched) = state.routes.match_request(&host, &method, &path) else {
|
// Two-phase dispatch (blueprint §11.5): first resolve Host → app_id,
|
||||||
|
// then run the existing matcher on that app's slice. No app claims
|
||||||
|
// this host → flat 404; the path doesn't get the chance to fire.
|
||||||
|
let Some(app_id) = state.app_domains.resolve_app(&host) else {
|
||||||
|
return Ok((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": format!("no app claims host {host:?}")
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(matched) = state
|
||||||
|
.routes
|
||||||
|
.match_request_for_app(app_id, &host, &method, &path)
|
||||||
|
else {
|
||||||
return Ok((
|
return Ok((
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
@@ -191,6 +213,7 @@ where
|
|||||||
let finished = Utc::now();
|
let finished = Utc::now();
|
||||||
|
|
||||||
let log = build_execution_log(
|
let log = build_execution_log(
|
||||||
|
script.app_id,
|
||||||
matched.matched.script_id,
|
matched.matched.script_id,
|
||||||
request_id,
|
request_id,
|
||||||
request_path,
|
request_path,
|
||||||
@@ -292,6 +315,7 @@ fn exec_response_to_http(resp: ExecResponse) -> Response {
|
|||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn build_execution_log(
|
fn build_execution_log(
|
||||||
|
app_id: AppId,
|
||||||
script_id: ScriptId,
|
script_id: ScriptId,
|
||||||
request_id: RequestId,
|
request_id: RequestId,
|
||||||
request_path: String,
|
request_path: String,
|
||||||
@@ -336,6 +360,7 @@ fn build_execution_log(
|
|||||||
|
|
||||||
ExecutionLog {
|
ExecutionLog {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
|
app_id,
|
||||||
script_id,
|
script_id,
|
||||||
request_id,
|
request_id,
|
||||||
request_path,
|
request_path,
|
||||||
|
|||||||
165
crates/orchestrator-core/src/routing/app_domains.rs
Normal file
165
crates/orchestrator-core/src/routing/app_domains.rs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
//! Host → app_id resolver. The first phase of the orchestrator's
|
||||||
|
//! two-phase dispatch (the second phase is the per-app route matcher
|
||||||
|
//! in `routing::table::RouteTable`).
|
||||||
|
//!
|
||||||
|
//! Cached in memory; the manager rebuilds the table after each
|
||||||
|
//! domain-claim CRUD operation (same pattern as `RouteTable`).
|
||||||
|
|
||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
use picloud_shared::AppId;
|
||||||
|
|
||||||
|
use super::pattern::{HostPattern, HostSpecificity};
|
||||||
|
|
||||||
|
/// A parsed domain claim ready for runtime matching.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CompiledAppDomain {
|
||||||
|
pub app_id: AppId,
|
||||||
|
pub pattern: HostPattern,
|
||||||
|
pub shape_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct AppDomainTable {
|
||||||
|
inner: RwLock<Vec<CompiledAppDomain>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppDomainTable {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic full replacement; called at startup and after every
|
||||||
|
/// domain CRUD operation.
|
||||||
|
pub fn replace(&self, domains: Vec<CompiledAppDomain>) {
|
||||||
|
let mut guard = self.inner.write().expect("app domain table poisoned");
|
||||||
|
*guard = domains;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a request's `Host` header to an `AppId`. Most-specific
|
||||||
|
/// claim wins: exact > longest wildcard > shorter wildcard. Returns
|
||||||
|
/// `None` when no claim covers `host` (orchestrator should 404).
|
||||||
|
#[must_use]
|
||||||
|
pub fn resolve_app(&self, host: &str) -> Option<AppId> {
|
||||||
|
let host = strip_port(host).to_ascii_lowercase();
|
||||||
|
let guard = self.inner.read().expect("app domain table poisoned");
|
||||||
|
let mut best: Option<(HostSpecificity, AppId)> = None;
|
||||||
|
for claim in guard.iter() {
|
||||||
|
if let Some(()) = host_matches(&claim.pattern, &host) {
|
||||||
|
let s = claim.pattern.specificity();
|
||||||
|
if best.is_none_or(|(prev, _)| s > prev) {
|
||||||
|
best = Some((s, claim.app_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(_, app_id)| app_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn snapshot(&self) -> Vec<CompiledAppDomain> {
|
||||||
|
self.inner
|
||||||
|
.read()
|
||||||
|
.expect("app domain table poisoned")
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_port(host: &str) -> &str {
|
||||||
|
host.split(':').next().unwrap_or(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_matches(pattern: &HostPattern, host: &str) -> Option<()> {
|
||||||
|
match pattern {
|
||||||
|
HostPattern::Any => Some(()),
|
||||||
|
HostPattern::Strict(s) => {
|
||||||
|
if s.eq_ignore_ascii_case(host) {
|
||||||
|
Some(())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HostPattern::Wildcard { suffix, .. } => {
|
||||||
|
let dotted = format!(".{}", suffix.to_ascii_lowercase());
|
||||||
|
host.strip_suffix(&dotted)
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::routing::pattern::parse_app_domain;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn id() -> AppId {
|
||||||
|
AppId::from(Uuid::new_v4())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile(app_id: AppId, raw: &str) -> CompiledAppDomain {
|
||||||
|
let d = parse_app_domain(raw).unwrap();
|
||||||
|
CompiledAppDomain {
|
||||||
|
app_id,
|
||||||
|
pattern: d.pattern,
|
||||||
|
shape_key: d.shape_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_exact_over_wildcard() {
|
||||||
|
let app_a = id();
|
||||||
|
let app_b = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![
|
||||||
|
compile(app_a, "foo.example.com"),
|
||||||
|
compile(app_b, "*.example.com"),
|
||||||
|
]);
|
||||||
|
assert_eq!(table.resolve_app("foo.example.com"), Some(app_a));
|
||||||
|
assert_eq!(table.resolve_app("bar.example.com"), Some(app_b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn longer_wildcard_beats_shorter() {
|
||||||
|
let inner = id();
|
||||||
|
let outer = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![
|
||||||
|
compile(inner, "*.api.example.com"),
|
||||||
|
compile(outer, "*.example.com"),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
table.resolve_app("v1.api.example.com"),
|
||||||
|
Some(inner),
|
||||||
|
"more-specific wildcard should win"
|
||||||
|
);
|
||||||
|
assert_eq!(table.resolve_app("v1.example.com"), Some(outer));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parameterized_resolves_like_wildcard() {
|
||||||
|
let app = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![compile(app, "{tenant}.example.com")]);
|
||||||
|
assert_eq!(table.resolve_app("acme.example.com"), Some(app));
|
||||||
|
assert!(table.resolve_app("example.com").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_none_when_no_claim() {
|
||||||
|
let app = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![compile(app, "foo.example.com")]);
|
||||||
|
assert!(table.resolve_app("nope.com").is_none());
|
||||||
|
assert!(table.resolve_app("").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_port() {
|
||||||
|
let app = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![compile(app, "localhost")]);
|
||||||
|
assert_eq!(table.resolve_app("localhost:18080"), Some(app));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,10 +40,13 @@ pub struct Matched {
|
|||||||
pub script_id: picloud_shared::ScriptId,
|
pub script_id: picloud_shared::ScriptId,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single route ready for matching.
|
/// A single route ready for matching. `app_id` is carried so the
|
||||||
|
/// caller (the orchestrator's `AppRouteTables`) can partition the
|
||||||
|
/// table; the matcher itself doesn't read it.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CompiledRoute {
|
pub struct CompiledRoute {
|
||||||
pub route_id: uuid::Uuid,
|
pub route_id: uuid::Uuid,
|
||||||
|
pub app_id: picloud_shared::AppId,
|
||||||
pub script_id: picloud_shared::ScriptId,
|
pub script_id: picloud_shared::ScriptId,
|
||||||
pub host: HostPattern,
|
pub host: HostPattern,
|
||||||
pub path: PathPattern,
|
pub path: PathPattern,
|
||||||
@@ -298,12 +301,13 @@ fn match_param(segs: &[PathSegment], request_path: &str) -> Option<BTreeMap<Stri
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::super::pattern::parse_path;
|
use super::super::pattern::parse_path;
|
||||||
use super::*;
|
use super::*;
|
||||||
use picloud_shared::{PathKind, ScriptId};
|
use picloud_shared::{AppId, PathKind, ScriptId};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn route(host: HostPattern, path_kind: PathKind, raw: &str) -> CompiledRoute {
|
fn route(host: HostPattern, path_kind: PathKind, raw: &str) -> CompiledRoute {
|
||||||
CompiledRoute {
|
CompiledRoute {
|
||||||
route_id: Uuid::new_v4(),
|
route_id: Uuid::new_v4(),
|
||||||
|
app_id: AppId::new(),
|
||||||
script_id: ScriptId::new(),
|
script_id: ScriptId::new(),
|
||||||
host,
|
host,
|
||||||
path: parse_path(path_kind, raw).unwrap(),
|
path: parse_path(path_kind, raw).unwrap(),
|
||||||
|
|||||||
@@ -17,12 +17,16 @@
|
|||||||
//! * **Host dispatch** — `strict > wildcard > any`; longest matching
|
//! * **Host dispatch** — `strict > wildcard > any`; longest matching
|
||||||
//! wildcard suffix breaks ties between wildcards.
|
//! wildcard suffix breaks ties between wildcards.
|
||||||
|
|
||||||
|
pub mod app_domains;
|
||||||
pub mod conflict;
|
pub mod conflict;
|
||||||
pub mod matcher;
|
pub mod matcher;
|
||||||
pub mod pattern;
|
pub mod pattern;
|
||||||
pub mod table;
|
pub mod table;
|
||||||
|
|
||||||
|
pub use app_domains::{AppDomainTable, CompiledAppDomain};
|
||||||
pub use conflict::{conflicts, ConflictReason};
|
pub use conflict::{conflicts, ConflictReason};
|
||||||
pub use matcher::{MatchResult, Matched};
|
pub use matcher::{MatchResult, Matched};
|
||||||
pub use pattern::{HostPattern, ParseError, PathPattern, PathSegment};
|
pub use pattern::{
|
||||||
|
parse_app_domain, HostPattern, ParseError, ParsedAppDomain, PathPattern, PathSegment,
|
||||||
|
};
|
||||||
pub use table::RouteTable;
|
pub use table::RouteTable;
|
||||||
|
|||||||
@@ -251,6 +251,106 @@ pub fn parse_host(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// App-domain patterns
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
use picloud_shared::DomainShape;
|
||||||
|
|
||||||
|
/// Result of parsing a user-supplied app domain claim. Carries the
|
||||||
|
/// host pattern (used at request time), the shape (used at write time
|
||||||
|
/// for collision checks), and the normalized shape_key.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ParsedAppDomain {
|
||||||
|
pub pattern: HostPattern,
|
||||||
|
pub shape: DomainShape,
|
||||||
|
/// Collision key: `"exact:<host>"` for exact; `"wildcard:<suffix>"`
|
||||||
|
/// for both wildcard AND parameterized — they share a shape per
|
||||||
|
/// blueprint §11.5 ("`{tenant}` has the same shape as `*` for this
|
||||||
|
/// check").
|
||||||
|
pub shape_key: String,
|
||||||
|
/// Captured binding name for parameterized claims, e.g., `Some("tenant")`
|
||||||
|
/// for `{tenant}.example.com`. Currently informational; the binding
|
||||||
|
/// is surfaced into request context in a future iteration.
|
||||||
|
pub binding: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a user-supplied app domain claim. Accepts:
|
||||||
|
/// * `app.example.com` — exact host
|
||||||
|
/// * `*.example.com` — wildcard suffix
|
||||||
|
/// * `{tenant}.example.com` — parameterized; same shape as wildcard
|
||||||
|
///
|
||||||
|
/// Distinct from `parse_host` (which is for route host fields): the
|
||||||
|
/// route parser still rejects `{...}` syntax — see
|
||||||
|
/// `ParseError::ReservedHostBraceSyntax`.
|
||||||
|
pub fn parse_app_domain(raw: &str) -> Result<ParsedAppDomain, ParseError> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(ParseError::EmptyHost);
|
||||||
|
}
|
||||||
|
let lowered = trimmed.to_ascii_lowercase();
|
||||||
|
|
||||||
|
// Wildcard: starts with "*."
|
||||||
|
if let Some(suffix) = lowered.strip_prefix("*.") {
|
||||||
|
if suffix.is_empty() {
|
||||||
|
return Err(ParseError::EmptyWildcardSuffix);
|
||||||
|
}
|
||||||
|
return Ok(ParsedAppDomain {
|
||||||
|
pattern: HostPattern::Wildcard {
|
||||||
|
suffix: suffix.to_string(),
|
||||||
|
capture: None,
|
||||||
|
},
|
||||||
|
shape: DomainShape::Wildcard,
|
||||||
|
shape_key: format!("wildcard:{suffix}"),
|
||||||
|
binding: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameterized: starts with "{name}." where `name` is an ident.
|
||||||
|
if let Some(stripped) = lowered.strip_prefix('{') {
|
||||||
|
let (binding, rest) = stripped
|
||||||
|
.split_once('}')
|
||||||
|
.ok_or(ParseError::ReservedHostBraceSyntax)?;
|
||||||
|
if binding.is_empty()
|
||||||
|
|| !binding
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||||
|
|| !binding.chars().next().unwrap().is_ascii_alphabetic()
|
||||||
|
{
|
||||||
|
return Err(ParseError::InvalidParamName(binding.to_string()));
|
||||||
|
}
|
||||||
|
let suffix = rest
|
||||||
|
.strip_prefix('.')
|
||||||
|
.ok_or(ParseError::ReservedHostBraceSyntax)?;
|
||||||
|
if suffix.is_empty() || suffix.contains('{') || suffix.contains('}') {
|
||||||
|
return Err(ParseError::ReservedHostBraceSyntax);
|
||||||
|
}
|
||||||
|
return Ok(ParsedAppDomain {
|
||||||
|
pattern: HostPattern::Wildcard {
|
||||||
|
suffix: suffix.to_string(),
|
||||||
|
capture: Some(binding.to_string()),
|
||||||
|
},
|
||||||
|
shape: DomainShape::Parameterized,
|
||||||
|
// Same shape_key as the equivalent wildcard — parameter
|
||||||
|
// name is a binding, not a discriminator.
|
||||||
|
shape_key: format!("wildcard:{suffix}"),
|
||||||
|
binding: Some(binding.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything else: exact host. Reject braces anywhere in the body
|
||||||
|
// (they'd be a malformed parameterized form).
|
||||||
|
if lowered.contains('{') || lowered.contains('}') {
|
||||||
|
return Err(ParseError::ReservedHostBraceSyntax);
|
||||||
|
}
|
||||||
|
Ok(ParsedAppDomain {
|
||||||
|
pattern: HostPattern::Strict(lowered.clone()),
|
||||||
|
shape: DomainShape::Exact,
|
||||||
|
shape_key: format!("exact:{lowered}"),
|
||||||
|
binding: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -393,6 +493,49 @@ mod tests {
|
|||||||
assert_eq!(e, ParseError::ReservedHostBraceSyntax);
|
assert_eq!(e, ParseError::ReservedHostBraceSyntax);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_app_domain_exact() {
|
||||||
|
let d = parse_app_domain("App.Example.COM").unwrap();
|
||||||
|
assert_eq!(d.shape, DomainShape::Exact);
|
||||||
|
assert_eq!(d.shape_key, "exact:app.example.com");
|
||||||
|
assert_eq!(d.pattern, HostPattern::Strict("app.example.com".into()));
|
||||||
|
assert!(d.binding.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_app_domain_wildcard_and_parameterized_share_shape_key() {
|
||||||
|
let w = parse_app_domain("*.example.com").unwrap();
|
||||||
|
let p = parse_app_domain("{tenant}.example.com").unwrap();
|
||||||
|
assert_eq!(w.shape, DomainShape::Wildcard);
|
||||||
|
assert_eq!(p.shape, DomainShape::Parameterized);
|
||||||
|
// Same shape_key — they collide at claim time (blueprint §11.5).
|
||||||
|
assert_eq!(w.shape_key, "wildcard:example.com");
|
||||||
|
assert_eq!(p.shape_key, "wildcard:example.com");
|
||||||
|
assert_eq!(p.binding.as_deref(), Some("tenant"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_app_domain_rejects_garbage() {
|
||||||
|
assert!(matches!(parse_app_domain(""), Err(ParseError::EmptyHost)));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_app_domain("*."),
|
||||||
|
Err(ParseError::EmptyWildcardSuffix)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_app_domain("{}.example.com"),
|
||||||
|
Err(ParseError::InvalidParamName(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_app_domain("{1tenant}.example.com"),
|
||||||
|
Err(ParseError::InvalidParamName(_))
|
||||||
|
));
|
||||||
|
// Mid-host braces — disallowed.
|
||||||
|
assert!(matches!(
|
||||||
|
parse_app_domain("foo.{tenant}.example.com"),
|
||||||
|
Err(ParseError::ReservedHostBraceSyntax)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn leading_literal_count_works() {
|
fn leading_literal_count_works() {
|
||||||
let exact = parse_path(PathKind::Exact, "/foo/users").unwrap();
|
let exact = parse_path(PathKind::Exact, "/foo/users").unwrap();
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
//! In-memory snapshot of compiled routes, shared by manager (writes)
|
//! In-memory snapshot of compiled routes, partitioned by `app_id`.
|
||||||
//! and orchestrator (reads).
|
|
||||||
//!
|
//!
|
||||||
//! Holds an `arc-swap`-style lock-free hand-off so the dispatcher can
|
//! The orchestrator looks up the app's slice by id after `AppDomainTable`
|
||||||
//! read without contending against the writer; in MVP-single-process
|
//! has resolved Host → app_id, then runs the existing matcher on that
|
||||||
//! we just use `RwLock` and accept the cheap contention.
|
//! slice. The matcher is unchanged; this type is just a per-app bucket.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
use picloud_shared::AppId;
|
||||||
|
|
||||||
use super::matcher::{r#match, CompiledRoute, MatchResult};
|
use super::matcher::{r#match, CompiledRoute, MatchResult};
|
||||||
|
|
||||||
|
/// Per-app compiled-route tables. Single MVP-mode writer (the manager,
|
||||||
|
/// via `replace_all`); contention against readers is minimal so a plain
|
||||||
|
/// `RwLock` is fine.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct RouteTable {
|
pub struct RouteTable {
|
||||||
inner: RwLock<Vec<CompiledRoute>>,
|
inner: RwLock<HashMap<AppId, Vec<CompiledRoute>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RouteTable {
|
impl RouteTable {
|
||||||
@@ -20,24 +25,54 @@ impl RouteTable {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the whole table atomically. The manager calls this after
|
/// Replace every per-app slice atomically. The manager calls this
|
||||||
/// each successful route CRUD operation (by re-reading from DB).
|
/// after each successful route CRUD operation; in cluster mode the
|
||||||
pub fn replace(&self, routes: Vec<CompiledRoute>) {
|
/// orchestrator's HTTP-fed receiver will too.
|
||||||
|
pub fn replace_all(&self, routes: Vec<CompiledRoute>) {
|
||||||
|
let mut by_app: HashMap<AppId, Vec<CompiledRoute>> = HashMap::new();
|
||||||
|
for r in routes {
|
||||||
|
by_app.entry(r.app_id).or_default().push(r);
|
||||||
|
}
|
||||||
let mut guard = self.inner.write().expect("route table poisoned");
|
let mut guard = self.inner.write().expect("route table poisoned");
|
||||||
*guard = routes;
|
*guard = by_app;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dispatch a request to a matching route, or `None`.
|
/// Dispatch a request to a matching route within `app_id`, or
|
||||||
|
/// `None`. Returns `None` when the app has no routes at all.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn match_request(&self, host: &str, method: &str, path: &str) -> Option<MatchResult> {
|
pub fn match_request_for_app(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
host: &str,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
) -> Option<MatchResult> {
|
||||||
let guard = self.inner.read().expect("route table poisoned");
|
let guard = self.inner.read().expect("route table poisoned");
|
||||||
r#match(guard.iter(), host, method, path)
|
let slice = guard.get(&app_id)?;
|
||||||
|
r#match(slice.iter(), host, method, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a clone of the currently compiled routes; intended for
|
/// Returns a clone of the currently compiled routes for `app_id`;
|
||||||
/// the dashboard's "list routes" admin endpoint.
|
/// intended for admin endpoints like "list this app's routes".
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn snapshot(&self) -> Vec<CompiledRoute> {
|
pub fn snapshot_for_app(&self, app_id: AppId) -> Vec<CompiledRoute> {
|
||||||
self.inner.read().expect("route table poisoned").clone()
|
self.inner
|
||||||
|
.read()
|
||||||
|
.expect("route table poisoned")
|
||||||
|
.get(&app_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All compiled routes across all apps. Used by tests and the
|
||||||
|
/// global admin "every route on this install" view.
|
||||||
|
#[must_use]
|
||||||
|
pub fn snapshot_all(&self) -> Vec<CompiledRoute> {
|
||||||
|
self.inner
|
||||||
|
.read()
|
||||||
|
.expect("route table poisoned")
|
||||||
|
.values()
|
||||||
|
.flat_map(|v| v.iter().cloned())
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ use axum::middleware::from_fn_with_state;
|
|||||||
use axum::{routing::get, Json, Router};
|
use axum::{routing::get, Json, Router};
|
||||||
use picloud_executor_core::{Engine, Limits};
|
use picloud_executor_core::{Engine, Limits};
|
||||||
use picloud_manager_core::{
|
use picloud_manager_core::{
|
||||||
admin_router, admins_router, auth_router, compile_routes, migrations, require_admin,
|
admin_router, admins_router, apps_api, apps_router, auth_router, compile_routes, migrations,
|
||||||
route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
|
require_admin, route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository,
|
||||||
AuthState, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
AdminsState, AppDomainRepository, AppRepository, AppsState, AuthState,
|
||||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresAppDomainRepository,
|
||||||
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink,
|
||||||
|
PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState,
|
||||||
|
RouteRepository, SandboxCeiling,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::routing::RouteTable;
|
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||||
use picloud_orchestrator_core::{
|
use picloud_orchestrator_core::{
|
||||||
data_plane_router, user_routes_router, DataPlaneState, LocalExecutorClient,
|
data_plane_router, user_routes_router, DataPlaneState, LocalExecutorClient,
|
||||||
};
|
};
|
||||||
@@ -80,14 +82,34 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
||||||
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
|
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
|
||||||
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
|
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
|
||||||
let route_repo = Arc::new(PostgresRouteRepository::new(pool));
|
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
||||||
|
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
||||||
|
let domains_repo: Arc<dyn AppDomainRepository> =
|
||||||
|
Arc::new(PostgresAppDomainRepository::new(pool));
|
||||||
|
|
||||||
// Compile the routes table once at startup; admin writes refresh it.
|
// Compile the routes table once at startup; admin writes refresh it.
|
||||||
let route_table = Arc::new(RouteTable::new());
|
let route_table = Arc::new(RouteTable::new());
|
||||||
let initial = route_repo.list_all().await?;
|
let initial = route_repo.list_all().await?;
|
||||||
let compiled = compile_routes(&initial)
|
let compiled = compile_routes(&initial)
|
||||||
.map_err(|e| anyhow::anyhow!("failed to compile stored routes: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("failed to compile stored routes: {e}"))?;
|
||||||
route_table.replace(compiled);
|
route_table.replace_all(compiled);
|
||||||
|
|
||||||
|
// Same shape for app domains (Host → app_id cache).
|
||||||
|
let app_domain_table = Arc::new(AppDomainTable::new());
|
||||||
|
let initial_domains = domains_repo.list_all().await?;
|
||||||
|
let compiled_domains: Vec<_> = initial_domains
|
||||||
|
.iter()
|
||||||
|
.filter_map(|d| {
|
||||||
|
picloud_orchestrator_core::routing::parse_app_domain(&d.pattern)
|
||||||
|
.ok()
|
||||||
|
.map(|p| picloud_orchestrator_core::routing::CompiledAppDomain {
|
||||||
|
app_id: d.app_id,
|
||||||
|
pattern: p.pattern,
|
||||||
|
shape_key: p.shape_key,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
app_domain_table.replace(compiled_domains);
|
||||||
|
|
||||||
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
|
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
|
||||||
script_repo.clone(),
|
script_repo.clone(),
|
||||||
@@ -95,21 +117,31 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
let executor = Arc::new(LocalExecutorClient::new(engine.clone()));
|
let executor = Arc::new(LocalExecutorClient::new(engine.clone()));
|
||||||
|
|
||||||
let admin = AdminState {
|
let admin = AdminState {
|
||||||
repo: Arc::new(PostgresScriptRepoHandle(script_repo)),
|
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
|
||||||
logs: log_repo,
|
logs: log_repo,
|
||||||
|
apps: apps_repo.clone(),
|
||||||
validator: engine as Arc<dyn ScriptValidator>,
|
validator: engine as Arc<dyn ScriptValidator>,
|
||||||
sandbox_ceiling: SandboxCeiling::from_env(),
|
sandbox_ceiling: SandboxCeiling::from_env(),
|
||||||
};
|
};
|
||||||
let route_admin = RouteAdminState {
|
let route_admin = RouteAdminState {
|
||||||
routes: route_repo,
|
routes: route_repo.clone(),
|
||||||
|
scripts: Arc::new(PostgresScriptRepoHandle(script_repo)),
|
||||||
|
domains: domains_repo.clone(),
|
||||||
table: route_table.clone(),
|
table: route_table.clone(),
|
||||||
};
|
};
|
||||||
let data_plane = DataPlaneState {
|
let data_plane = DataPlaneState {
|
||||||
executor,
|
executor,
|
||||||
resolver,
|
resolver,
|
||||||
log_sink,
|
log_sink,
|
||||||
|
app_domains: app_domain_table.clone(),
|
||||||
routes: route_table,
|
routes: route_table,
|
||||||
};
|
};
|
||||||
|
let apps_state = AppsState {
|
||||||
|
apps: apps_repo,
|
||||||
|
domains: domains_repo,
|
||||||
|
routes: route_repo,
|
||||||
|
domain_table: app_domain_table,
|
||||||
|
};
|
||||||
|
|
||||||
let auth_state = AuthState {
|
let auth_state = AuthState {
|
||||||
users: auth.users.clone(),
|
users: auth.users.clone(),
|
||||||
@@ -129,8 +161,13 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
.merge(admin_router(admin))
|
.merge(admin_router(admin))
|
||||||
.merge(route_admin_router(route_admin))
|
.merge(route_admin_router(route_admin))
|
||||||
.merge(admins_router(admins_state))
|
.merge(admins_router(admins_state))
|
||||||
|
.merge(apps_router(apps_state))
|
||||||
.layer(from_fn_with_state(auth_state.clone(), require_admin));
|
.layer(from_fn_with_state(auth_state.clone(), require_admin));
|
||||||
|
|
||||||
|
// Silence "unused import" lint on `apps_api` — we re-export via the
|
||||||
|
// facade above; the bare module path is retained so it's discoverable.
|
||||||
|
let _ = apps_api::AppsState::clone;
|
||||||
|
|
||||||
let api_v1 = Router::new()
|
let api_v1 = Router::new()
|
||||||
.nest("/admin", auth_router(auth_state))
|
.nest("/admin", auth_router(auth_state))
|
||||||
.nest("/admin", guarded_admin)
|
.nest("/admin", guarded_admin)
|
||||||
@@ -201,6 +238,12 @@ impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
|
|||||||
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||||
self.0.list().await
|
self.0.list().await
|
||||||
}
|
}
|
||||||
|
async fn list_for_app(
|
||||||
|
&self,
|
||||||
|
app_id: picloud_shared::AppId,
|
||||||
|
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||||
|
self.0.list_for_app(app_id).await
|
||||||
|
}
|
||||||
async fn create(
|
async fn create(
|
||||||
&self,
|
&self,
|
||||||
input: picloud_manager_core::NewScript,
|
input: picloud_manager_core::NewScript,
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ use std::time::Duration;
|
|||||||
use picloud::{build_app, init_db, AuthDeps};
|
use picloud::{build_app, init_db, AuthDeps};
|
||||||
use picloud_manager_core::{
|
use picloud_manager_core::{
|
||||||
auth::{hash_password, validate_password_hash},
|
auth::{hash_password, validate_password_hash},
|
||||||
bootstrap_first_admin, migrations, AdminSessionRepository, AdminUserRepository,
|
bootstrap_first_admin, migrations, seed_hello_world_if_fresh, AdminSessionRepository,
|
||||||
|
AdminUserRepository, HelloWorldOutcome, PostgresAppRepository, PostgresRouteRepository,
|
||||||
|
PostgresScriptRepository,
|
||||||
};
|
};
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
@@ -44,6 +46,23 @@ async fn run_server() -> anyhow::Result<()> {
|
|||||||
let auth = AuthDeps::from_pool(pool.clone());
|
let auth = AuthDeps::from_pool(pool.clone());
|
||||||
bootstrap_first_admin(&*auth.users).await?;
|
bootstrap_first_admin(&*auth.users).await?;
|
||||||
|
|
||||||
|
// Seed Hello World into the default app when this is a fresh
|
||||||
|
// install (no scripts and no routes). Idempotent on upgrades.
|
||||||
|
let apps = Arc::new(PostgresAppRepository::new(pool.clone()));
|
||||||
|
let scripts = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
||||||
|
let routes = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
||||||
|
match seed_hello_world_if_fresh(apps, scripts, routes).await {
|
||||||
|
Ok(HelloWorldOutcome::Seeded) => {
|
||||||
|
tracing::info!("hello-world seed inserted into the default app");
|
||||||
|
}
|
||||||
|
Ok(HelloWorldOutcome::SkippedExisting) => {
|
||||||
|
tracing::debug!("hello-world seed skipped (default app already populated)");
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(?err, "hello-world seed failed (continuing startup)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Background session-prune sweep. Cheap; keeps the table from
|
// Background session-prune sweep. Cheap; keeps the table from
|
||||||
// growing unbounded. Expired rows are also rejected at lookup time,
|
// growing unbounded. Expired rows are also rejected at lookup time,
|
||||||
// so a delayed sweep can't extend session lifetimes.
|
// so a delayed sweep can't extend session lifetimes.
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ use sqlx::PgPool;
|
|||||||
/// the bearer token into the TestServer as a default header so every
|
/// the bearer token into the TestServer as a default header so every
|
||||||
/// request in the test passes the `require_admin` middleware.
|
/// request in the test passes the `require_admin` middleware.
|
||||||
async fn server(pool: PgPool) -> TestServer {
|
async fn server(pool: PgPool) -> TestServer {
|
||||||
|
let (server, _app_id) = server_with_app(pool).await;
|
||||||
|
server
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `server`, but also returns the default app's id — needed by
|
||||||
|
/// any test that creates scripts (every script now requires `app_id`).
|
||||||
|
async fn server_with_app(pool: PgPool) -> (TestServer, String) {
|
||||||
use picloud_manager_core::auth::hash_password;
|
use picloud_manager_core::auth::hash_password;
|
||||||
|
|
||||||
let auth = picloud::AuthDeps::from_pool(pool.clone());
|
let auth = picloud::AuthDeps::from_pool(pool.clone());
|
||||||
@@ -45,7 +52,32 @@ async fn server(pool: PgPool) -> TestServer {
|
|||||||
.expect("login should return token")
|
.expect("login should return token")
|
||||||
.to_string();
|
.to_string();
|
||||||
server.add_header("authorization", format!("Bearer {token}"));
|
server.add_header("authorization", format!("Bearer {token}"));
|
||||||
server
|
// Note: user-route dispatch needs an explicit `host: <claim>` header
|
||||||
|
// on each request (the axum_test client doesn't default to a real
|
||||||
|
// host). The default app claims `localhost`; user-route tests below
|
||||||
|
// add the header per request via `.add_header("host", "localhost")`
|
||||||
|
// so per-test overrides for other apps cleanly replace it.
|
||||||
|
|
||||||
|
// The 0005 migration unconditionally inserts a `default` app; fetch
|
||||||
|
// its id so tests can attach scripts to it without re-running the
|
||||||
|
// Rust-side hello-world seed (which only fires from main.rs).
|
||||||
|
// The get-app handler returns `{ ...App, redirect_to?: ... }` —
|
||||||
|
// the app fields are flattened at the response root.
|
||||||
|
let app: Value = server.get("/api/v1/admin/apps/default").await.json();
|
||||||
|
let app_id = app["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_else(|| panic!("default app id missing from response: {app}"))
|
||||||
|
.to_string();
|
||||||
|
(server, app_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge `{ "app_id": <default> }` into a create-script body. Saves
|
||||||
|
/// repeating the same field in 25+ tests.
|
||||||
|
fn with_app(app_id: &str, mut body: Value) -> Value {
|
||||||
|
body.as_object_mut()
|
||||||
|
.expect("script body must be a JSON object")
|
||||||
|
.insert("app_id".into(), Value::String(app_id.to_string()));
|
||||||
|
body
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -67,30 +99,37 @@ async fn healthz_responds_ok(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn create_script_returns_201_with_full_record(pool: PgPool) {
|
async fn create_script_returns_201_with_full_record(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "echo",
|
"name": "echo",
|
||||||
"description": "test",
|
"description": "test",
|
||||||
"source": "#{ statusCode: 200, body: 42 }",
|
"source": "#{ statusCode: 200, body: 42 }",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status(axum::http::StatusCode::CREATED);
|
r.assert_status(axum::http::StatusCode::CREATED);
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["name"], "echo");
|
assert_eq!(body["name"], "echo");
|
||||||
assert_eq!(body["version"], 1);
|
assert_eq!(body["version"], 1);
|
||||||
assert_eq!(body["timeout_seconds"], 30);
|
assert_eq!(body["timeout_seconds"], 30);
|
||||||
|
assert_eq!(body["app_id"], app_id);
|
||||||
assert!(body["id"].as_str().is_some());
|
assert!(body["id"].as_str().is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
|
async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
|
||||||
let r = server(pool)
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
.await
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "broken", "source": "@@@ not rhai @@@" }))
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({ "name": "broken", "source": "@@@ not rhai @@@" }),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
@@ -100,14 +139,14 @@ async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn duplicate_name_returns_409(pool: PgPool) {
|
async fn duplicate_name_returns_409(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
s.post("/api/v1/admin/scripts")
|
s.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "dup", "source": "42" }))
|
.json(&with_app(&app_id, json!({ "name": "dup", "source": "42" })))
|
||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "dup", "source": "43" }))
|
.json(&with_app(&app_id, json!({ "name": "dup", "source": "43" })))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status(axum::http::StatusCode::CONFLICT);
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
}
|
}
|
||||||
@@ -115,10 +154,10 @@ async fn duplicate_name_returns_409(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn list_returns_all_scripts(pool: PgPool) {
|
async fn list_returns_all_scripts(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
for name in ["alpha", "bravo", "charlie"] {
|
for name in ["alpha", "bravo", "charlie"] {
|
||||||
s.post("/api/v1/admin/scripts")
|
s.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": name, "source": "1" }))
|
.json(&with_app(&app_id, json!({ "name": name, "source": "1" })))
|
||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
}
|
}
|
||||||
@@ -133,10 +172,10 @@ async fn list_returns_all_scripts(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn update_bumps_version_and_persists_changes(pool: PgPool) {
|
async fn update_bumps_version_and_persists_changes(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "u", "source": "1" }))
|
.json(&with_app(&app_id, json!({ "name": "u", "source": "1" })))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -155,10 +194,10 @@ async fn update_bumps_version_and_persists_changes(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn update_with_invalid_source_returns_422(pool: PgPool) {
|
async fn update_with_invalid_source_returns_422(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "u", "source": "1" }))
|
.json(&with_app(&app_id, json!({ "name": "u", "source": "1" })))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -173,10 +212,10 @@ async fn update_with_invalid_source_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn delete_then_get_returns_404(pool: PgPool) {
|
async fn delete_then_get_returns_404(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "d", "source": "1" }))
|
.json(&with_app(&app_id, json!({ "name": "d", "source": "1" })))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -207,13 +246,16 @@ async fn get_nonexistent_returns_404(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn execute_echoes_body_back(pool: PgPool) {
|
async fn execute_echoes_body_back(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "echo",
|
"name": "echo",
|
||||||
"source": "#{ statusCode: 200, body: ctx.request.body }",
|
"source": "#{ statusCode: 200, body: ctx.request.body }",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -230,13 +272,16 @@ async fn execute_echoes_body_back(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn execute_passes_through_status_and_headers(pool: PgPool) {
|
async fn execute_passes_through_status_and_headers(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "header-test",
|
"name": "header-test",
|
||||||
"source": "#{ statusCode: 201, headers: #{ \"x-tag\": \"on\" }, body: 1 }",
|
"source": "#{ statusCode: 201, headers: #{ \"x-tag\": \"on\" }, body: 1 }",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -263,13 +308,16 @@ async fn execute_nonexistent_returns_404(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn execution_logs_capture_invocations(pool: PgPool) {
|
async fn execution_logs_capture_invocations(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "logger",
|
"name": "logger",
|
||||||
"source": "log::info(\"called\", #{ marker: 7 }); #{ statusCode: 200, body: \"done\" }",
|
"source": "log::info(\"called\", #{ marker: 7 }); #{ statusCode: 200, body: \"done\" }",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -320,10 +368,13 @@ async fn execution_logs_capture_invocations(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
|
async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "no-sandbox", "source": "1" }))
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({ "name": "no-sandbox", "source": "1" }),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
assert_eq!(created["sandbox"], json!({}));
|
assert_eq!(created["sandbox"], json!({}));
|
||||||
@@ -332,14 +383,17 @@ async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
|
async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "tight",
|
"name": "tight",
|
||||||
"source": "1",
|
"source": "1",
|
||||||
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -359,14 +413,17 @@ async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
|
|||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
|
async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
|
||||||
// Default conservative ceiling caps max_operations at 10_000_000.
|
// Default conservative ceiling caps max_operations at 10_000_000.
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "too-loose",
|
"name": "too-loose",
|
||||||
"source": "1",
|
"source": "1",
|
||||||
"sandbox": { "max_operations": 100_000_000 }
|
"sandbox": { "max_operations": 100_000_000 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
@@ -376,14 +433,17 @@ async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn sandbox_unknown_field_returns_422(pool: PgPool) {
|
async fn sandbox_unknown_field_returns_422(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "typo",
|
"name": "typo",
|
||||||
"source": "1",
|
"source": "1",
|
||||||
"sandbox": { "max_operashuns": 500 }
|
"sandbox": { "max_operashuns": 500 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
// serde's deny_unknown_fields causes axum to reject with 422 or
|
// serde's deny_unknown_fields causes axum to reject with 422 or
|
||||||
// 400 depending on extractor; the routing is irrelevant here, just
|
// 400 depending on extractor; the routing is irrelevant here, just
|
||||||
@@ -397,15 +457,18 @@ async fn sandbox_unknown_field_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
|
async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
// Tight max_operations on a loop the default would happily run.
|
// Tight max_operations on a loop the default would happily run.
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "tight-exec",
|
"name": "tight-exec",
|
||||||
"source": "let n = 0; for i in 0..10000 { n += 1; } n",
|
"source": "let n = 0; for i in 0..10000 { n += 1; } n",
|
||||||
"sandbox": { "max_operations": 500 }
|
"sandbox": { "max_operations": 500 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -422,14 +485,17 @@ async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn update_replaces_sandbox_wholesale(pool: PgPool) {
|
async fn update_replaces_sandbox_wholesale(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "patch-target",
|
"name": "patch-target",
|
||||||
"source": "1",
|
"source": "1",
|
||||||
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -455,10 +521,10 @@ async fn update_replaces_sandbox_wholesale(pool: PgPool) {
|
|||||||
// Custom routing
|
// Custom routing
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
async fn create_basic_script(s: &TestServer, name: &str, source: &str) -> String {
|
async fn create_basic_script(s: &TestServer, app_id: &str, name: &str, source: &str) -> String {
|
||||||
let v: Value = s
|
let v: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": name, "source": source }))
|
.json(&with_app(app_id, json!({ "name": name, "source": source })))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
v["id"].as_str().unwrap().to_string()
|
v["id"].as_str().unwrap().to_string()
|
||||||
@@ -467,9 +533,10 @@ async fn create_basic_script(s: &TestServer, name: &str, source: &str) -> String
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_exact_dispatches_to_script(pool: PgPool) {
|
async fn route_exact_dispatches_to_script(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(
|
let id = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"greet",
|
"greet",
|
||||||
"#{ statusCode: 200, body: #{ msg: \"hi\", path: ctx.request.path } }",
|
"#{ statusCode: 200, body: #{ msg: \"hi\", path: ctx.request.path } }",
|
||||||
)
|
)
|
||||||
@@ -483,7 +550,7 @@ async fn route_exact_dispatches_to_script(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
let r = s.get("/greet").await;
|
let r = s.get("/greet").add_header("host", "localhost").await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["msg"], "hi");
|
assert_eq!(body["msg"], "hi");
|
||||||
@@ -493,9 +560,10 @@ async fn route_exact_dispatches_to_script(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_param_captures_path_vars(pool: PgPool) {
|
async fn route_param_captures_path_vars(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(
|
let id = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"greet-name",
|
"greet-name",
|
||||||
"#{ statusCode: 200, body: #{ name: ctx.request.params.name } }",
|
"#{ statusCode: 200, body: #{ name: ctx.request.params.name } }",
|
||||||
)
|
)
|
||||||
@@ -509,7 +577,7 @@ async fn route_param_captures_path_vars(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
let r = s.get("/greet/alice").await;
|
let r = s.get("/greet/alice").add_header("host", "localhost").await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["name"], "alice");
|
assert_eq!(body["name"], "alice");
|
||||||
@@ -518,9 +586,10 @@ async fn route_param_captures_path_vars(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_prefix_captures_rest(pool: PgPool) {
|
async fn route_prefix_captures_rest(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(
|
let id = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"echo-prefix",
|
"echo-prefix",
|
||||||
"#{ statusCode: 200, body: #{ rest: ctx.request.rest } }",
|
"#{ statusCode: 200, body: #{ rest: ctx.request.rest } }",
|
||||||
)
|
)
|
||||||
@@ -534,19 +603,28 @@ async fn route_prefix_captures_rest(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
let r = s.get("/echo/foo/bar").await;
|
let r = s.get("/echo/foo/bar").add_header("host", "localhost").await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["rest"], "foo/bar");
|
assert_eq!(body["rest"], "foo/bar");
|
||||||
|
|
||||||
s.get("/echo").await.assert_status_not_found();
|
s.get("/echo")
|
||||||
|
.add_header("host", "localhost")
|
||||||
|
.await
|
||||||
|
.assert_status_not_found();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_query_string_exposed_to_script(pool: PgPool) {
|
async fn route_query_string_exposed_to_script(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "qs", "#{ statusCode: 200, body: ctx.request.query }").await;
|
let id = create_basic_script(
|
||||||
|
&s,
|
||||||
|
&app_id,
|
||||||
|
"qs",
|
||||||
|
"#{ statusCode: 200, body: ctx.request.query }",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"host_kind": "any",
|
"host_kind": "any",
|
||||||
@@ -556,7 +634,7 @@ async fn route_query_string_exposed_to_script(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
let r = s.get("/qs?a=1&b=two").await;
|
let r = s.get("/qs?a=1&b=two").add_header("host", "localhost").await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body, json!({ "a": "1", "b": "two" }));
|
assert_eq!(body, json!({ "a": "1", "b": "two" }));
|
||||||
@@ -565,8 +643,8 @@ async fn route_query_string_exposed_to_script(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_invalid_pattern_returns_422(pool: PgPool) {
|
async fn route_invalid_pattern_returns_422(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "x", "1").await;
|
let id = create_basic_script(&s, &app_id, "x", "1").await;
|
||||||
let r = s
|
let r = s
|
||||||
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
@@ -581,8 +659,8 @@ async fn route_invalid_pattern_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_conflict_returns_409(pool: PgPool) {
|
async fn route_conflict_returns_409(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "x", "1").await;
|
let id = create_basic_script(&s, &app_id, "x", "1").await;
|
||||||
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"host_kind": "any",
|
"host_kind": "any",
|
||||||
@@ -608,8 +686,8 @@ async fn route_conflict_returns_409(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_reserved_path_returns_422(pool: PgPool) {
|
async fn route_reserved_path_returns_422(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "x", "1").await;
|
let id = create_basic_script(&s, &app_id, "x", "1").await;
|
||||||
let r = s
|
let r = s
|
||||||
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
@@ -624,8 +702,8 @@ async fn route_reserved_path_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_match_preview_endpoint(pool: PgPool) {
|
async fn route_match_preview_endpoint(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "g", "1").await;
|
let id = create_basic_script(&s, &app_id, "g", "1").await;
|
||||||
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"host_kind": "any",
|
"host_kind": "any",
|
||||||
@@ -637,7 +715,11 @@ async fn route_match_preview_endpoint(pool: PgPool) {
|
|||||||
|
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/routes:match")
|
.post("/api/v1/admin/routes:match")
|
||||||
.json(&json!({ "url": "http://localhost:8000/greet/alice", "method": "GET" }))
|
.json(&json!({
|
||||||
|
"app_id": app_id,
|
||||||
|
"url": "http://localhost:8000/greet/alice",
|
||||||
|
"method": "GET"
|
||||||
|
}))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
@@ -648,8 +730,8 @@ async fn route_match_preview_endpoint(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_delete_removes_dispatch(pool: PgPool) {
|
async fn route_delete_removes_dispatch(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "g", "#{ statusCode: 200, body: 1 }").await;
|
let id = create_basic_script(&s, &app_id, "g", "#{ statusCode: 200, body: 1 }").await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
@@ -661,27 +743,35 @@ async fn route_delete_removes_dispatch(pool: PgPool) {
|
|||||||
.json();
|
.json();
|
||||||
let route_id = created["id"].as_str().unwrap();
|
let route_id = created["id"].as_str().unwrap();
|
||||||
|
|
||||||
s.get("/g").await.assert_status_ok();
|
s.get("/g")
|
||||||
|
.add_header("host", "localhost")
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
s.delete(&format!("/api/v1/admin/routes/{route_id}"))
|
s.delete(&format!("/api/v1/admin/routes/{route_id}"))
|
||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::NO_CONTENT);
|
.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
s.get("/g").await.assert_status_not_found();
|
s.get("/g")
|
||||||
|
.add_header("host", "localhost")
|
||||||
|
.await
|
||||||
|
.assert_status_not_found();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id_p = create_basic_script(
|
let id_p = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"by-param",
|
"by-param",
|
||||||
"#{ statusCode: 200, body: #{ tag: \"param\" } }",
|
"#{ statusCode: 200, body: #{ tag: \"param\" } }",
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let id_pr = create_basic_script(
|
let id_pr = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"by-prefix",
|
"by-prefix",
|
||||||
"#{ statusCode: 200, body: #{ tag: \"prefix\" } }",
|
"#{ statusCode: 200, body: #{ tag: \"prefix\" } }",
|
||||||
)
|
)
|
||||||
@@ -704,12 +794,12 @@ async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
|||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
// Single segment under /foo/ — both match; param wins by spec.
|
// Single segment under /foo/ — both match; param wins by spec.
|
||||||
let r = s.get("/foo/x").await;
|
let r = s.get("/foo/x").add_header("host", "localhost").await;
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["tag"], "param");
|
assert_eq!(body["tag"], "param");
|
||||||
|
|
||||||
// Two segments — only prefix matches.
|
// Two segments — only prefix matches.
|
||||||
let r2 = s.get("/foo/x/y").await;
|
let r2 = s.get("/foo/x/y").add_header("host", "localhost").await;
|
||||||
let body2: Value = r2.json();
|
let body2: Value = r2.json();
|
||||||
assert_eq!(body2["tag"], "prefix");
|
assert_eq!(body2["tag"], "prefix");
|
||||||
}
|
}
|
||||||
@@ -718,7 +808,7 @@ async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
|||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn root_returns_404_when_no_route(pool: PgPool) {
|
async fn root_returns_404_when_no_route(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let s = server(pool).await;
|
||||||
let r = s.get("/").await;
|
let r = s.get("/").add_header("host", "localhost").await;
|
||||||
r.assert_status_not_found();
|
r.assert_status_not_found();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,22 +821,325 @@ async fn version_includes_public_base_url(pool: PgPool) {
|
|||||||
let v: Value = r.json();
|
let v: Value = r.json();
|
||||||
assert!(v["public_base_url"].is_string());
|
assert!(v["public_base_url"].is_string());
|
||||||
assert_eq!(v["api"], 1);
|
assert_eq!(v["api"], 1);
|
||||||
assert_eq!(v["schema"], 4);
|
assert_eq!(v["schema"], 5);
|
||||||
assert_eq!(v["sdk"], "1.1");
|
assert_eq!(v["sdk"], "1.1");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// App scoping (Phase 3b)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn default_app_is_seeded_by_migration(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let r = s.get("/api/v1/admin/apps").await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
let apps: Vec<Value> = r.json();
|
||||||
|
let default = apps
|
||||||
|
.iter()
|
||||||
|
.find(|a| a["slug"] == "default")
|
||||||
|
.expect("default app must exist");
|
||||||
|
assert_eq!(default["name"], "Default");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn cross_app_isolation_at_dispatch(pool: PgPool) {
|
||||||
|
let (s, default_id) = server_with_app(pool).await;
|
||||||
|
|
||||||
|
// Two apps each create a script with the same name (per-app
|
||||||
|
// uniqueness — would have collided pre-3b).
|
||||||
|
let app_b: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "tenant-b", "name": "Tenant B" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let b_id = app_b["id"].as_str().unwrap();
|
||||||
|
s.post(&format!("/api/v1/admin/apps/{b_id}/domains"))
|
||||||
|
.json(&json!({ "pattern": "b.localhost" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
let id_default: String = s
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
&default_id,
|
||||||
|
json!({
|
||||||
|
"name": "echo",
|
||||||
|
"source": "#{ statusCode: 200, body: #{ from: \"default\" } }"
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.json::<Value>()["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
let id_b: String = s
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
b_id,
|
||||||
|
json!({
|
||||||
|
"name": "echo",
|
||||||
|
"source": "#{ statusCode: 200, body: #{ from: \"b\" } }"
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.json::<Value>()["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
s.post(&format!("/api/v1/admin/scripts/{id_default}/routes"))
|
||||||
|
.json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/echo" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
s.post(&format!("/api/v1/admin/scripts/{id_b}/routes"))
|
||||||
|
.json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/echo" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
// Same path, different host — routes land in different apps.
|
||||||
|
let from_default: Value = s.get("/echo").add_header("host", "localhost").await.json();
|
||||||
|
assert_eq!(from_default["from"], "default");
|
||||||
|
|
||||||
|
let from_b: Value = s
|
||||||
|
.get("/echo")
|
||||||
|
.add_header("host", "b.localhost")
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
assert_eq!(from_b["from"], "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn unknown_host_returns_404(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let r = s.get("/whatever").add_header("host", "nope.invalid").await;
|
||||||
|
r.assert_status_not_found();
|
||||||
|
let body: Value = r.json();
|
||||||
|
assert!(body["error"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("no app claims host"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn execute_by_id_works_without_host_claim(pool: PgPool) {
|
||||||
|
// The /api/v1/execute/{id} bypass is the implicit __internal__
|
||||||
|
// claim of every app — it MUST keep working for an app with zero
|
||||||
|
// public domain claims.
|
||||||
|
let (s, _) = server_with_app(pool).await;
|
||||||
|
let app: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "internal-only", "name": "Internal Only" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let app_id = app["id"].as_str().unwrap();
|
||||||
|
let script: Value = s
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
app_id,
|
||||||
|
json!({ "name": "x", "source": "#{ statusCode: 200, body: \"ok\" }" }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let id = script["id"].as_str().unwrap();
|
||||||
|
let r = s
|
||||||
|
.post(&format!("/api/v1/execute/{id}"))
|
||||||
|
.json(&json!({}))
|
||||||
|
.await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn duplicate_slug_creates_a_409(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
s.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "alpha", "name": "First" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let r = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "alpha", "name": "Second" }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn reserved_slug_rejected(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
for bad in ["new", "api", "admin", "login"] {
|
||||||
|
let r = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": bad, "name": "x" }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn slug_rename_keeps_old_as_redirect(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let app: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "old-slug", "name": "x" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let id = app["id"].as_str().unwrap();
|
||||||
|
s.patch(&format!("/api/v1/admin/apps/{id}"))
|
||||||
|
.json(&json!({ "slug": "new-slug" }))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
let resp: Value = s.get("/api/v1/admin/apps/old-slug").await.json();
|
||||||
|
// The old slug resolves via history and surfaces `redirect_to`.
|
||||||
|
assert_eq!(resp["redirect_to"], "new-slug");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn claiming_historical_slug_needs_force_takeover(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
// Set up a history row.
|
||||||
|
let first: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "soon-retired", "name": "x" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
s.patch(&format!(
|
||||||
|
"/api/v1/admin/apps/{}",
|
||||||
|
first["id"].as_str().unwrap()
|
||||||
|
))
|
||||||
|
.json(&json!({ "slug": "kept" }))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
|
// Plain create against the retired slug → 409.
|
||||||
|
let r = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "soon-retired", "name": "y" }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
|
|
||||||
|
// With force_takeover → 201.
|
||||||
|
let r = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "soon-retired", "name": "y", "force_takeover": true }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn shape_key_collision_rejected(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let a: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "a", "name": "A" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let b: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "b", "name": "B" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
s.post(&format!(
|
||||||
|
"/api/v1/admin/apps/{}/domains",
|
||||||
|
a["id"].as_str().unwrap()
|
||||||
|
))
|
||||||
|
.json(&json!({ "pattern": "*.example.com" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
// Parameterized form should collide with wildcard form.
|
||||||
|
let r = s
|
||||||
|
.post(&format!(
|
||||||
|
"/api/v1/admin/apps/{}/domains",
|
||||||
|
b["id"].as_str().unwrap()
|
||||||
|
))
|
||||||
|
.json(&json!({ "pattern": "{tenant}.example.com" }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn delete_app_with_scripts_returns_409(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let app: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "with-scripts", "name": "x" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let id = app["id"].as_str().unwrap();
|
||||||
|
s.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(id, json!({ "name": "s", "source": "1" })))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let r = s.delete(&format!("/api/v1/admin/apps/{id}")).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn list_scripts_filtered_by_app(pool: PgPool) {
|
||||||
|
let (s, default_id) = server_with_app(pool).await;
|
||||||
|
let other: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "filter-target", "name": "x" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let other_id = other["id"].as_str().unwrap();
|
||||||
|
|
||||||
|
s.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
&default_id,
|
||||||
|
json!({ "name": "in-default", "source": "1" }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
s.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
other_id,
|
||||||
|
json!({ "name": "in-other", "source": "1" }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
// Filter by id.
|
||||||
|
let filtered: Vec<Value> = s
|
||||||
|
.get(&format!("/api/v1/admin/scripts?app={other_id}"))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
assert_eq!(filtered.len(), 1);
|
||||||
|
assert_eq!(filtered[0]["name"], "in-other");
|
||||||
|
|
||||||
|
// Filter by slug.
|
||||||
|
let filtered_by_slug: Vec<Value> = s
|
||||||
|
.get("/api/v1/admin/scripts?app=filter-target")
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
assert_eq!(filtered_by_slug.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn execution_errors_are_still_logged(pool: PgPool) {
|
async fn execution_errors_are_still_logged(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "boom",
|
"name": "boom",
|
||||||
"source": "1 / 0",
|
"source": "1 / 0",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
|
|||||||
53
crates/shared/src/app.rs
Normal file
53
crates/shared/src/app.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
//! App scoping: top-level isolation boundary for scripts, routes,
|
||||||
|
//! domains, and (forward) data. Every script and route belongs to
|
||||||
|
//! exactly one app; cross-app references are not allowed.
|
||||||
|
//!
|
||||||
|
//! See blueprint §11.5. The orchestrator dispatches via two-phase
|
||||||
|
//! lookup: `Host → app_id → route trie`.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::AppId;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct App {
|
||||||
|
pub id: AppId,
|
||||||
|
/// URL-safe identifier; appears in dashboard paths. Mutable via the
|
||||||
|
/// slug-rename flow which preserves the old slug as a permanent 301
|
||||||
|
/// in `app_slug_history`.
|
||||||
|
pub slug: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum DomainShape {
|
||||||
|
/// Exact host: `app.example.com`.
|
||||||
|
Exact,
|
||||||
|
/// Wildcard suffix: `*.example.com` matches any subdomain.
|
||||||
|
Wildcard,
|
||||||
|
/// Parameterized wildcard: `{tenant}.example.com`. Same shape as
|
||||||
|
/// `Wildcard` for collision purposes; the binding name surfaces in
|
||||||
|
/// request context (future).
|
||||||
|
Parameterized,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AppDomain {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub app_id: AppId,
|
||||||
|
/// As the user typed it: `app.example.com`, `*.example.com`, or
|
||||||
|
/// `{tenant}.example.com`.
|
||||||
|
pub pattern: String,
|
||||||
|
pub shape: DomainShape,
|
||||||
|
/// Normalized collision key. `exact:<host>` for exact; `wildcard:<suffix>`
|
||||||
|
/// for both wildcard and parameterized (parameter name is a binding,
|
||||||
|
/// not a discriminator — per blueprint §11.5).
|
||||||
|
pub shape_key: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
@@ -11,4 +11,10 @@ pub enum Error {
|
|||||||
|
|
||||||
#[error("invalid script source: {0}")]
|
#[error("invalid script source: {0}")]
|
||||||
InvalidScript(String),
|
InvalidScript(String),
|
||||||
|
|
||||||
|
#[error("app not found: {0}")]
|
||||||
|
AppNotFound(crate::AppId),
|
||||||
|
|
||||||
|
#[error("domain claim conflict: {0}")]
|
||||||
|
DomainConflict(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{RequestId, ScriptId};
|
use crate::{AppId, RequestId, ScriptId};
|
||||||
|
|
||||||
/// One row in the `execution_logs` table. Same shape flows through the
|
/// One row in the `execution_logs` table. Same shape flows through the
|
||||||
/// `ExecutionLogSink` trait and the `GET /scripts/{id}/logs` response.
|
/// `ExecutionLogSink` trait and the `GET /scripts/{id}/logs` response.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ExecutionLog {
|
pub struct ExecutionLog {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
/// Owning app at the time of execution. Materialized at write time
|
||||||
|
/// so a future "move script to another app" doesn't retag history.
|
||||||
|
pub app_id: AppId,
|
||||||
pub script_id: ScriptId,
|
pub script_id: ScriptId,
|
||||||
pub request_id: RequestId,
|
pub request_id: RequestId,
|
||||||
|
|
||||||
|
|||||||
@@ -51,3 +51,4 @@ id_type!(ScriptId);
|
|||||||
id_type!(ExecutionId);
|
id_type!(ExecutionId);
|
||||||
id_type!(RequestId);
|
id_type!(RequestId);
|
||||||
id_type!(AdminUserId);
|
id_type!(AdminUserId);
|
||||||
|
id_type!(AppId);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//! that core's crate. Things here must be genuinely shared (IDs, the Script
|
//! that core's crate. Things here must be genuinely shared (IDs, the Script
|
||||||
//! entity, error roots, transport DTOs).
|
//! entity, error roots, transport DTOs).
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod execution_log;
|
pub mod execution_log;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
@@ -14,9 +15,10 @@ pub mod script;
|
|||||||
pub mod validator;
|
pub mod validator;
|
||||||
pub mod version;
|
pub mod version;
|
||||||
|
|
||||||
|
pub use app::{App, AppDomain, DomainShape};
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
pub use execution_log::{ExecutionLog, ExecutionStatus};
|
pub use execution_log::{ExecutionLog, ExecutionStatus};
|
||||||
pub use ids::{AdminUserId, ExecutionId, RequestId, ScriptId};
|
pub use ids::{AdminUserId, AppId, ExecutionId, RequestId, ScriptId};
|
||||||
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
||||||
pub use route::{HostKind, PathKind, Route};
|
pub use route::{HostKind, PathKind, Route};
|
||||||
pub use sandbox::ScriptSandbox;
|
pub use sandbox::ScriptSandbox;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::ScriptId;
|
use crate::{AppId, ScriptId};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -40,6 +40,10 @@ pub enum PathKind {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Route {
|
pub struct Route {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
/// Owning app. Always equals `scripts.app_id` for the bound script.
|
||||||
|
/// Carried on the route row so the orchestrator can partition the
|
||||||
|
/// route table without joining back to scripts on every refresh.
|
||||||
|
pub app_id: AppId,
|
||||||
pub script_id: ScriptId,
|
pub script_id: ScriptId,
|
||||||
|
|
||||||
pub host_kind: HostKind,
|
pub host_kind: HostKind,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{ScriptId, ScriptSandbox};
|
use crate::{AppId, ScriptId, ScriptSandbox};
|
||||||
|
|
||||||
/// A user-uploaded Rhai script and its execution configuration.
|
/// A user-uploaded Rhai script and its execution configuration.
|
||||||
///
|
///
|
||||||
@@ -11,6 +11,10 @@ use crate::{ScriptId, ScriptSandbox};
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Script {
|
pub struct Script {
|
||||||
pub id: ScriptId,
|
pub id: ScriptId,
|
||||||
|
/// Owning app. Set on create, immutable thereafter — a "move to
|
||||||
|
/// another app" is a copy+delete, not an in-place edit (snapshot
|
||||||
|
/// semantics — see blueprint §11.5).
|
||||||
|
pub app_id: AppId,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub version: i32,
|
pub version: i32,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface ScriptSandbox {
|
|||||||
|
|
||||||
export interface Script {
|
export interface Script {
|
||||||
id: string;
|
id: string;
|
||||||
|
app_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
version: number;
|
version: number;
|
||||||
@@ -32,11 +33,64 @@ export interface Script {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface App {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DomainShape = 'exact' | 'wildcard' | 'parameterized';
|
||||||
|
|
||||||
|
export interface AppDomain {
|
||||||
|
id: string;
|
||||||
|
app_id: string;
|
||||||
|
pattern: string;
|
||||||
|
shape: DomainShape;
|
||||||
|
shape_key: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppLookupResponse {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
/// Present only when the requested slug was a retired redirect.
|
||||||
|
redirect_to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlugCheckResponse {
|
||||||
|
ok: boolean;
|
||||||
|
conflict_kind: 'current' | 'historical' | 'invalid' | 'reserved' | null;
|
||||||
|
current_app: App | null;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAppInput {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
force_takeover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatchAppInput {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
slug?: string;
|
||||||
|
force_takeover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type HostKind = 'any' | 'strict' | 'wildcard';
|
export type HostKind = 'any' | 'strict' | 'wildcard';
|
||||||
export type PathKind = 'exact' | 'prefix' | 'param';
|
export type PathKind = 'exact' | 'prefix' | 'param';
|
||||||
|
|
||||||
export interface Route {
|
export interface Route {
|
||||||
id: string;
|
id: string;
|
||||||
|
app_id: string;
|
||||||
script_id: string;
|
script_id: string;
|
||||||
host_kind: HostKind;
|
host_kind: HostKind;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -106,6 +160,7 @@ export interface ExecutionLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateScriptInput {
|
export interface CreateScriptInput {
|
||||||
|
app_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
@@ -257,20 +312,23 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
remove: (routeId: string) =>
|
remove: (routeId: string) =>
|
||||||
adminRequest<null>(`/api/v1/admin/routes/${routeId}`, { method: 'DELETE' }),
|
adminRequest<null>(`/api/v1/admin/routes/${routeId}`, { method: 'DELETE' }),
|
||||||
check: (input: RouteInput) =>
|
check: (appId: string, input: RouteInput) =>
|
||||||
adminRequest<CheckRouteResponse>('/api/v1/admin/routes:check', {
|
adminRequest<CheckRouteResponse>('/api/v1/admin/routes:check', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(input)
|
body: JSON.stringify({ ...input, app_id: appId })
|
||||||
}),
|
}),
|
||||||
match: (url: string, method = 'GET') =>
|
match: (appId: string, url: string, method = 'GET') =>
|
||||||
adminRequest<MatchRouteResponse>('/api/v1/admin/routes:match', {
|
adminRequest<MatchRouteResponse>('/api/v1/admin/routes:match', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ url, method })
|
body: JSON.stringify({ app_id: appId, url, method })
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
scripts: {
|
scripts: {
|
||||||
list: () => adminRequest<Script[]>('/api/v1/admin/scripts'),
|
list: (opts: { app?: string } = {}) => {
|
||||||
|
const qs = opts.app ? `?app=${encodeURIComponent(opts.app)}` : '';
|
||||||
|
return adminRequest<Script[]>(`/api/v1/admin/scripts${qs}`);
|
||||||
|
},
|
||||||
get: (id: string) => adminRequest<Script>(`/api/v1/admin/scripts/${id}`),
|
get: (id: string) => adminRequest<Script>(`/api/v1/admin/scripts/${id}`),
|
||||||
create: (input: CreateScriptInput) =>
|
create: (input: CreateScriptInput) =>
|
||||||
adminRequest<Script>('/api/v1/admin/scripts', {
|
adminRequest<Script>('/api/v1/admin/scripts', {
|
||||||
@@ -295,6 +353,51 @@ export const api = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
apps: {
|
||||||
|
list: () => adminRequest<App[]>('/api/v1/admin/apps'),
|
||||||
|
get: (idOrSlug: string) =>
|
||||||
|
adminRequest<AppLookupResponse>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`),
|
||||||
|
create: (input: CreateAppInput) =>
|
||||||
|
adminRequest<App>('/api/v1/admin/apps', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}),
|
||||||
|
update: (idOrSlug: string, input: PatchAppInput) =>
|
||||||
|
adminRequest<App>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}),
|
||||||
|
remove: (idOrSlug: string) =>
|
||||||
|
adminRequest<null>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
}),
|
||||||
|
slugCheck: (idOrSlug: string, newSlug: string) =>
|
||||||
|
adminRequest<SlugCheckResponse>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/slug:check`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ new_slug: newSlug })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
domains: {
|
||||||
|
listForApp: (idOrSlug: string) =>
|
||||||
|
adminRequest<AppDomain[]>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains`
|
||||||
|
),
|
||||||
|
create: (idOrSlug: string, pattern: string) =>
|
||||||
|
adminRequest<AppDomain>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ pattern }) }
|
||||||
|
),
|
||||||
|
remove: (idOrSlug: string, domainId: string) =>
|
||||||
|
adminRequest<null>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains/${domainId}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
execute: async (
|
execute: async (
|
||||||
id: string,
|
id: string,
|
||||||
body: unknown,
|
body: unknown,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
<header>
|
<header>
|
||||||
<a href={base + '/'} class="brand">PiCloud</a>
|
<a href={base + '/'} class="brand">PiCloud</a>
|
||||||
<nav>
|
<nav>
|
||||||
<a href={base + '/'}>Scripts</a>
|
<a href={base + '/apps'}>Apps</a>
|
||||||
<a href={base + '/admins'}>Admins</a>
|
<a href={base + '/admins'}>Admins</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
|
|||||||
@@ -1,242 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { api, ApiError, type Script } from '$lib/api';
|
import { goto } from '$app/navigation';
|
||||||
import CodeEditor from '$lib/CodeEditor.svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
const SAMPLE_SOURCE = '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
// Dashboard entry: always lands on the apps list now (multi-app
|
||||||
|
// scoping makes "scripts at root" no longer meaningful — every
|
||||||
let scripts = $state<Script[] | null>(null);
|
// script lives inside an app).
|
||||||
let listError = $state<string | null>(null);
|
onMount(() => {
|
||||||
let loading = $state(true);
|
void goto(`${base}/apps`, { replaceState: true });
|
||||||
|
|
||||||
let showCreate = $state(false);
|
|
||||||
let createName = $state('');
|
|
||||||
let createDescription = $state('');
|
|
||||||
let createSource = $state(SAMPLE_SOURCE);
|
|
||||||
let creating = $state(false);
|
|
||||||
let createError = $state<string | null>(null);
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading = true;
|
|
||||||
listError = null;
|
|
||||||
try {
|
|
||||||
scripts = await api.scripts.list();
|
|
||||||
} catch (e) {
|
|
||||||
listError = e instanceof Error ? e.message : String(e);
|
|
||||||
scripts = null;
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitCreate(event: Event) {
|
|
||||||
event.preventDefault();
|
|
||||||
creating = true;
|
|
||||||
createError = null;
|
|
||||||
try {
|
|
||||||
await api.scripts.create({
|
|
||||||
name: createName.trim(),
|
|
||||||
description: createDescription.trim() || null,
|
|
||||||
source: createSource
|
|
||||||
});
|
|
||||||
showCreate = false;
|
|
||||||
createName = '';
|
|
||||||
createDescription = '';
|
|
||||||
createSource = SAMPLE_SOURCE;
|
|
||||||
await load();
|
|
||||||
} catch (e) {
|
|
||||||
createError = e instanceof Error ? e.message : String(e);
|
|
||||||
if (e instanceof ApiError && e.status === 422) {
|
|
||||||
createError = `Syntax error: ${createError}`;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
creating = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void load();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section>
|
<p class="muted">Redirecting…</p>
|
||||||
<header class="page-header">
|
|
||||||
<h1>Scripts</h1>
|
|
||||||
<button type="button" onclick={() => (showCreate = !showCreate)}>
|
|
||||||
{showCreate ? 'Cancel' : 'New script'}
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{#if showCreate}
|
|
||||||
<form class="create-form" onsubmit={submitCreate}>
|
|
||||||
<div class="row">
|
|
||||||
<label>
|
|
||||||
<span>Name</span>
|
|
||||||
<input bind:value={createName} required minlength="1" placeholder="echo" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Description</span>
|
|
||||||
<input bind:value={createDescription} placeholder="optional" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label class="full">
|
|
||||||
<span>Source (Rhai)</span>
|
|
||||||
<CodeEditor bind:value={createSource} language="rhai" minHeight="14rem" />
|
|
||||||
</label>
|
|
||||||
{#if createError}
|
|
||||||
<div class="error">{createError}</div>
|
|
||||||
{/if}
|
|
||||||
<div class="actions">
|
|
||||||
<button type="submit" disabled={creating}>
|
|
||||||
{creating ? 'Creating…' : 'Create script'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<p class="muted">Loading…</p>
|
|
||||||
{:else if listError}
|
|
||||||
<div class="error">
|
|
||||||
<strong>Could not load scripts.</strong>
|
|
||||||
<p>{listError}</p>
|
|
||||||
<button type="button" onclick={() => void load()}>Retry</button>
|
|
||||||
</div>
|
|
||||||
{:else if scripts && scripts.length === 0}
|
|
||||||
<p class="muted">No scripts yet. Create one above to get started.</p>
|
|
||||||
{:else if scripts}
|
|
||||||
<ul class="list">
|
|
||||||
{#each scripts as script (script.id)}
|
|
||||||
<li>
|
|
||||||
<a href="{base}/scripts/{script.id}">
|
|
||||||
<div class="primary">
|
|
||||||
<strong>{script.name}</strong>
|
|
||||||
<span class="muted">v{script.version}</span>
|
|
||||||
</div>
|
|
||||||
<div class="secondary muted">
|
|
||||||
{script.description ?? '—'}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
background: #38bdf8;
|
|
||||||
color: #0b1220;
|
|
||||||
border: none;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
|
||||||
border: 1px solid #b91c1c;
|
|
||||||
background: #450a0a;
|
|
||||||
color: #fecaca;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form {
|
|
||||||
background: #1e293b;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 1.25rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form .row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 2fr;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #cbd5e1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form label.full {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form input {
|
|
||||||
background: #0b1220;
|
|
||||||
color: #e2e8f0;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list a {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
background: #1e293b;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list a:hover {
|
|
||||||
background: #283549;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
305
dashboard/src/routes/apps/+page.svelte
Normal file
305
dashboard/src/routes/apps/+page.svelte
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { api, ApiError, type App } from '$lib/api';
|
||||||
|
|
||||||
|
let apps = $state<App[] | null>(null);
|
||||||
|
let listError = $state<string | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
let showCreate = $state(false);
|
||||||
|
let createSlug = $state('');
|
||||||
|
let createName = $state('');
|
||||||
|
let createDescription = $state('');
|
||||||
|
let creating = $state(false);
|
||||||
|
let createError = $state<string | null>(null);
|
||||||
|
let createHistoricalConflict = $state<App | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
listError = null;
|
||||||
|
try {
|
||||||
|
apps = await api.apps.list();
|
||||||
|
} catch (e) {
|
||||||
|
listError = e instanceof Error ? e.message : String(e);
|
||||||
|
apps = null;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCreate() {
|
||||||
|
createSlug = '';
|
||||||
|
createName = '';
|
||||||
|
createDescription = '';
|
||||||
|
createError = null;
|
||||||
|
createHistoricalConflict = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate(event: Event, forceTakeover = false) {
|
||||||
|
event.preventDefault();
|
||||||
|
creating = true;
|
||||||
|
createError = null;
|
||||||
|
if (!forceTakeover) createHistoricalConflict = null;
|
||||||
|
try {
|
||||||
|
await api.apps.create({
|
||||||
|
slug: createSlug.trim(),
|
||||||
|
name: createName.trim(),
|
||||||
|
description: createDescription.trim() || null,
|
||||||
|
force_takeover: forceTakeover || undefined
|
||||||
|
});
|
||||||
|
showCreate = false;
|
||||||
|
resetCreate();
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 409 && e.body) {
|
||||||
|
const body = e.body as { conflict_kind?: string; current_app?: App };
|
||||||
|
if (body.conflict_kind === 'historical' && body.current_app) {
|
||||||
|
createHistoricalConflict = body.current_app;
|
||||||
|
createError = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>Apps</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
showCreate = !showCreate;
|
||||||
|
if (!showCreate) resetCreate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showCreate ? 'Cancel' : 'New app'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if showCreate}
|
||||||
|
<form class="create-form" onsubmit={(e) => submitCreate(e)}>
|
||||||
|
<div class="row">
|
||||||
|
<label>
|
||||||
|
<span>Slug</span>
|
||||||
|
<input
|
||||||
|
bind:value={createSlug}
|
||||||
|
required
|
||||||
|
pattern="[a-z0-9][a-z0-9-]*"
|
||||||
|
placeholder="my-app"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Name</span>
|
||||||
|
<input bind:value={createName} required placeholder="My App" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Description</span>
|
||||||
|
<input bind:value={createDescription} placeholder="optional" />
|
||||||
|
</label>
|
||||||
|
{#if createHistoricalConflict}
|
||||||
|
<div class="warning">
|
||||||
|
<strong>Slug previously redirected.</strong>
|
||||||
|
<p>
|
||||||
|
<code>{createSlug}</code> currently redirects to
|
||||||
|
<code>{createHistoricalConflict.slug}</code>. Using it here will break any
|
||||||
|
external links that still target the old slug.
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="secondary" onclick={() => (createHistoricalConflict = null)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={(e) => submitCreate(e, true)}
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
{creating ? 'Claiming…' : 'Claim slug anyway'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if createError}
|
||||||
|
<div class="error">{createError}</div>
|
||||||
|
{/if}
|
||||||
|
{#if !createHistoricalConflict}
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" disabled={creating}>
|
||||||
|
{creating ? 'Creating…' : 'Create app'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="muted">Loading…</p>
|
||||||
|
{:else if listError}
|
||||||
|
<div class="error">
|
||||||
|
<strong>Could not load apps.</strong>
|
||||||
|
<p>{listError}</p>
|
||||||
|
<button type="button" onclick={() => void load()}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if apps && apps.length === 0}
|
||||||
|
<p class="muted">No apps yet. Create one above to get started.</p>
|
||||||
|
{:else if apps}
|
||||||
|
<ul class="list">
|
||||||
|
{#each apps as app (app.id)}
|
||||||
|
<li>
|
||||||
|
<a href="{base}/apps/{app.slug}">
|
||||||
|
<div class="primary">
|
||||||
|
<strong>{app.name}</strong>
|
||||||
|
<span class="muted">/{app.slug}</span>
|
||||||
|
</div>
|
||||||
|
<div class="secondary muted">
|
||||||
|
{app.description ?? '—'}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
border: 1px solid #ca8a04;
|
||||||
|
background: #3f2e07;
|
||||||
|
color: #fde68a;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning code {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form {
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form .row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form input {
|
||||||
|
background: #0b1220;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list a {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list a:hover {
|
||||||
|
background: #283549;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
653
dashboard/src/routes/apps/[slug]/+page.svelte
Normal file
653
dashboard/src/routes/apps/[slug]/+page.svelte
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
ApiError,
|
||||||
|
type App,
|
||||||
|
type AppDomain,
|
||||||
|
type Script
|
||||||
|
} from '$lib/api';
|
||||||
|
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||||
|
|
||||||
|
const SAMPLE_SOURCE =
|
||||||
|
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
||||||
|
|
||||||
|
type Tab = 'scripts' | 'domains' | 'settings';
|
||||||
|
|
||||||
|
let slug = $derived(page.params.slug ?? '');
|
||||||
|
let app = $state<App | null>(null);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let activeTab = $state<Tab>('scripts');
|
||||||
|
|
||||||
|
let scripts = $state<Script[]>([]);
|
||||||
|
let domains = $state<AppDomain[]>([]);
|
||||||
|
|
||||||
|
// Script create
|
||||||
|
let showCreateScript = $state(false);
|
||||||
|
let createScriptName = $state('');
|
||||||
|
let createScriptDescription = $state('');
|
||||||
|
let createScriptSource = $state(SAMPLE_SOURCE);
|
||||||
|
let creatingScript = $state(false);
|
||||||
|
let createScriptError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Domain create
|
||||||
|
let createDomainPattern = $state('');
|
||||||
|
let creatingDomain = $state(false);
|
||||||
|
let createDomainError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
let editName = $state('');
|
||||||
|
let editDescription = $state('');
|
||||||
|
let editSlug = $state('');
|
||||||
|
let savingSettings = $state(false);
|
||||||
|
let settingsError = $state<string | null>(null);
|
||||||
|
let slugTakeoverNeeded = $state<App | null>(null);
|
||||||
|
|
||||||
|
async function loadApp() {
|
||||||
|
loading = true;
|
||||||
|
loadError = null;
|
||||||
|
try {
|
||||||
|
const fetched = await api.apps.get(slug);
|
||||||
|
if (fetched.redirect_to && fetched.redirect_to !== slug) {
|
||||||
|
await goto(`${base}/apps/${fetched.redirect_to}`, { replaceState: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app = {
|
||||||
|
id: fetched.id,
|
||||||
|
slug: fetched.slug,
|
||||||
|
name: fetched.name,
|
||||||
|
description: fetched.description,
|
||||||
|
created_at: fetched.created_at,
|
||||||
|
updated_at: fetched.updated_at
|
||||||
|
};
|
||||||
|
editName = app.name;
|
||||||
|
editDescription = app.description ?? '';
|
||||||
|
editSlug = app.slug;
|
||||||
|
await Promise.all([loadScripts(app.id), loadDomains(app.id)]);
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadScripts(appId: string) {
|
||||||
|
try {
|
||||||
|
scripts = await api.scripts.list({ app: appId });
|
||||||
|
} catch (e) {
|
||||||
|
scripts = [];
|
||||||
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDomains(appId: string) {
|
||||||
|
try {
|
||||||
|
domains = await api.domains.listForApp(appId);
|
||||||
|
} catch (e) {
|
||||||
|
domains = [];
|
||||||
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreateScript(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!app) return;
|
||||||
|
creatingScript = true;
|
||||||
|
createScriptError = null;
|
||||||
|
try {
|
||||||
|
await api.scripts.create({
|
||||||
|
app_id: app.id,
|
||||||
|
name: createScriptName.trim(),
|
||||||
|
description: createScriptDescription.trim() || null,
|
||||||
|
source: createScriptSource
|
||||||
|
});
|
||||||
|
showCreateScript = false;
|
||||||
|
createScriptName = '';
|
||||||
|
createScriptDescription = '';
|
||||||
|
createScriptSource = SAMPLE_SOURCE;
|
||||||
|
await loadScripts(app.id);
|
||||||
|
} catch (e) {
|
||||||
|
createScriptError = e instanceof Error ? e.message : String(e);
|
||||||
|
if (e instanceof ApiError && e.status === 422) {
|
||||||
|
createScriptError = `Validation: ${createScriptError}`;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
creatingScript = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreateDomain(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!app) return;
|
||||||
|
creatingDomain = true;
|
||||||
|
createDomainError = null;
|
||||||
|
try {
|
||||||
|
await api.domains.create(app.id, createDomainPattern.trim());
|
||||||
|
createDomainPattern = '';
|
||||||
|
await loadDomains(app.id);
|
||||||
|
} catch (e) {
|
||||||
|
createDomainError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
creatingDomain = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDomain(d: AppDomain) {
|
||||||
|
if (!app) return;
|
||||||
|
if (!window.confirm(`Delete domain claim ${d.pattern}?`)) return;
|
||||||
|
try {
|
||||||
|
await api.domains.remove(app.id, d.id);
|
||||||
|
await loadDomains(app.id);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings(event: Event, forceTakeover = false) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!app) return;
|
||||||
|
savingSettings = true;
|
||||||
|
settingsError = null;
|
||||||
|
if (!forceTakeover) slugTakeoverNeeded = null;
|
||||||
|
try {
|
||||||
|
const slugChanged = editSlug.trim() !== app.slug;
|
||||||
|
const updated = await api.apps.update(app.id, {
|
||||||
|
name: editName.trim() !== app.name ? editName.trim() : undefined,
|
||||||
|
description:
|
||||||
|
editDescription !== (app.description ?? '')
|
||||||
|
? editDescription || null
|
||||||
|
: undefined,
|
||||||
|
slug: slugChanged ? editSlug.trim() : undefined,
|
||||||
|
force_takeover: forceTakeover || undefined
|
||||||
|
});
|
||||||
|
if (slugChanged) {
|
||||||
|
await goto(`${base}/apps/${updated.slug}`, { replaceState: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app = updated;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 409 && e.body) {
|
||||||
|
const body = e.body as { conflict_kind?: string; current_app?: App };
|
||||||
|
if (body.conflict_kind === 'historical' && body.current_app) {
|
||||||
|
slugTakeoverNeeded = body.current_app;
|
||||||
|
settingsError = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settingsError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
savingSettings = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteApp() {
|
||||||
|
if (!app) return;
|
||||||
|
const yes = window.confirm(
|
||||||
|
`Delete app "${app.name}"? This requires zero scripts and zero domain claims.`
|
||||||
|
);
|
||||||
|
if (!yes) return;
|
||||||
|
try {
|
||||||
|
await api.apps.remove(app.id);
|
||||||
|
await goto(`${base}/apps`);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void loadApp();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading && !app}
|
||||||
|
<p class="muted">Loading…</p>
|
||||||
|
{:else if loadError && !app}
|
||||||
|
<div class="error">
|
||||||
|
<strong>Could not load app.</strong>
|
||||||
|
<p>{loadError}</p>
|
||||||
|
<a href="{base}/apps">Back to apps</a>
|
||||||
|
</div>
|
||||||
|
{:else if app}
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="{base}/apps">Apps</a> / <code>{app.slug}</code>
|
||||||
|
</div>
|
||||||
|
<h1>{app.name}</h1>
|
||||||
|
{#if app.description}<p class="muted">{app.description}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={activeTab === 'scripts'}
|
||||||
|
onclick={() => (activeTab = 'scripts')}>Scripts ({scripts.length})</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={activeTab === 'domains'}
|
||||||
|
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={activeTab === 'settings'}
|
||||||
|
onclick={() => (activeTab = 'settings')}>Settings</button
|
||||||
|
>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{#if activeTab === 'scripts'}
|
||||||
|
<section>
|
||||||
|
<div class="row">
|
||||||
|
<h2>Scripts</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showCreateScript = !showCreateScript)}
|
||||||
|
>
|
||||||
|
{showCreateScript ? 'Cancel' : 'New script'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showCreateScript}
|
||||||
|
<form class="create-form" onsubmit={submitCreateScript}>
|
||||||
|
<div class="row">
|
||||||
|
<label>
|
||||||
|
<span>Name</span>
|
||||||
|
<input bind:value={createScriptName} required placeholder="echo" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Description</span>
|
||||||
|
<input bind:value={createScriptDescription} placeholder="optional" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="full">
|
||||||
|
<span>Source (Rhai)</span>
|
||||||
|
<CodeEditor bind:value={createScriptSource} language="rhai" minHeight="14rem" />
|
||||||
|
</label>
|
||||||
|
{#if createScriptError}
|
||||||
|
<div class="error">{createScriptError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" disabled={creatingScript}>
|
||||||
|
{creatingScript ? 'Creating…' : 'Create script'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if scripts.length === 0}
|
||||||
|
<p class="muted">No scripts in this app yet.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="list">
|
||||||
|
{#each scripts as script (script.id)}
|
||||||
|
<li>
|
||||||
|
<a href="{base}/scripts/{script.id}">
|
||||||
|
<div class="primary">
|
||||||
|
<strong>{script.name}</strong>
|
||||||
|
<span class="muted">v{script.version}</span>
|
||||||
|
</div>
|
||||||
|
<div class="secondary muted">{script.description ?? '—'}</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{:else if activeTab === 'domains'}
|
||||||
|
<section>
|
||||||
|
<h2>Domain claims</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Hosts this app answers on. Routes inside this app can only bind to
|
||||||
|
these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for
|
||||||
|
wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture.
|
||||||
|
</p>
|
||||||
|
<form class="create-form inline" onsubmit={submitCreateDomain}>
|
||||||
|
<input
|
||||||
|
bind:value={createDomainPattern}
|
||||||
|
required
|
||||||
|
placeholder="app.example.com"
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={creatingDomain}>
|
||||||
|
{creatingDomain ? 'Adding…' : 'Add domain'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{#if createDomainError}
|
||||||
|
<div class="error">{createDomainError}</div>
|
||||||
|
{/if}
|
||||||
|
{#if domains.length === 0}
|
||||||
|
<p class="muted">No domain claims yet.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="list">
|
||||||
|
{#each domains as d (d.id)}
|
||||||
|
<li class="domain-row">
|
||||||
|
<div>
|
||||||
|
<code>{d.pattern}</code>
|
||||||
|
<span class="muted">— {d.shape}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary danger"
|
||||||
|
onclick={() => void removeDomain(d)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{:else if activeTab === 'settings'}
|
||||||
|
<section>
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<form class="create-form" onsubmit={(e) => saveSettings(e)}>
|
||||||
|
<label>
|
||||||
|
<span>Name</span>
|
||||||
|
<input bind:value={editName} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Description</span>
|
||||||
|
<input bind:value={editDescription} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Slug</span>
|
||||||
|
<input
|
||||||
|
bind:value={editSlug}
|
||||||
|
required
|
||||||
|
pattern="[a-z0-9][a-z0-9-]*"
|
||||||
|
/>
|
||||||
|
<small class="muted">
|
||||||
|
Renaming records the old slug as a permanent 301 redirect.
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
{#if slugTakeoverNeeded}
|
||||||
|
<div class="warning">
|
||||||
|
<strong>Slug previously redirected.</strong>
|
||||||
|
<p>
|
||||||
|
<code>{editSlug}</code> currently redirects to
|
||||||
|
<code>{slugTakeoverNeeded.slug}</code>. Renaming to it will break old
|
||||||
|
links.
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
onclick={() => (slugTakeoverNeeded = null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={(e) => saveSettings(e, true)}
|
||||||
|
disabled={savingSettings}
|
||||||
|
>
|
||||||
|
{savingSettings ? 'Renaming…' : 'Rename anyway'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if settingsError}
|
||||||
|
<div class="error">{settingsError}</div>
|
||||||
|
{/if}
|
||||||
|
{#if !slugTakeoverNeeded}
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" disabled={savingSettings}>
|
||||||
|
{savingSettings ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="danger-zone">
|
||||||
|
<h3>Delete app</h3>
|
||||||
|
<p class="muted">
|
||||||
|
Requires the app to have zero scripts and zero domain claims.
|
||||||
|
</p>
|
||||||
|
<button type="button" class="danger" onclick={deleteApp}>Delete app</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb a {
|
||||||
|
color: #94a3b8;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb a:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb code {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: none;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button.active {
|
||||||
|
color: #38bdf8;
|
||||||
|
border-bottom-color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: #7f1d1d;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary.danger {
|
||||||
|
background: transparent;
|
||||||
|
color: #fca5a5;
|
||||||
|
border-color: #7f1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
border: 1px solid #ca8a04;
|
||||||
|
background: #3f2e07;
|
||||||
|
color: #fde68a;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning code {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form {
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form.inline {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form .row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form label.full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form input {
|
||||||
|
background: #0b1220;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font: inherit;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list a {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list a:hover {
|
||||||
|
background: #283549;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-row code {
|
||||||
|
background: #0b1220;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #7f1d1d;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #1e0a0a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -38,6 +38,8 @@
|
|||||||
let scriptLoading = $state(true);
|
let scriptLoading = $state(true);
|
||||||
let info = $state<VersionInfo | null>(null);
|
let info = $state<VersionInfo | null>(null);
|
||||||
|
|
||||||
|
let appSlug = $state<string | null>(null);
|
||||||
|
|
||||||
async function loadScript() {
|
async function loadScript() {
|
||||||
scriptLoading = true;
|
scriptLoading = true;
|
||||||
scriptError = null;
|
scriptError = null;
|
||||||
@@ -48,6 +50,14 @@
|
|||||||
editableDescription = script.description ?? '';
|
editableDescription = script.description ?? '';
|
||||||
editableTimeout = script.timeout_seconds;
|
editableTimeout = script.timeout_seconds;
|
||||||
editableSandbox = { ...(script.sandbox ?? {}) };
|
editableSandbox = { ...(script.sandbox ?? {}) };
|
||||||
|
// Resolve the owning app's slug for the breadcrumb. Failure
|
||||||
|
// is non-fatal — the page works without it.
|
||||||
|
void api.apps
|
||||||
|
.get(script.app_id)
|
||||||
|
.then((a) => {
|
||||||
|
appSlug = a.slug;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
scriptError = e instanceof Error ? e.message : String(e);
|
scriptError = e instanceof Error ? e.message : String(e);
|
||||||
script = null;
|
script = null;
|
||||||
@@ -251,8 +261,9 @@
|
|||||||
|
|
||||||
async function runPreview() {
|
async function runPreview() {
|
||||||
previewResult = null;
|
previewResult = null;
|
||||||
|
if (!script) return;
|
||||||
try {
|
try {
|
||||||
const r = await api.routes.match(previewUrl, previewMethod);
|
const r = await api.routes.match(script.app_id, previewUrl, previewMethod);
|
||||||
if (r.matched) {
|
if (r.matched) {
|
||||||
const ours = r.matched.script_id === id;
|
const ours = r.matched.script_id === id;
|
||||||
const tag = ours ? '✓ matches THIS script' : '⚠ matches a DIFFERENT script';
|
const tag = ours ? '✓ matches THIS script' : '⚠ matches a DIFFERENT script';
|
||||||
@@ -368,6 +379,13 @@
|
|||||||
{:else if script}
|
{:else if script}
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
|
{#if appSlug}
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="{base}/apps">Apps</a> /
|
||||||
|
<a href="{base}/apps/{appSlug}">{appSlug}</a> / Scripts /
|
||||||
|
<code>{script.name}</code>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<h1>{script.name}</h1>
|
<h1>{script.name}</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
|
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
|
||||||
@@ -756,6 +774,23 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
margin: 1rem 0 1.5rem;
|
margin: 1rem 0 1.5rem;
|
||||||
}
|
}
|
||||||
|
.breadcrumb {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.breadcrumb a {
|
||||||
|
color: #94a3b8;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.breadcrumb a:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.breadcrumb code {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
|
|||||||
@@ -128,8 +128,8 @@ A surface can hit its own `1.0` independently of the product. The SDK in particu
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Product | `0.5.1` |
|
| Product | `0.5.1` |
|
||||||
| SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) |
|
| SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) |
|
||||||
| API | `1` |
|
| API | `1` (additive: `Script.app_id`, `Route.app_id`, `ExecutionLog.app_id`, new `/api/v1/admin/apps/*` endpoints, `?app=` filter on script list) |
|
||||||
| Schema | `3` (matches `migrations/0003_routes.sql`) |
|
| Schema | `5` (matches `migrations/0005_apps.sql`) |
|
||||||
| Wire | `1` (reserved; cluster mode not implemented) |
|
| Wire | `1` (reserved; cluster mode not implemented) |
|
||||||
|
|
||||||
Read live from `GET /version` on any running instance.
|
Read live from `GET /version` on any running instance.
|
||||||
|
|||||||
@@ -853,7 +853,17 @@ Permission checks land in middleware that initially only enforces "authenticated
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11.5 App Scoping (v1.x)
|
## 11.5 App Scoping (Phase 3b) — Shipped
|
||||||
|
|
||||||
|
**Status**: shipped. Implementation lives in:
|
||||||
|
- `crates/shared/src/{app,ids,script,route}.rs` — `App`, `AppDomain`, `AppId`, `app_id` fields on `Script`/`Route`/`ExecutionLog`.
|
||||||
|
- `crates/manager-core/src/{app_repo,app_domain_repo,apps_api,app_bootstrap}.rs` — repos + admin API + Hello-World seed.
|
||||||
|
- `crates/orchestrator-core/src/routing/{app_domains,pattern,table}.rs` — `AppDomainTable`, `parse_app_domain`, per-app `RouteTable`.
|
||||||
|
- Migration `0005_apps.sql`.
|
||||||
|
|
||||||
|
**Deviations from the design below**: none of substance. Two operational notes:
|
||||||
|
- The Hello-World seed lives in `crates/manager-core/seeds/hello.rhai` and is inserted by a Rust bootstrap step (`seed_hello_world_if_fresh`) rather than from the migration — keeps it testable and gives the dashboard editor real source to render. The migration always inserts the `default` app + `localhost` claim; the seed only fires when that app is otherwise empty.
|
||||||
|
- Per-app admin roles/permissions are deferred — every authenticated admin can act on every app. The middleware seam (`auth_middleware::require_admin`) is the place where role checks slot in later.
|
||||||
|
|
||||||
**Purpose**: PiCloud hosts multiple independent applications on one platform. Each app is the isolation boundary for scripts, routes, domains, and (later) data — App A cannot see or modify App B's resources except through HTTP calls between them.
|
**Purpose**: PiCloud hosts multiple independent applications on one platform. Each app is the isolation boundary for scripts, routes, domains, and (later) data — App A cannot see or modify App B's resources except through HTTP calls between them.
|
||||||
|
|
||||||
@@ -1042,7 +1052,7 @@ Two foundation pieces that must land before the v1.1 service expansion, because
|
|||||||
|
|
||||||
**3a. Admin auth** — ✓ shipped. See section 11.4. Per-user `admin_users` (not a shared secret), Argon2id passwords, env-var bootstrap of the first admin, session-token doubling as bearer token for API. No roles in this cut; schema is forward-compatible with later RBAC.
|
**3a. Admin auth** — ✓ shipped. See section 11.4. Per-user `admin_users` (not a shared secret), Argon2id passwords, env-var bootstrap of the first admin, session-token doubling as bearer token for API. No roles in this cut; schema is forward-compatible with later RBAC.
|
||||||
|
|
||||||
**3b. Multi-app scoping** — see section 11.5. Introduce `apps`, `app_domains`, and `app_id` columns on `scripts` and `routes`. Migration assigns existing data to a `default` app (or seeds a `Hello World` app on fresh installs). Orchestrator dispatch becomes two-phase (Host → app → route). Reserved internal domain (`__internal__`) keeps `/api/v1/execute/{id}/*` working for app scripts without requiring a public hostname. Dashboard becomes app-hierarchical (`/admin/apps/{slug}/...`); API keeps its existing flat shape with new app-management endpoints under `/api/v1/admin/apps/*`.
|
**3b. Multi-app scoping** — ✓ shipped. See section 11.5. `apps`, `app_domains`, `app_slug_history` tables; `app_id` columns on `scripts`, `routes`, `execution_logs`. Migration assigns existing data to a `default` app and always claims `localhost`; a Rust-side bootstrap inserts a `Hello World` script + `/hello` route when the default app is empty. Orchestrator dispatch is two-phase (Host → app → route trie). `/api/v1/execute/{id}/*` continues to work without a public domain claim. Dashboard is app-hierarchical (`/admin/apps`, `/admin/apps/{slug}/...`); API stays flat with new endpoints under `/api/v1/admin/apps/*` and a `?app=` filter on script listing. Per-app admin roles deferred.
|
||||||
|
|
||||||
**Why both before v1.1**: every v1.1 service (KV, docs, users, etc.) needs an `app_id` scoping key in its schema. Adding it now, with one small migration on existing tables, is cheap. Adding it after those services ship is several migrations on populated data.
|
**Why both before v1.1**: every v1.1 service (KV, docs, users, etc.) needs an `app_id` scoping key in its schema. Adding it now, with one small migration on existing tables, is cheap. Adding it after those services ship is several migrations on populated data.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user