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:
MechaCat02
2026-05-25 21:03:05 +02:00
parent 6891496589
commit 4c41374db4
38 changed files with 3848 additions and 441 deletions

View File

@@ -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.
**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

View 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);

View 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}!` }
};

View File

@@ -5,17 +5,18 @@
use std::sync::Arc;
use axum::{
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use picloud_shared::{
ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
AppId, ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
};
use serde::Deserialize;
use crate::app_repo::AppRepository;
use crate::repo::{
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
};
@@ -27,6 +28,9 @@ use crate::sandbox::{CeilingError, SandboxCeiling};
pub struct AdminState<R, L> {
pub repo: Arc<R>,
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 sandbox_ceiling: SandboxCeiling,
}
@@ -36,6 +40,7 @@ impl<R, L> Clone for AdminState<R, L> {
Self {
repo: self.repo.clone(),
logs: self.logs.clone(),
apps: self.apps.clone(),
validator: self.validator.clone(),
sandbox_ceiling: self.sandbox_ceiling,
}
@@ -70,6 +75,9 @@ where
#[derive(Debug, Deserialize)]
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 description: Option<String>,
pub source: String,
@@ -82,6 +90,14 @@ pub struct CreateScriptRequest {
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)]
pub struct UpdateScriptRequest {
pub name: Option<String>,
@@ -113,8 +129,32 @@ where
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>,
Query(q): Query<ListScriptsQuery>,
) -> 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?))
}
}
/// 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>(
@@ -135,9 +175,15 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
) -> Result<(StatusCode, Json<Script>), ApiError> {
state.validator.validate(&input.source)?;
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
.repo
.create(NewScript {
app_id: input.app_id,
name: input.name,
description: input.description,
source: input.source,
@@ -223,6 +269,9 @@ pub enum ApiError {
#[error("script not found: {0}")]
NotFound(ScriptId),
#[error("app not found: {0}")]
AppNotFound(String),
#[error("conflict: {0}")]
Conflict(String),
@@ -240,6 +289,7 @@ impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
Self::AppNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
Self::Invalid(_) | Self::Ceiling(_) => {
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())

View 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

View 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,
}
}
}

View 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(&current_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,
}
}
}

View 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()
}
}

View File

@@ -8,6 +8,10 @@ pub mod admin_session_repo;
pub mod admin_user_repo;
pub mod admin_users_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_api;
pub mod auth_bootstrap;
@@ -30,6 +34,10 @@ pub use admin_user_repo::{
};
pub use admin_users_api::{admins_router, AdminsState};
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_bootstrap::{
bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError,

View File

@@ -28,15 +28,16 @@ impl ExecutionLogSink for PostgresExecutionLogSink {
sqlx::query(
"INSERT INTO execution_logs ( \
id, script_id, request_id, \
id, app_id, script_id, request_id, \
request_path, request_headers, request_body, \
response_code, response_body, \
logs, duration_ms, status, created_at \
) 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.app_id.into_inner())
.bind(log.script_id.into_inner())
.bind(log.request_id.into_inner())
.bind(&log.request_path)

View File

@@ -2,7 +2,9 @@ use std::collections::BTreeMap;
use async_trait::async_trait;
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;
#[derive(Debug, thiserror::Error)]
@@ -21,7 +23,10 @@ pub enum ScriptRepositoryError {
#[async_trait]
pub trait ScriptRepository: Send + Sync {
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_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
async fn update(
&self,
@@ -35,6 +40,7 @@ pub trait ScriptRepository: Send + Sync {
/// constraints; the repo enforces them in the DB regardless.
#[derive(Debug, Clone)]
pub struct NewScript {
pub app_id: AppId,
pub name: String,
pub description: Option<String>,
pub source: String,
@@ -78,7 +84,7 @@ impl PostgresScriptRepository {
impl ScriptRepository for PostgresScriptRepository {
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
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 \
FROM scripts WHERE id = $1",
)
@@ -90,7 +96,7 @@ impl ScriptRepository for PostgresScriptRepository {
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
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 \
FROM scripts ORDER BY name",
)
@@ -99,17 +105,30 @@ impl ScriptRepository for PostgresScriptRepository {
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> {
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
.unwrap_or_else(|_| serde_json::json!({}));
let res = sqlx::query_as::<_, ScriptRow>(
"INSERT INTO scripts ( \
name, description, source, \
app_id, name, description, source, \
timeout_seconds, memory_limit_mb, sandbox \
) VALUES ($1, $2, $3, COALESCE($4, 30), COALESCE($5, 256), $6) \
RETURNING id, name, description, version, source, \
) VALUES ($1, $2, $3, $4, COALESCE($5, 30), COALESCE($6, 256), $7) \
RETURNING id, app_id, name, description, version, source, \
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
)
.bind(input.app_id.into_inner())
.bind(&input.name)
.bind(input.description.as_deref())
.bind(&input.source)
@@ -123,7 +142,7 @@ impl ScriptRepository for PostgresScriptRepository {
Ok(row) => Ok(row.into()),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
Err(ScriptRepositoryError::Conflict(format!(
"a script named {:?} already exists",
"a script named {:?} already exists in this app",
input.name
)))
}
@@ -141,12 +160,13 @@ impl ScriptRepository for PostgresScriptRepository {
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
// Sandbox is replaced wholesale when present; per-field merging
// 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
.sandbox
.as_ref()
.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 \
name = COALESCE($2, name), \
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
@@ -157,7 +177,7 @@ impl ScriptRepository for PostgresScriptRepository {
version = version + 1, \
updated_at = NOW() \
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",
)
.bind(id.into_inner())
@@ -169,10 +189,18 @@ impl ScriptRepository for PostgresScriptRepository {
.bind(patch.memory_limit_mb)
.bind(sandbox_json)
.fetch_optional(&self.pool)
.await?;
.await;
row.map(Into::into)
.ok_or(ScriptRepositoryError::NotFound(id))
match res {
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> {
@@ -191,6 +219,7 @@ impl ScriptRepository for PostgresScriptRepository {
#[derive(sqlx::FromRow)]
struct ScriptRow {
id: uuid::Uuid,
app_id: uuid::Uuid,
name: String,
description: Option<String>,
version: i32,
@@ -211,6 +240,7 @@ impl From<ScriptRow> for Script {
let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default();
Self {
id: r.id.into(),
app_id: r.app_id.into(),
name: r.name,
description: r.description,
version: r.version,
@@ -284,7 +314,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
offset: i64,
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> {
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, \
response_code, response_body, \
logs, duration_ms, status, created_at \
@@ -306,6 +336,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
#[derive(sqlx::FromRow)]
struct ExecutionLogRow {
id: uuid::Uuid,
app_id: uuid::Uuid,
script_id: uuid::Uuid,
request_id: uuid::Uuid,
request_path: Option<String>,
@@ -331,6 +362,7 @@ impl From<ExecutionLogRow> for ExecutionLog {
};
Self {
id: r.id,
app_id: r.app_id.into(),
script_id: r.script_id.into(),
request_id: RequestId::from(r.request_id),
request_path: r.request_path.unwrap_or_default(),

View File

@@ -13,39 +13,49 @@ use axum::{
Json, Router,
};
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 uuid::Uuid;
use crate::repo::ScriptRepositoryError;
use crate::app_domain_repo::AppDomainRepository;
use crate::repo::{ScriptRepository, ScriptRepositoryError};
use crate::route_repo::{NewRoute, RouteRepository};
pub struct RouteAdminState<RR> {
pub struct RouteAdminState<RR, SR> {
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>,
}
impl<RR> Clone for RouteAdminState<RR> {
impl<RR, SR> Clone for RouteAdminState<RR, SR> {
fn clone(&self) -> Self {
Self {
routes: self.routes.clone(),
scripts: self.scripts.clone(),
domains: self.domains.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
RR: RouteRepository + 'static,
SR: ScriptRepository + 'static,
{
Router::new()
.route(
"/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:check", post(check_route::<RR>))
.route("/routes:match", post(match_route::<RR>))
.route("/routes/{route_id}", delete(delete_route::<RR, SR>))
.route("/routes:check", post(check_route::<RR, SR>))
.route("/routes:match", post(match_route::<RR, SR>))
.with_state(state)
}
@@ -67,6 +77,10 @@ pub struct CreateRouteRequest {
#[derive(Debug, Deserialize)]
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,
#[serde(default)]
pub host: String,
@@ -84,6 +98,9 @@ pub struct CheckRouteResponse {
#[derive(Debug, Deserialize)]
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,
#[serde(default = "default_method")]
pub method: String,
@@ -111,15 +128,15 @@ pub struct MatchedRoute {
// Handlers
// ----------------------------------------------------------------------------
async fn list_routes<RR: RouteRepository>(
State(state): State<RouteAdminState<RR>>,
async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>,
Path(script_id): Path<ScriptId>,
) -> Result<Json<Vec<Route>>, RouteApiError> {
Ok(Json(state.routes.list_for_script(script_id).await?))
}
async fn create_route<RR: RouteRepository>(
State(state): State<RouteAdminState<RR>>,
async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>,
Path(script_id): Path<ScriptId>,
Json(input): Json<CreateRouteRequest>,
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
@@ -130,8 +147,22 @@ async fn create_route<RR: RouteRepository>(
input.host_param_name.as_deref(),
)?;
// Within-kind conflict check against existing routes.
let existing = state.routes.list_all().await?;
// Look up the script's owning app — every route inherits it.
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(
&existing,
input.host_kind,
@@ -149,6 +180,7 @@ async fn create_route<RR: RouteRepository>(
let created = state
.routes
.create(NewRoute {
app_id,
script_id,
host_kind: input.host_kind,
host: input.host,
@@ -162,8 +194,8 @@ async fn create_route<RR: RouteRepository>(
Ok((StatusCode::CREATED, Json(created)))
}
async fn delete_route<RR: RouteRepository>(
State(state): State<RouteAdminState<RR>>,
async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>,
Path(route_id): Path<Uuid>,
) -> Result<StatusCode, RouteApiError> {
state.routes.delete(route_id).await?;
@@ -171,14 +203,14 @@ async fn delete_route<RR: RouteRepository>(
Ok(StatusCode::NO_CONTENT)
}
async fn check_route<RR: RouteRepository>(
State(state): State<RouteAdminState<RR>>,
async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>,
Json(input): Json<CheckRouteRequest>,
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
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(
&existing,
input.host_kind,
@@ -201,8 +233,8 @@ async fn check_route<RR: RouteRepository>(
}))
}
async fn match_route<RR: RouteRepository>(
State(state): State<RouteAdminState<RR>>,
async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>,
Json(input): Json<MatchRouteRequest>,
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
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 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 {
matched: result.map(|r| MatchedRoute {
route_id: r.matched.route_id,
@@ -263,12 +297,12 @@ fn first_conflict(
Ok(None)
}
async fn refresh_table<RR: RouteRepository>(
state: &RouteAdminState<RR>,
async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
state: &RouteAdminState<RR, SR>,
) -> Result<(), RouteApiError> {
let rows = state.routes.list_all().await?;
let compiled = compile_routes(&rows)?;
state.table.replace(compiled);
state.table.replace_all(compiled);
Ok(())
}
@@ -277,6 +311,7 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
.map(|r| {
Ok(CompiledRoute {
route_id: r.id,
app_id: r.app_id,
script_id: r.script_id,
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
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()
}
/// 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
// ----------------------------------------------------------------------------
@@ -304,6 +412,15 @@ pub enum RouteApiError {
#[error("bad request: {0}")]
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}")]
Repo(#[from] ScriptRepositoryError),
}
@@ -326,10 +443,21 @@ impl IntoResponse for RouteApiError {
StatusCode::UNPROCESSABLE_ENTITY,
serde_json::json!({ "error": self.to_string() }),
),
Self::Repo(ScriptRepositoryError::NotFound(_)) => (
Self::ScriptNotFound(_) | Self::Repo(ScriptRepositoryError::NotFound(_)) => (
StatusCode::NOT_FOUND,
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(_)) => (
StatusCode::CONFLICT,
serde_json::json!({ "error": self.to_string() }),

View File

@@ -1,10 +1,10 @@
//! CRUD over the `routes` table.
//!
//! The orchestrator's `RouteTable` is repopulated from this repo after
//! every write — see the route_admin module for the binding.
//! The orchestrator's `AppRouteTables` is repopulated from this repo
//! after every write — see the route_admin module for the binding.
use async_trait::async_trait;
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId};
use sqlx::PgPool;
use uuid::Uuid;
@@ -12,6 +12,7 @@ use crate::repo::ScriptRepositoryError;
#[derive(Debug, Clone)]
pub struct NewRoute {
pub app_id: AppId,
pub script_id: ScriptId,
pub host_kind: HostKind,
pub host: String,
@@ -24,12 +25,21 @@ pub struct NewRoute {
#[async_trait]
pub trait RouteRepository: Send + Sync {
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(
&self,
script_id: ScriptId,
) -> Result<Vec<Route>, ScriptRepositoryError>;
async fn create(&self, input: NewRoute) -> Result<Route, 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 {
@@ -47,7 +57,7 @@ impl PostgresRouteRepository {
impl RouteRepository for PostgresRouteRepository {
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
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 \
FROM routes ORDER BY created_at",
)
@@ -56,12 +66,24 @@ impl RouteRepository for PostgresRouteRepository {
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(
&self,
script_id: ScriptId,
) -> Result<Vec<Route>, ScriptRepositoryError> {
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 \
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> {
let res = sqlx::query_as::<_, RouteRow>(
"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 \
) VALUES ($1, $2, $3, $4, $5, $6, $7) \
RETURNING id, script_id, host_kind, host, host_param_name, \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, created_at",
)
.bind(input.app_id.into_inner())
.bind(input.script_id.into_inner())
.bind(host_kind_str(input.host_kind))
.bind(&input.host)
@@ -112,6 +135,24 @@ impl RouteRepository for PostgresRouteRepository {
}
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 {
@@ -133,6 +174,7 @@ const fn path_kind_str(k: PathKind) -> &'static str {
#[derive(sqlx::FromRow)]
struct RouteRow {
id: Uuid,
app_id: Uuid,
script_id: Uuid,
host_kind: String,
host: String,
@@ -147,6 +189,7 @@ impl From<RouteRow> for Route {
fn from(r: RouteRow) -> Self {
Self {
id: r.id,
app_id: r.app_id.into(),
script_id: r.script_id.into(),
host_kind: match r.host_kind.as_str() {
"strict" => HostKind::Strict,

View File

@@ -3,6 +3,43 @@
## 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
id: uuid NOT NULL default=gen_random_uuid()
script_id: uuid NOT NULL
@@ -16,6 +53,7 @@ table: execution_logs
duration_ms: integer NOT NULL default=0
status: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
app_id: uuid NOT NULL
table: routes
id: uuid NOT NULL default=gen_random_uuid()
@@ -27,6 +65,7 @@ table: routes
path: text NOT NULL
method: text NULL
created_at: timestamp with time zone NOT NULL default=now()
app_id: uuid NOT NULL
table: scripts
id: uuid NOT NULL default=gen_random_uuid()
@@ -39,42 +78,94 @@ table: scripts
created_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
app_id: uuid NOT NULL
## 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:
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_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
indexes on routes:
routes_app_id_idx: public.routes USING btree (app_id)
routes_lookup_idx: public.routes USING btree (host_kind, host)
routes_pkey: public.routes USING btree (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:
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)
## 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:
[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
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
constraints on routes:
[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])))
[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
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
constraints on scripts:
[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)))
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
## applied migrations
0001: init
0002: sandbox
0003: routes
0004: admin auth
0005: apps

View File

@@ -17,22 +17,26 @@ use axum::{
use chrono::Utc;
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
use picloud_shared::{
ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
AppId, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
};
use serde_json::Value as Json_;
use uuid::Uuid;
use crate::client::ExecutorClient;
use crate::resolver::{ResolverError, ScriptResolver};
use crate::routing::RouteTable;
use crate::routing::{AppDomainTable, RouteTable};
/// State shared by data-plane handlers.
pub struct DataPlaneState<E, R> {
pub executor: Arc<E>,
pub resolver: Arc<R>,
pub log_sink: Arc<dyn ExecutionLogSink>,
/// Routing table for user-defined paths. Shared with the manager
/// (admin router writes; this side reads).
/// Host → app_id resolver. Run before `routes` to filter to the
/// 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>,
}
@@ -42,6 +46,7 @@ impl<E, R> Clone for DataPlaneState<E, R> {
executor: self.executor.clone(),
resolver: self.resolver.clone(),
log_sink: self.log_sink.clone(),
app_domains: self.app_domains.clone(),
routes: self.routes.clone(),
}
}
@@ -109,6 +114,7 @@ where
// audit-visible platform — but a sink failure must not mask the
// user-facing result, so we only log a warning if it fails.
let log = build_execution_log(
script.app_id,
id,
request_id,
request_path,
@@ -145,7 +151,23 @@ where
.to_string();
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((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
@@ -191,6 +213,7 @@ where
let finished = Utc::now();
let log = build_execution_log(
script.app_id,
matched.matched.script_id,
request_id,
request_path,
@@ -292,6 +315,7 @@ fn exec_response_to_http(resp: ExecResponse) -> Response {
#[allow(clippy::too_many_arguments)]
fn build_execution_log(
app_id: AppId,
script_id: ScriptId,
request_id: RequestId,
request_path: String,
@@ -336,6 +360,7 @@ fn build_execution_log(
ExecutionLog {
id: Uuid::new_v4(),
app_id,
script_id,
request_id,
request_path,

View 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));
}
}

View File

@@ -40,10 +40,13 @@ pub struct Matched {
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)]
pub struct CompiledRoute {
pub route_id: uuid::Uuid,
pub app_id: picloud_shared::AppId,
pub script_id: picloud_shared::ScriptId,
pub host: HostPattern,
pub path: PathPattern,
@@ -298,12 +301,13 @@ fn match_param(segs: &[PathSegment], request_path: &str) -> Option<BTreeMap<Stri
mod tests {
use super::super::pattern::parse_path;
use super::*;
use picloud_shared::{PathKind, ScriptId};
use picloud_shared::{AppId, PathKind, ScriptId};
use uuid::Uuid;
fn route(host: HostPattern, path_kind: PathKind, raw: &str) -> CompiledRoute {
CompiledRoute {
route_id: Uuid::new_v4(),
app_id: AppId::new(),
script_id: ScriptId::new(),
host,
path: parse_path(path_kind, raw).unwrap(),

View File

@@ -17,12 +17,16 @@
//! * **Host dispatch** — `strict > wildcard > any`; longest matching
//! wildcard suffix breaks ties between wildcards.
pub mod app_domains;
pub mod conflict;
pub mod matcher;
pub mod pattern;
pub mod table;
pub use app_domains::{AppDomainTable, CompiledAppDomain};
pub use conflict::{conflicts, ConflictReason};
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;

View File

@@ -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
// ----------------------------------------------------------------------------
@@ -393,6 +493,49 @@ mod tests {
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]
fn leading_literal_count_works() {
let exact = parse_path(PathKind::Exact, "/foo/users").unwrap();

View File

@@ -1,17 +1,22 @@
//! In-memory snapshot of compiled routes, shared by manager (writes)
//! and orchestrator (reads).
//! In-memory snapshot of compiled routes, partitioned by `app_id`.
//!
//! Holds an `arc-swap`-style lock-free hand-off so the dispatcher can
//! read without contending against the writer; in MVP-single-process
//! we just use `RwLock` and accept the cheap contention.
//! The orchestrator looks up the app's slice by id after `AppDomainTable`
//! has resolved Host → app_id, then runs the existing matcher on that
//! slice. The matcher is unchanged; this type is just a per-app bucket.
use std::collections::HashMap;
use std::sync::RwLock;
use picloud_shared::AppId;
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)]
pub struct RouteTable {
inner: RwLock<Vec<CompiledRoute>>,
inner: RwLock<HashMap<AppId, Vec<CompiledRoute>>>,
}
impl RouteTable {
@@ -20,24 +25,54 @@ impl RouteTable {
Self::default()
}
/// Replace the whole table atomically. The manager calls this after
/// each successful route CRUD operation (by re-reading from DB).
pub fn replace(&self, routes: Vec<CompiledRoute>) {
/// Replace every per-app slice atomically. The manager calls this
/// after each successful route CRUD operation; in cluster mode the
/// 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");
*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]
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");
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
/// the dashboard's "list routes" admin endpoint.
/// Returns a clone of the currently compiled routes for `app_id`;
/// intended for admin endpoints like "list this app's routes".
#[must_use]
pub fn snapshot(&self) -> Vec<CompiledRoute> {
self.inner.read().expect("route table poisoned").clone()
pub fn snapshot_for_app(&self, app_id: AppId) -> Vec<CompiledRoute> {
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()
}
}

View File

@@ -10,13 +10,15 @@ use axum::middleware::from_fn_with_state;
use axum::{routing::get, Json, Router};
use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{
admin_router, admins_router, auth_router, compile_routes, migrations, require_admin,
route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
AuthState, PostgresAdminSessionRepository, PostgresAdminUserRepository,
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
admin_router, admins_router, apps_api, apps_router, auth_router, compile_routes, migrations,
require_admin, route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository,
AdminsState, AppDomainRepository, AppRepository, AppsState, AuthState,
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresAppDomainRepository,
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::{
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 log_repo = Arc::new(PostgresExecutionLogRepository::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.
let route_table = Arc::new(RouteTable::new());
let initial = route_repo.list_all().await?;
let compiled = compile_routes(&initial)
.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(
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 admin = AdminState {
repo: Arc::new(PostgresScriptRepoHandle(script_repo)),
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
logs: log_repo,
apps: apps_repo.clone(),
validator: engine as Arc<dyn ScriptValidator>,
sandbox_ceiling: SandboxCeiling::from_env(),
};
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(),
};
let data_plane = DataPlaneState {
executor,
resolver,
log_sink,
app_domains: app_domain_table.clone(),
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 {
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(route_admin_router(route_admin))
.merge(admins_router(admins_state))
.merge(apps_router(apps_state))
.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()
.nest("/admin", auth_router(auth_state))
.nest("/admin", guarded_admin)
@@ -201,6 +238,12 @@ impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
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(
&self,
input: picloud_manager_core::NewScript,

View File

@@ -11,7 +11,9 @@ use std::time::Duration;
use picloud::{build_app, init_db, AuthDeps};
use picloud_manager_core::{
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;
@@ -44,6 +46,23 @@ async fn run_server() -> anyhow::Result<()> {
let auth = AuthDeps::from_pool(pool.clone());
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
// growing unbounded. Expired rows are also rejected at lookup time,
// so a delayed sweep can't extend session lifetimes.

View File

@@ -23,6 +23,13 @@ use sqlx::PgPool;
/// the bearer token into the TestServer as a default header so every
/// request in the test passes the `require_admin` middleware.
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;
let auth = picloud::AuthDeps::from_pool(pool.clone());
@@ -45,7 +52,32 @@ async fn server(pool: PgPool) -> TestServer {
.expect("login should return token")
.to_string();
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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
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
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "echo",
"description": "test",
"source": "#{ statusCode: 200, body: 42 }",
}))
}),
))
.await;
r.assert_status(axum::http::StatusCode::CREATED);
let body: Value = r.json();
assert_eq!(body["name"], "echo");
assert_eq!(body["version"], 1);
assert_eq!(body["timeout_seconds"], 30);
assert_eq!(body["app_id"], app_id);
assert!(body["id"].as_str().is_some());
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
let r = server(pool)
.await
let (s, app_id) = server_with_app(pool).await;
let r = s
.post("/api/v1/admin/scripts")
.json(&json!({ "name": "broken", "source": "@@@ not rhai @@@" }))
.json(&with_app(
&app_id,
json!({ "name": "broken", "source": "@@@ not rhai @@@" }),
))
.await;
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
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")
.json(&json!({ "name": "dup", "source": "42" }))
.json(&with_app(&app_id, json!({ "name": "dup", "source": "42" })))
.await
.assert_status(axum::http::StatusCode::CREATED);
let r = s
.post("/api/v1/admin/scripts")
.json(&json!({ "name": "dup", "source": "43" }))
.json(&with_app(&app_id, json!({ "name": "dup", "source": "43" })))
.await;
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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
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"] {
s.post("/api/v1/admin/scripts")
.json(&json!({ "name": name, "source": "1" }))
.json(&with_app(&app_id, json!({ "name": name, "source": "1" })))
.await
.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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
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
.post("/api/v1/admin/scripts")
.json(&json!({ "name": "u", "source": "1" }))
.json(&with_app(&app_id, json!({ "name": "u", "source": "1" })))
.await
.json();
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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
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
.post("/api/v1/admin/scripts")
.json(&json!({ "name": "u", "source": "1" }))
.json(&with_app(&app_id, json!({ "name": "u", "source": "1" })))
.await
.json();
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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
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
.post("/api/v1/admin/scripts")
.json(&json!({ "name": "d", "source": "1" }))
.json(&with_app(&app_id, json!({ "name": "d", "source": "1" })))
.await
.json();
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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
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
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "echo",
"source": "#{ statusCode: 200, body: ctx.request.body }",
}))
}),
))
.await
.json();
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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
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
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "header-test",
"source": "#{ statusCode: 201, headers: #{ \"x-tag\": \"on\" }, body: 1 }",
}))
}),
))
.await
.json();
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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
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
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "logger",
"source": "log::info(\"called\", #{ marker: 7 }); #{ statusCode: 200, body: \"done\" }",
}))
}),
))
.await
.json();
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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
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
.post("/api/v1/admin/scripts")
.json(&json!({ "name": "no-sandbox", "source": "1" }))
.json(&with_app(
&app_id,
json!({ "name": "no-sandbox", "source": "1" }),
))
.await
.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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
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
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "tight",
"source": "1",
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
}))
}),
))
.await
.json();
assert_eq!(
@@ -359,14 +413,17 @@ async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
// 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
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "too-loose",
"source": "1",
"sandbox": { "max_operations": 100_000_000 }
}))
}),
))
.await;
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
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
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "typo",
"source": "1",
"sandbox": { "max_operashuns": 500 }
}))
}),
))
.await;
// serde's deny_unknown_fields causes axum to reject with 422 or
// 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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
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.
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "tight-exec",
"source": "let n = 0; for i in 0..10000 { n += 1; } n",
"sandbox": { "max_operations": 500 }
}))
}),
))
.await
.json();
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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
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
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "patch-target",
"source": "1",
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
}))
}),
))
.await
.json();
let id = created["id"].as_str().unwrap();
@@ -455,10 +521,10 @@ async fn update_replaces_sandbox_wholesale(pool: PgPool) {
// 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
.post("/api/v1/admin/scripts")
.json(&json!({ "name": name, "source": source }))
.json(&with_app(app_id, json!({ "name": name, "source": source })))
.await
.json();
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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
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(
&s,
&app_id,
"greet",
"#{ statusCode: 200, body: #{ msg: \"hi\", path: ctx.request.path } }",
)
@@ -483,7 +550,7 @@ async fn route_exact_dispatches_to_script(pool: PgPool) {
.await
.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();
let body: Value = r.json();
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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
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(
&s,
&app_id,
"greet-name",
"#{ statusCode: 200, body: #{ name: ctx.request.params.name } }",
)
@@ -509,7 +577,7 @@ async fn route_param_captures_path_vars(pool: PgPool) {
.await
.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();
let body: Value = r.json();
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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
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(
&s,
&app_id,
"echo-prefix",
"#{ statusCode: 200, body: #{ rest: ctx.request.rest } }",
)
@@ -534,19 +603,28 @@ async fn route_prefix_captures_rest(pool: PgPool) {
.await
.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();
let body: Value = r.json();
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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_query_string_exposed_to_script(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(&s, "qs", "#{ statusCode: 200, body: ctx.request.query }").await;
let (s, app_id) = server_with_app(pool).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"))
.json(&json!({
"host_kind": "any",
@@ -556,7 +634,7 @@ async fn route_query_string_exposed_to_script(pool: PgPool) {
.await
.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();
let body: Value = r.json();
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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_invalid_pattern_returns_422(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(&s, "x", "1").await;
let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(&s, &app_id, "x", "1").await;
let r = s
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
@@ -581,8 +659,8 @@ async fn route_invalid_pattern_returns_422(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_conflict_returns_409(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(&s, "x", "1").await;
let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(&s, &app_id, "x", "1").await;
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
"host_kind": "any",
@@ -608,8 +686,8 @@ async fn route_conflict_returns_409(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_reserved_path_returns_422(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(&s, "x", "1").await;
let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(&s, &app_id, "x", "1").await;
let r = s
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
@@ -624,8 +702,8 @@ async fn route_reserved_path_returns_422(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_match_preview_endpoint(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(&s, "g", "1").await;
let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(&s, &app_id, "g", "1").await;
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
"host_kind": "any",
@@ -637,7 +715,11 @@ async fn route_match_preview_endpoint(pool: PgPool) {
let r = s
.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;
r.assert_status_ok();
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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_delete_removes_dispatch(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(&s, "g", "#{ statusCode: 200, body: 1 }").await;
let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(&s, &app_id, "g", "#{ statusCode: 200, body: 1 }").await;
let created: Value = s
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
@@ -661,27 +743,35 @@ async fn route_delete_removes_dispatch(pool: PgPool) {
.json();
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}"))
.await
.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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
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(
&s,
&app_id,
"by-param",
"#{ statusCode: 200, body: #{ tag: \"param\" } }",
)
.await;
let id_pr = create_basic_script(
&s,
&app_id,
"by-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);
// 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();
assert_eq!(body["tag"], "param");
// 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();
assert_eq!(body2["tag"], "prefix");
}
@@ -718,7 +808,7 @@ async fn route_specificity_param_beats_prefix(pool: PgPool) {
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn root_returns_404_when_no_route(pool: PgPool) {
let s = server(pool).await;
let r = s.get("/").await;
let r = s.get("/").add_header("host", "localhost").await;
r.assert_status_not_found();
}
@@ -731,22 +821,325 @@ async fn version_includes_public_base_url(pool: PgPool) {
let v: Value = r.json();
assert!(v["public_base_url"].is_string());
assert_eq!(v["api"], 1);
assert_eq!(v["schema"], 4);
assert_eq!(v["schema"], 5);
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"]
#[sqlx::test(migrations = "../manager-core/migrations")]
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
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "boom",
"source": "1 / 0",
}))
}),
))
.await
.json();
let id = created["id"].as_str().unwrap();

53
crates/shared/src/app.rs Normal file
View 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>,
}

View File

@@ -11,4 +11,10 @@ pub enum Error {
#[error("invalid script source: {0}")]
InvalidScript(String),
#[error("app not found: {0}")]
AppNotFound(crate::AppId),
#[error("domain claim conflict: {0}")]
DomainConflict(String),
}

View File

@@ -4,13 +4,16 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{RequestId, ScriptId};
use crate::{AppId, RequestId, ScriptId};
/// One row in the `execution_logs` table. Same shape flows through the
/// `ExecutionLogSink` trait and the `GET /scripts/{id}/logs` response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionLog {
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 request_id: RequestId,

View File

@@ -51,3 +51,4 @@ id_type!(ScriptId);
id_type!(ExecutionId);
id_type!(RequestId);
id_type!(AdminUserId);
id_type!(AppId);

View File

@@ -4,6 +4,7 @@
//! that core's crate. Things here must be genuinely shared (IDs, the Script
//! entity, error roots, transport DTOs).
pub mod app;
pub mod error;
pub mod execution_log;
pub mod ids;
@@ -14,9 +15,10 @@ pub mod script;
pub mod validator;
pub mod version;
pub use app::{App, AppDomain, DomainShape};
pub use error::Error;
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 route::{HostKind, PathKind, Route};
pub use sandbox::ScriptSandbox;

View File

@@ -7,7 +7,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::ScriptId;
use crate::{AppId, ScriptId};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
@@ -40,6 +40,10 @@ pub enum PathKind {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Route {
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 host_kind: HostKind,

View File

@@ -1,7 +1,7 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{ScriptId, ScriptSandbox};
use crate::{AppId, ScriptId, ScriptSandbox};
/// A user-uploaded Rhai script and its execution configuration.
///
@@ -11,6 +11,10 @@ use crate::{ScriptId, ScriptSandbox};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Script {
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 description: Option<String>,
pub version: i32,

View File

@@ -21,6 +21,7 @@ export interface ScriptSandbox {
export interface Script {
id: string;
app_id: string;
name: string;
description: string | null;
version: number;
@@ -32,11 +33,64 @@ export interface Script {
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 PathKind = 'exact' | 'prefix' | 'param';
export interface Route {
id: string;
app_id: string;
script_id: string;
host_kind: HostKind;
host: string;
@@ -106,6 +160,7 @@ export interface ExecutionLog {
}
export interface CreateScriptInput {
app_id: string;
name: string;
description?: string | null;
source: string;
@@ -257,20 +312,23 @@ export const api = {
}),
remove: (routeId: string) =>
adminRequest<null>(`/api/v1/admin/routes/${routeId}`, { method: 'DELETE' }),
check: (input: RouteInput) =>
check: (appId: string, input: RouteInput) =>
adminRequest<CheckRouteResponse>('/api/v1/admin/routes:check', {
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', {
method: 'POST',
body: JSON.stringify({ url, method })
body: JSON.stringify({ app_id: appId, url, method })
})
},
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}`),
create: (input: CreateScriptInput) =>
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 (
id: string,
body: unknown,

View File

@@ -45,7 +45,7 @@
<header>
<a href={base + '/'} class="brand">PiCloud</a>
<nav>
<a href={base + '/'}>Scripts</a>
<a href={base + '/apps'}>Apps</a>
<a href={base + '/admins'}>Admins</a>
</nav>
<div class="spacer"></div>

View File

@@ -1,242 +1,20 @@
<script lang="ts">
import { base } from '$app/paths';
import { api, ApiError, type Script } from '$lib/api';
import CodeEditor from '$lib/CodeEditor.svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
const SAMPLE_SOURCE = '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
let scripts = $state<Script[] | null>(null);
let listError = $state<string | null>(null);
let loading = $state(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();
// Dashboard entry: always lands on the apps list now (multi-app
// scoping makes "scripts at root" no longer meaningful — every
// script lives inside an app).
onMount(() => {
void goto(`${base}/apps`, { replaceState: true });
});
</script>
<section>
<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>
<p class="muted">Redirecting…</p>
<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 {
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>

View 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>

View 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>

View File

@@ -38,6 +38,8 @@
let scriptLoading = $state(true);
let info = $state<VersionInfo | null>(null);
let appSlug = $state<string | null>(null);
async function loadScript() {
scriptLoading = true;
scriptError = null;
@@ -48,6 +50,14 @@
editableDescription = script.description ?? '';
editableTimeout = script.timeout_seconds;
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) {
scriptError = e instanceof Error ? e.message : String(e);
script = null;
@@ -251,8 +261,9 @@
async function runPreview() {
previewResult = null;
if (!script) return;
try {
const r = await api.routes.match(previewUrl, previewMethod);
const r = await api.routes.match(script.app_id, previewUrl, previewMethod);
if (r.matched) {
const ours = r.matched.script_id === id;
const tag = ours ? '✓ matches THIS script' : '⚠ matches a DIFFERENT script';
@@ -368,6 +379,13 @@
{:else if script}
<header class="page-header">
<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>
<p class="muted">
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
@@ -756,6 +774,23 @@
align-items: flex-start;
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 {
margin: 0;
font-size: 1.5rem;

View File

@@ -128,8 +128,8 @@ A surface can hit its own `1.0` independently of the product. The SDK in particu
|---|---|
| Product | `0.5.1` |
| SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) |
| API | `1` |
| Schema | `3` (matches `migrations/0003_routes.sql`) |
| API | `1` (additive: `Script.app_id`, `Route.app_id`, `ExecutionLog.app_id`, new `/api/v1/admin/apps/*` endpoints, `?app=` filter on script list) |
| Schema | `5` (matches `migrations/0005_apps.sql`) |
| Wire | `1` (reserved; cluster mode not implemented) |
Read live from `GET /version` on any running instance.

View File

@@ -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.
@@ -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.
**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.