From 4c41374db4861155f59a17a6c9c3dfd2d795a776 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 25 May 2026 21:03:05 +0200 Subject: [PATCH] feat(manager-core,orchestrator-core): multi-app scoping (Phase 3b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 2 +- crates/manager-core/migrations/0005_apps.sql | 117 ++++ crates/manager-core/seeds/hello.rhai | 15 + crates/manager-core/src/api.rs | 56 +- crates/manager-core/src/app_bootstrap.rs | 92 +++ crates/manager-core/src/app_domain_repo.rs | 152 ++++ crates/manager-core/src/app_repo.rs | 380 ++++++++++ crates/manager-core/src/apps_api.rs | 510 ++++++++++++++ crates/manager-core/src/lib.rs | 8 + crates/manager-core/src/log_sink.rs | 5 +- crates/manager-core/src/repo.rs | 60 +- crates/manager-core/src/route_admin.rs | 182 ++++- crates/manager-core/src/route_repo.rs | 59 +- crates/manager-core/tests/expected_schema.txt | 95 ++- crates/orchestrator-core/src/api.rs | 35 +- .../src/routing/app_domains.rs | 165 +++++ .../orchestrator-core/src/routing/matcher.rs | 8 +- crates/orchestrator-core/src/routing/mod.rs | 6 +- .../orchestrator-core/src/routing/pattern.rs | 143 ++++ crates/orchestrator-core/src/routing/table.rs | 69 +- crates/picloud/src/lib.rs | 63 +- crates/picloud/src/main.rs | 21 +- crates/picloud/tests/api.rs | 599 +++++++++++++--- crates/shared/src/app.rs | 53 ++ crates/shared/src/error.rs | 6 + crates/shared/src/execution_log.rs | 5 +- crates/shared/src/ids.rs | 1 + crates/shared/src/lib.rs | 4 +- crates/shared/src/route.rs | 6 +- crates/shared/src/script.rs | 6 +- dashboard/src/lib/api.ts | 113 ++- dashboard/src/routes/+layout.svelte | 2 +- dashboard/src/routes/+page.svelte | 238 +------ dashboard/src/routes/apps/+page.svelte | 305 ++++++++ dashboard/src/routes/apps/[slug]/+page.svelte | 653 ++++++++++++++++++ .../src/routes/scripts/[id]/+page.svelte | 37 +- docs/versioning.md | 4 +- serverless_cloud_blueprint.md | 14 +- 38 files changed, 3848 insertions(+), 441 deletions(-) create mode 100644 crates/manager-core/migrations/0005_apps.sql create mode 100644 crates/manager-core/seeds/hello.rhai create mode 100644 crates/manager-core/src/app_bootstrap.rs create mode 100644 crates/manager-core/src/app_domain_repo.rs create mode 100644 crates/manager-core/src/app_repo.rs create mode 100644 crates/manager-core/src/apps_api.rs create mode 100644 crates/orchestrator-core/src/routing/app_domains.rs create mode 100644 crates/shared/src/app.rs create mode 100644 dashboard/src/routes/apps/+page.svelte create mode 100644 dashboard/src/routes/apps/[slug]/+page.svelte diff --git a/CLAUDE.md b/CLAUDE.md index 57adbcb..bd5ec44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/crates/manager-core/migrations/0005_apps.sql b/crates/manager-core/migrations/0005_apps.sql new file mode 100644 index 0000000..43583b2 --- /dev/null +++ b/crates/manager-core/migrations/0005_apps.sql @@ -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: for shape='exact' +-- wildcard: 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); diff --git a/crates/manager-core/seeds/hello.rhai b/crates/manager-core/seeds/hello.rhai new file mode 100644 index 0000000..da80d42 --- /dev/null +++ b/crates/manager-core/seeds/hello.rhai @@ -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}!` } +}; diff --git a/crates/manager-core/src/api.rs b/crates/manager-core/src/api.rs index abfca9b..b949a75 100644 --- a/crates/manager-core/src/api.rs +++ b/crates/manager-core/src/api.rs @@ -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 { pub repo: Arc, pub logs: Arc, + /// App lookups: validates `app_id` on create, resolves `?app=` + /// filter on list. Trait-object so apps_repo can stay separate. + pub apps: Arc, pub validator: Arc, pub sandbox_ceiling: SandboxCeiling, } @@ -36,6 +40,7 @@ impl Clone for AdminState { 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, 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, +} + #[derive(Debug, Deserialize)] pub struct UpdateScriptRequest { pub name: Option, @@ -113,8 +129,32 @@ where async fn list_scripts( State(state): State>, + Query(q): Query, ) -> Result>, ApiError> { - Ok(Json(state.repo.list().await?)) + 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=` OR `?app=`. 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 { + if let Ok(uuid) = ident.parse::() { + 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( @@ -135,9 +175,15 @@ async fn create_script( ) -> Result<(StatusCode, Json -
- - - {#if showCreate} -
-
- - -
- - {#if createError} -
{createError}
- {/if} -
- -
-
- {/if} - - {#if loading} -

Loading…

- {:else if listError} -
- Could not load scripts. -

{listError}

- -
- {:else if scripts && scripts.length === 0} -

No scripts yet. Create one above to get started.

- {:else if scripts} - - {/if} -
+

Redirecting…

diff --git a/dashboard/src/routes/apps/+page.svelte b/dashboard/src/routes/apps/+page.svelte new file mode 100644 index 0000000..a553210 --- /dev/null +++ b/dashboard/src/routes/apps/+page.svelte @@ -0,0 +1,305 @@ + + +
+ + + {#if showCreate} +
submitCreate(e)}> +
+ + +
+ + {#if createHistoricalConflict} +
+ Slug previously redirected. +

+ {createSlug} currently redirects to + {createHistoricalConflict.slug}. Using it here will break any + external links that still target the old slug. +

+
+ + +
+
+ {:else if createError} +
{createError}
+ {/if} + {#if !createHistoricalConflict} +
+ +
+ {/if} +
+ {/if} + + {#if loading} +

Loading…

+ {:else if listError} +
+ Could not load apps. +

{listError}

+ +
+ {:else if apps && apps.length === 0} +

No apps yet. Create one above to get started.

+ {:else if apps} + + {/if} +
+ + diff --git a/dashboard/src/routes/apps/[slug]/+page.svelte b/dashboard/src/routes/apps/[slug]/+page.svelte new file mode 100644 index 0000000..c661e4b --- /dev/null +++ b/dashboard/src/routes/apps/[slug]/+page.svelte @@ -0,0 +1,653 @@ + + +{#if loading && !app} +

Loading…

+{:else if loadError && !app} +
+ Could not load app. +

{loadError}

+ Back to apps +
+{:else if app} + + + + + {#if activeTab === 'scripts'} +
+
+

Scripts

+ +
+ + {#if showCreateScript} +
+
+ + +
+ + {#if createScriptError} +
{createScriptError}
+ {/if} +
+ +
+
+ {/if} + + {#if scripts.length === 0} +

No scripts in this app yet.

+ {:else} + + {/if} +
+ {:else if activeTab === 'domains'} +
+

Domain claims

+

+ Hosts this app answers on. Routes inside this app can only bind to + these. Use app.example.com for exact, *.example.com for + wildcard, or {'{'}tenant{'}'}.example.com to bind a capture. +

+
+ + +
+ {#if createDomainError} +
{createDomainError}
+ {/if} + {#if domains.length === 0} +

No domain claims yet.

+ {:else} +
    + {#each domains as d (d.id)} +
  • +
    + {d.pattern} + — {d.shape} +
    + +
  • + {/each} +
+ {/if} +
+ {:else if activeTab === 'settings'} +
+

Settings

+
saveSettings(e)}> + + + + {#if slugTakeoverNeeded} +
+ Slug previously redirected. +

+ {editSlug} currently redirects to + {slugTakeoverNeeded.slug}. Renaming to it will break old + links. +

+
+ + +
+
+ {:else if settingsError} +
{settingsError}
+ {/if} + {#if !slugTakeoverNeeded} +
+ +
+ {/if} +
+ +
+

Delete app

+

+ Requires the app to have zero scripts and zero domain claims. +

+ +
+
+ {/if} +{/if} + + diff --git a/dashboard/src/routes/scripts/[id]/+page.svelte b/dashboard/src/routes/scripts/[id]/+page.svelte index 4ed4d42..207e320 100644 --- a/dashboard/src/routes/scripts/[id]/+page.svelte +++ b/dashboard/src/routes/scripts/[id]/+page.svelte @@ -38,6 +38,8 @@ let scriptLoading = $state(true); let info = $state(null); + let appSlug = $state(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}