Compare commits
11 Commits
878cbe9439
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c41374db4 | ||
|
|
6891496589 | ||
|
|
646bd55174 | ||
|
|
56de652f7a | ||
|
|
3d4c7b160b | ||
|
|
267c40f59c | ||
|
|
1dc53a0226 | ||
|
|
6cdb1244b8 | ||
|
|
bc8b512b56 | ||
|
|
a80e6d1ca4 | ||
|
|
0eaf4aee69 |
12
CLAUDE.md
12
CLAUDE.md
@@ -8,6 +8,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
|
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
|
||||||
|
|
||||||
|
**Current focus (Phase 4, v1.1):** data-plane SDKs — KV store, then document store, then HTTP client, then cron triggers. See blueprint §12. Phase 3 (admin auth + multi-app scoping) shipped; every v1.1+ table starts with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE` and every Rhai SDK call resolves its app from the execution context.
|
||||||
|
|
||||||
## Three-Service Architecture
|
## Three-Service Architecture
|
||||||
|
|
||||||
The platform splits into three logical services, each backed by a `*-core` library crate so the same logic runs in single-process MVP mode and split-process cluster mode:
|
The platform splits into three logical services, each backed by a `*-core` library crate so the same logic runs in single-process MVP mode and split-process cluster mode:
|
||||||
@@ -26,7 +28,7 @@ In MVP, all three run in one process (`picloud` binary). In cluster mode, each r
|
|||||||
|
|
||||||
Versioned API surfaces live under `/api/v{N}/...`. See [docs/versioning.md](docs/versioning.md) for the full scheme.
|
Versioned API surfaces live under `/api/v{N}/...`. See [docs/versioning.md](docs/versioning.md) for the full scheme.
|
||||||
|
|
||||||
- `/api/v1/admin/*` — manager (control plane: script CRUD, routes CRUD + check + match, logs, config)
|
- `/api/v1/admin/*` — manager (control plane: script CRUD, routes CRUD + check + match, logs, config; apps CRUD once Phase 3b lands)
|
||||||
- `/api/v1/execute/{id}` — orchestrator (data plane: invoke a script by ID, always-available bypass)
|
- `/api/v1/execute/{id}` — orchestrator (data plane: invoke a script by ID, always-available bypass)
|
||||||
- `/admin/*` — dashboard SPA (SvelteKit, `paths.base = '/admin'`)
|
- `/admin/*` — dashboard SPA (SvelteKit, `paths.base = '/admin'`)
|
||||||
- `/healthz` — liveness (string `"ok"`)
|
- `/healthz` — liveness (string `"ok"`)
|
||||||
@@ -37,6 +39,10 @@ Reserved path prefixes (rejected at route creation): `/api/`, `/admin/`, `/healt
|
|||||||
|
|
||||||
Caddy fronts everything. Same Caddyfile shape works for single-node and cluster — only upstream targets change.
|
Caddy fronts everything. Same Caddyfile shape works for single-node and cluster — only upstream targets change.
|
||||||
|
|
||||||
|
**Param syntax convention:** route paths use `:name` (e.g., `/users/:id`); domains (once apps land) use `{name}` (e.g., `{tenant}.example.com`). These are deliberately distinct — never use `:` in a domain context or `{}` in a route-path context.
|
||||||
|
|
||||||
|
**Two-phase dispatch (Phase 3b onward):** the orchestrator first resolves `Host` → app (most-specific domain claim wins), then runs that app's route trie. The route matcher itself is unchanged and never sees other apps' routes.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Rust 1.92+** workspace, pinned via `rust-toolchain.toml`
|
- **Rust 1.92+** workspace, pinned via `rust-toolchain.toml`
|
||||||
@@ -102,4 +108,6 @@ docs/
|
|||||||
|
|
||||||
## Out of MVP
|
## Out of MVP
|
||||||
|
|
||||||
Queue triggers, cron triggers, SMTP ingress, KV / docs / email / users / HTTP SDKs in scripts, interceptors, workflows, function-to-function `invoke()`, auth, multi-tenancy, secrets, metrics dashboard. All deferred to v1.1+ per the blueprint. Don't pre-build for them — but don't make decisions that close the door on them either.
|
Queue triggers, cron triggers, SMTP ingress, KV / docs / email / users / HTTP SDKs in scripts, interceptors, workflows, function-to-function `invoke()`, secrets, metrics dashboard. All deferred to v1.1+ per the blueprint. Don't pre-build for them — but don't make decisions that close the door on them either.
|
||||||
|
|
||||||
|
**Pulled forward to Phase 3 (pre-v1.1):** admin auth, multi-app scoping. Cross-app data sharing (export/import) stays at v1.3+; the initial cut enforces strict isolation. See blueprint §11.5.
|
||||||
|
|||||||
53
Cargo.lock
generated
53
Cargo.lock
generated
@@ -46,6 +46,18 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"blake2",
|
||||||
|
"cpufeatures",
|
||||||
|
"password-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "assert-json-diff"
|
name = "assert-json-diff"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
@@ -206,6 +218,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -1233,6 +1254,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-hash"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pear"
|
name = "pear"
|
||||||
version = "0.2.9"
|
version = "0.2.9"
|
||||||
@@ -1273,7 +1305,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud"
|
name = "picloud"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1297,7 +1329,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-executor"
|
name = "picloud-executor"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-executor-core",
|
"picloud-executor-core",
|
||||||
@@ -1309,7 +1341,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-executor-core"
|
name = "picloud-executor-core"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"picloud-shared",
|
"picloud-shared",
|
||||||
@@ -1323,7 +1355,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-manager"
|
name = "picloud-manager"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-manager-core",
|
"picloud-manager-core",
|
||||||
@@ -1335,17 +1367,22 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-manager-core"
|
name = "picloud-manager-core"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"picloud-orchestrator-core",
|
"picloud-orchestrator-core",
|
||||||
"picloud-shared",
|
"picloud-shared",
|
||||||
|
"rand 0.8.6",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -1353,7 +1390,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-orchestrator"
|
name = "picloud-orchestrator"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-orchestrator-core",
|
"picloud-orchestrator-core",
|
||||||
@@ -1365,7 +1402,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-orchestrator-core"
|
name = "picloud-orchestrator-core"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1384,7 +1421,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-shared"
|
name = "picloud-shared"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
@@ -66,6 +66,12 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
|||||||
url = "2"
|
url = "2"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
|
||||||
|
# Auth (admin users + sessions)
|
||||||
|
argon2 = "0.5"
|
||||||
|
rand = { version = "0.8", features = ["getrandom"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|
||||||
|
|||||||
@@ -22,3 +22,11 @@ uuid.workspace = true
|
|||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
|
|
||||||
|
argon2.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio.workspace = true
|
||||||
|
|||||||
33
crates/manager-core/migrations/0004_admin_auth.sql
Normal file
33
crates/manager-core/migrations/0004_admin_auth.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
-- Phase 3a admin auth — see blueprint §11.4.
|
||||||
|
--
|
||||||
|
-- Per-user platform-operator accounts (distinct from the v1.1+ `users`
|
||||||
|
-- table, which is for script-end users). Every authenticated admin is a
|
||||||
|
-- full admin in this cut; role/permission tables will be added later
|
||||||
|
-- without touching this schema.
|
||||||
|
--
|
||||||
|
-- `admin_sessions.token_hash` stores SHA-256 of the raw token; the raw
|
||||||
|
-- value only ever exists in the login response, the HttpOnly cookie, and
|
||||||
|
-- bearer-token requests. Cascade on user delete kills the user's sessions
|
||||||
|
-- automatically — which is also why deactivating a user can simply wipe
|
||||||
|
-- their rows instead of marking each session expired.
|
||||||
|
|
||||||
|
CREATE TABLE admin_users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_login_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE admin_sessions (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX admin_sessions_user_idx ON admin_sessions (user_id);
|
||||||
|
CREATE INDEX admin_sessions_expiry_idx ON admin_sessions (expires_at);
|
||||||
117
crates/manager-core/migrations/0005_apps.sql
Normal file
117
crates/manager-core/migrations/0005_apps.sql
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
-- Phase 3b multi-app scoping — see blueprint §11.5.
|
||||||
|
--
|
||||||
|
-- Apps are the top-level isolation boundary for scripts, routes, domain
|
||||||
|
-- claims and (forward) data. The orchestrator dispatches Host → app_id →
|
||||||
|
-- route trie; cross-app resource access is not possible.
|
||||||
|
--
|
||||||
|
-- This migration is unconditional:
|
||||||
|
-- 1. Creates the three new tables (apps, app_domains, app_slug_history).
|
||||||
|
-- 2. Always inserts a "default" app claiming `localhost` so existing
|
||||||
|
-- installs get a usable home for their pre-existing scripts/routes.
|
||||||
|
-- 3. Backfills app_id on scripts, routes, execution_logs from the
|
||||||
|
-- default app row, then promotes the columns to NOT NULL + FK.
|
||||||
|
--
|
||||||
|
-- Fresh installs get the same "default" app row; an in-Rust bootstrap
|
||||||
|
-- step (manager-core::app_bootstrap) decides whether to seed a Hello
|
||||||
|
-- World script into it. Doing the seed in Rust keeps it testable and
|
||||||
|
-- lets the script source live in a real .rhai file.
|
||||||
|
|
||||||
|
CREATE TABLE apps (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
-- URL-safe identifier; mutable via the rename flow which records
|
||||||
|
-- the prior slug in app_slug_history for permanent 301 redirects.
|
||||||
|
-- Format validation (`^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
|
||||||
|
-- check) lives in Rust handlers, not SQL.
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Domain claims. Most-specific wins at request time; same-shape
|
||||||
|
-- collisions are rejected at claim time via the UNIQUE(shape_key).
|
||||||
|
-- shape_key encoding:
|
||||||
|
-- exact:<lowercased-host> for shape='exact'
|
||||||
|
-- wildcard:<lowercased-suffix> for shape='wildcard' AND 'parameterized'
|
||||||
|
-- (parameterized is the same shape as wildcard for collision — the
|
||||||
|
-- parameter name is a binding, not a discriminator. See blueprint §11.5.)
|
||||||
|
CREATE TABLE app_domains (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
pattern TEXT NOT NULL,
|
||||||
|
shape TEXT NOT NULL CHECK (shape IN ('exact', 'wildcard', 'parameterized')),
|
||||||
|
shape_key TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX app_domains_app_id_idx ON app_domains (app_id);
|
||||||
|
|
||||||
|
-- Permanent 301 redirects after a slug rename. A row dies only when
|
||||||
|
-- another app explicitly claims the retired slug (with confirmation in
|
||||||
|
-- the UI). On_delete cascade: if the owning app is deleted, its history
|
||||||
|
-- row goes too (otherwise the redirect would point at a dead app).
|
||||||
|
CREATE TABLE app_slug_history (
|
||||||
|
slug TEXT PRIMARY KEY,
|
||||||
|
current_app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
retired_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed the default app + a localhost claim. Used by both upgrade and
|
||||||
|
-- fresh-install paths; the Rust bootstrap layers Hello World on top
|
||||||
|
-- only when the install was fresh.
|
||||||
|
WITH default_app AS (
|
||||||
|
INSERT INTO apps (slug, name, description)
|
||||||
|
VALUES ('default', 'Default', 'The default application — assigned to all pre-existing scripts and routes during the multi-app migration.')
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
INSERT INTO app_domains (app_id, pattern, shape, shape_key)
|
||||||
|
SELECT id, 'localhost', 'exact', 'exact:localhost' FROM default_app;
|
||||||
|
|
||||||
|
-- Add app_id to scripts. The default app already exists (above), so
|
||||||
|
-- there is exactly one row to look up.
|
||||||
|
ALTER TABLE scripts ADD COLUMN app_id UUID;
|
||||||
|
UPDATE scripts SET app_id = (SELECT id FROM apps WHERE slug = 'default');
|
||||||
|
ALTER TABLE scripts ALTER COLUMN app_id SET NOT NULL;
|
||||||
|
ALTER TABLE scripts
|
||||||
|
ADD CONSTRAINT scripts_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
-- Per-app name uniqueness. Two apps can each have a script called
|
||||||
|
-- "echo"; previously they could not.
|
||||||
|
DROP INDEX scripts_name_uidx;
|
||||||
|
CREATE UNIQUE INDEX scripts_name_uidx ON scripts (app_id, LOWER(name));
|
||||||
|
|
||||||
|
CREATE INDEX scripts_app_id_idx ON scripts (app_id);
|
||||||
|
|
||||||
|
-- Add app_id to routes, mirroring the script's app.
|
||||||
|
ALTER TABLE routes ADD COLUMN app_id UUID;
|
||||||
|
UPDATE routes
|
||||||
|
SET app_id = scripts.app_id
|
||||||
|
FROM scripts
|
||||||
|
WHERE routes.script_id = scripts.id;
|
||||||
|
ALTER TABLE routes ALTER COLUMN app_id SET NOT NULL;
|
||||||
|
ALTER TABLE routes
|
||||||
|
ADD CONSTRAINT routes_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Replace the route uniqueness index so two apps can claim identical
|
||||||
|
-- (host_kind, host, path_kind, path, method) tuples — they live in
|
||||||
|
-- separate route trees and never see each other.
|
||||||
|
DROP INDEX routes_unique_binding_idx;
|
||||||
|
CREATE UNIQUE INDEX routes_unique_binding_idx
|
||||||
|
ON routes (app_id, host_kind, host, path_kind, path, COALESCE(method, ''));
|
||||||
|
|
||||||
|
CREATE INDEX routes_app_id_idx ON routes (app_id);
|
||||||
|
|
||||||
|
-- Add app_id to execution_logs. Materialized at write time so future
|
||||||
|
-- script-moves (or eventual export/import) don't silently retag history.
|
||||||
|
ALTER TABLE execution_logs ADD COLUMN app_id UUID;
|
||||||
|
UPDATE execution_logs
|
||||||
|
SET app_id = scripts.app_id
|
||||||
|
FROM scripts
|
||||||
|
WHERE execution_logs.script_id = scripts.id;
|
||||||
|
ALTER TABLE execution_logs ALTER COLUMN app_id SET NOT NULL;
|
||||||
|
ALTER TABLE execution_logs
|
||||||
|
ADD CONSTRAINT execution_logs_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX execution_logs_app_id_created_at_idx
|
||||||
|
ON execution_logs (app_id, created_at DESC);
|
||||||
15
crates/manager-core/seeds/hello.rhai
Normal file
15
crates/manager-core/seeds/hello.rhai
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Hello World — the reference example seeded into the default app on
|
||||||
|
// fresh installs. Bound to GET /hello.
|
||||||
|
|
||||||
|
let who = ctx.request.body;
|
||||||
|
let name = if who != () && type_of(who) == "map" && who.contains("name") {
|
||||||
|
who.name
|
||||||
|
} else {
|
||||||
|
"world"
|
||||||
|
};
|
||||||
|
|
||||||
|
return #{
|
||||||
|
statusCode: 200,
|
||||||
|
headers: #{ "Content-Type": "application/json" },
|
||||||
|
body: #{ message: `Hello, ${name}!` }
|
||||||
|
};
|
||||||
152
crates/manager-core/src/admin_session_repo.rs
Normal file
152
crates/manager-core/src/admin_session_repo.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
//! CRUD over the `admin_sessions` table.
|
||||||
|
//!
|
||||||
|
//! The token never appears in this module — only its SHA-256 hash. The
|
||||||
|
//! raw value lives in `auth::GeneratedToken` long enough to hit the
|
||||||
|
//! cookie and the JSON response, then is forgotten. Lookups also filter
|
||||||
|
//! expired rows at query time so a delayed prune sweep can never extend
|
||||||
|
//! a session's life.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::AdminUserId;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AdminSessionRepositoryError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a session lookup. Includes the user id (for auth context)
|
||||||
|
/// and the existing `expires_at` so the middleware can decide whether
|
||||||
|
/// the sliding window bump is worth a write.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AdminSessionLookup {
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AdminSessionRepository: Send + Sync {
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
token_hash: &str,
|
||||||
|
expires_at: DateTime<Utc>,
|
||||||
|
) -> Result<(), AdminSessionRepositoryError>;
|
||||||
|
/// Look up a session by token hash. Returns `None` for missing or
|
||||||
|
/// already-expired rows (the query filters them).
|
||||||
|
async fn lookup(
|
||||||
|
&self,
|
||||||
|
token_hash: &str,
|
||||||
|
) -> Result<Option<AdminSessionLookup>, AdminSessionRepositoryError>;
|
||||||
|
/// Sliding-window bump. Sets `last_used_at = NOW()` and `expires_at`
|
||||||
|
/// to the supplied value.
|
||||||
|
async fn touch(
|
||||||
|
&self,
|
||||||
|
token_hash: &str,
|
||||||
|
new_expires_at: DateTime<Utc>,
|
||||||
|
) -> Result<(), AdminSessionRepositoryError>;
|
||||||
|
async fn delete(&self, token_hash: &str) -> Result<(), AdminSessionRepositoryError>;
|
||||||
|
/// Delete every session belonging to a user. Used when the user is
|
||||||
|
/// deactivated or has their password reset out-of-band — both
|
||||||
|
/// invalidate all current logins for that account.
|
||||||
|
async fn delete_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<u64, AdminSessionRepositoryError>;
|
||||||
|
/// Sweep expired rows. The auth middleware filters expired rows on
|
||||||
|
/// lookup, so this is just bounded-growth hygiene, not correctness.
|
||||||
|
async fn prune_expired(&self) -> Result<u64, AdminSessionRepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresAdminSessionRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresAdminSessionRepository {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AdminSessionRepository for PostgresAdminSessionRepository {
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
token_hash: &str,
|
||||||
|
expires_at: DateTime<Utc>,
|
||||||
|
) -> Result<(), AdminSessionRepositoryError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO admin_sessions (token_hash, user_id, expires_at) \
|
||||||
|
VALUES ($1, $2, $3)",
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.bind(expires_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn lookup(
|
||||||
|
&self,
|
||||||
|
token_hash: &str,
|
||||||
|
) -> Result<Option<AdminSessionLookup>, AdminSessionRepositoryError> {
|
||||||
|
let row: Option<(uuid::Uuid, DateTime<Utc>)> = sqlx::query_as(
|
||||||
|
"SELECT user_id, expires_at FROM admin_sessions \
|
||||||
|
WHERE token_hash = $1 AND expires_at > NOW()",
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|(uid, exp)| AdminSessionLookup {
|
||||||
|
user_id: uid.into(),
|
||||||
|
expires_at: exp,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn touch(
|
||||||
|
&self,
|
||||||
|
token_hash: &str,
|
||||||
|
new_expires_at: DateTime<Utc>,
|
||||||
|
) -> Result<(), AdminSessionRepositoryError> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE admin_sessions SET last_used_at = NOW(), expires_at = $2 \
|
||||||
|
WHERE token_hash = $1",
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(new_expires_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, token_hash: &str) -> Result<(), AdminSessionRepositoryError> {
|
||||||
|
sqlx::query("DELETE FROM admin_sessions WHERE token_hash = $1")
|
||||||
|
.bind(token_hash)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<u64, AdminSessionRepositoryError> {
|
||||||
|
let res = sqlx::query("DELETE FROM admin_sessions WHERE user_id = $1")
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prune_expired(&self) -> Result<u64, AdminSessionRepositoryError> {
|
||||||
|
let res = sqlx::query("DELETE FROM admin_sessions WHERE expires_at <= NOW()")
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected())
|
||||||
|
}
|
||||||
|
}
|
||||||
322
crates/manager-core/src/admin_user_repo.rs
Normal file
322
crates/manager-core/src/admin_user_repo.rs
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
//! CRUD over the `admin_users` table.
|
||||||
|
//!
|
||||||
|
//! Password hashes go in and come out as opaque strings — this module
|
||||||
|
//! never inspects or computes them; that's `auth.rs`'s job. The "must
|
||||||
|
//! keep at least one active admin" guard is implemented as a separate
|
||||||
|
//! count query the API layer composes around `set_active` / `delete`.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::AdminUserId;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AdminUserRepositoryError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
|
||||||
|
#[error("not found: {0}")]
|
||||||
|
NotFound(AdminUserId),
|
||||||
|
|
||||||
|
#[error("username already taken: {0}")]
|
||||||
|
DuplicateUsername(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row returned to handlers and bootstrap. Never includes the password
|
||||||
|
/// hash by accident — that lives in `AdminUserCredentials` (separate
|
||||||
|
/// fetch from `get_credentials_by_username`).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AdminUserRow {
|
||||||
|
pub id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub last_login_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Credentials fetched for the login path only. Splitting the hash off
|
||||||
|
/// from the public row makes it obvious in handler code which calls
|
||||||
|
/// touch a secret.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AdminUserCredentials {
|
||||||
|
pub id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
pub is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AdminUserRepository: Send + Sync {
|
||||||
|
async fn get(&self, id: AdminUserId) -> Result<Option<AdminUserRow>, AdminUserRepositoryError>;
|
||||||
|
async fn get_by_username(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError>;
|
||||||
|
async fn get_credentials_by_username(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError>;
|
||||||
|
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
|
async fn update_username(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
|
async fn update_password_hash(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
|
async fn set_active(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
is_active: bool,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
|
async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError>;
|
||||||
|
async fn touch_last_login(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError>;
|
||||||
|
/// Count of `is_active = true` rows. Used at bootstrap to decide
|
||||||
|
/// whether to seed the first admin.
|
||||||
|
async fn count_active(&self) -> Result<i64, AdminUserRepositoryError>;
|
||||||
|
/// Count of `is_active = true` rows excluding the given id. Used by
|
||||||
|
/// last-admin protection: "would deactivating / deleting this user
|
||||||
|
/// leave zero active admins?"
|
||||||
|
async fn count_active_excluding(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
) -> Result<i64, AdminUserRepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresAdminUserRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresAdminUserRepository {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AdminUserRepository for PostgresAdminUserRepository {
|
||||||
|
async fn get(&self, id: AdminUserId) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
||||||
|
FROM admin_users WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_username(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
||||||
|
FROM admin_users WHERE username = $1",
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_credentials_by_username(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AdminCredsRecord>(
|
||||||
|
"SELECT id, username, password_hash, is_active \
|
||||||
|
FROM admin_users WHERE username = $1",
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
||||||
|
FROM admin_users ORDER BY username",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"INSERT INTO admin_users (username, password_hash) \
|
||||||
|
VALUES ($1, $2) \
|
||||||
|
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.bind(password_hash)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(row) => Ok(row.into()),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||||
|
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
|
||||||
|
),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_username(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"UPDATE admin_users SET username = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(Some(row)) => Ok(row.into()),
|
||||||
|
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||||
|
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
|
||||||
|
),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_password_hash(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"UPDATE admin_users SET password_hash = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(password_hash)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(Into::into)
|
||||||
|
.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_active(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
is_active: bool,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"UPDATE admin_users SET is_active = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(is_active)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(Into::into)
|
||||||
|
.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||||
|
let res = sqlx::query("DELETE FROM admin_users WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(AdminUserRepositoryError::NotFound(id));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn touch_last_login(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||||
|
sqlx::query("UPDATE admin_users SET last_login_at = NOW() WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_active(&self) -> Result<i64, AdminUserRepositoryError> {
|
||||||
|
let (count,): (i64,) =
|
||||||
|
sqlx::query_as("SELECT COUNT(*)::BIGINT FROM admin_users WHERE is_active")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_active_excluding(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
) -> Result<i64, AdminUserRepositoryError> {
|
||||||
|
let (count,): (i64,) =
|
||||||
|
sqlx::query_as("SELECT COUNT(*)::BIGINT FROM admin_users WHERE is_active AND id <> $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AdminUserRecord {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
username: String,
|
||||||
|
is_active: bool,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
updated_at: DateTime<Utc>,
|
||||||
|
last_login_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AdminUserRecord> for AdminUserRow {
|
||||||
|
fn from(r: AdminUserRecord) -> Self {
|
||||||
|
Self {
|
||||||
|
id: r.id.into(),
|
||||||
|
username: r.username,
|
||||||
|
is_active: r.is_active,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
last_login_at: r.last_login_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AdminCredsRecord {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
username: String,
|
||||||
|
password_hash: String,
|
||||||
|
is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AdminCredsRecord> for AdminUserCredentials {
|
||||||
|
fn from(r: AdminCredsRecord) -> Self {
|
||||||
|
Self {
|
||||||
|
id: r.id.into(),
|
||||||
|
username: r.username,
|
||||||
|
password_hash: r.password_hash,
|
||||||
|
is_active: r.is_active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
320
crates/manager-core/src/admin_users_api.rs
Normal file
320
crates/manager-core/src/admin_users_api.rs
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
//! `/api/v1/admin/admins/*` — admin user CRUD. Guarded by
|
||||||
|
//! `require_admin`; every authenticated admin can call all of these.
|
||||||
|
//! Role/permission walls land later (see blueprint §11.4 — no
|
||||||
|
//! privilege levels in this cut).
|
||||||
|
//!
|
||||||
|
//! "Last active admin" protection lives at the service layer (not just
|
||||||
|
//! the DB) so it can produce a clean 422 with a human-readable message
|
||||||
|
//! rather than a SQL constraint violation. Deactivating a user also
|
||||||
|
//! wipes their sessions; deleting cascades through the FK.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::Router;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::AdminUserId;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::admin_session_repo::AdminSessionRepository;
|
||||||
|
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
|
||||||
|
use crate::auth::hash_password;
|
||||||
|
|
||||||
|
/// Validation knobs are tuned by NIST 800-63B-ish guidance: username is
|
||||||
|
/// a strict ASCII subset so the lookup column stays predictable, and
|
||||||
|
/// password has a minimum length but no complexity rules (complexity
|
||||||
|
/// rules push users to predictable patterns).
|
||||||
|
const USERNAME_MIN: usize = 2;
|
||||||
|
const USERNAME_MAX: usize = 32;
|
||||||
|
const PASSWORD_MIN: usize = 8;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AdminsState {
|
||||||
|
pub users: Arc<dyn AdminUserRepository>,
|
||||||
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn admins_router(state: AdminsState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/admins", get(list_admins).post(create_admin))
|
||||||
|
.route(
|
||||||
|
"/admins/{id}",
|
||||||
|
get(get_admin).patch(patch_admin).delete(delete_admin),
|
||||||
|
)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// DTOs
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AdminDto {
|
||||||
|
pub id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub last_login_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AdminUserRow> for AdminDto {
|
||||||
|
fn from(r: AdminUserRow) -> Self {
|
||||||
|
Self {
|
||||||
|
id: r.id,
|
||||||
|
username: r.username,
|
||||||
|
is_active: r.is_active,
|
||||||
|
created_at: r.created_at,
|
||||||
|
last_login_at: r.last_login_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateAdminRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
pub struct PatchAdminRequest {
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub is_active: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_admins(
|
||||||
|
State(state): State<AdminsState>,
|
||||||
|
) -> Result<Json<Vec<AdminDto>>, AdminApiError> {
|
||||||
|
let rows = state.users.list().await?;
|
||||||
|
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_admin(
|
||||||
|
State(state): State<AdminsState>,
|
||||||
|
Path(id): Path<AdminUserId>,
|
||||||
|
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||||
|
state
|
||||||
|
.users
|
||||||
|
.get(id)
|
||||||
|
.await?
|
||||||
|
.map(AdminDto::from)
|
||||||
|
.map(Json)
|
||||||
|
.ok_or(AdminApiError::NotFound(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_admin(
|
||||||
|
State(state): State<AdminsState>,
|
||||||
|
Json(input): Json<CreateAdminRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<AdminDto>), AdminApiError> {
|
||||||
|
let username = input.username.trim();
|
||||||
|
validate_username(username)?;
|
||||||
|
validate_password(&input.password)?;
|
||||||
|
let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
|
||||||
|
let row = state.users.create(username, &hash).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(row.into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_admin(
|
||||||
|
State(state): State<AdminsState>,
|
||||||
|
Path(id): Path<AdminUserId>,
|
||||||
|
Json(input): Json<PatchAdminRequest>,
|
||||||
|
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||||
|
// Verify the target exists upfront — keeps the error path uniform
|
||||||
|
// for "rename a missing user" etc.
|
||||||
|
let _ = state
|
||||||
|
.users
|
||||||
|
.get(id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AdminApiError::NotFound(id))?;
|
||||||
|
|
||||||
|
let mut latest: Option<AdminUserRow> = None;
|
||||||
|
|
||||||
|
if let Some(raw_username) = input.username.as_deref() {
|
||||||
|
let new_username = raw_username.trim();
|
||||||
|
validate_username(new_username)?;
|
||||||
|
latest = Some(state.users.update_username(id, new_username).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(new_password) = input.password.as_deref() {
|
||||||
|
validate_password(new_password)?;
|
||||||
|
let hash = hash_password(new_password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
|
||||||
|
latest = Some(state.users.update_password_hash(id, &hash).await?);
|
||||||
|
// Best practice: rotating your own password should still keep
|
||||||
|
// your session alive, so we don't wipe sessions here. (If we
|
||||||
|
// wanted "log everyone else out on password change", that'd be
|
||||||
|
// a `delete_for_user` + re-issue current session. Out of scope
|
||||||
|
// for the initial cut.)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(new_active) = input.is_active {
|
||||||
|
// Last-active-admin guard: only when transitioning to inactive.
|
||||||
|
if !new_active {
|
||||||
|
let remaining = state.users.count_active_excluding(id).await?;
|
||||||
|
if remaining == 0 {
|
||||||
|
return Err(AdminApiError::LastActiveAdmin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
latest = Some(state.users.set_active(id, new_active).await?);
|
||||||
|
// Deactivation invalidates all of the user's sessions. Cheap
|
||||||
|
// and safer than waiting for sliding-window expiry.
|
||||||
|
if !new_active {
|
||||||
|
if let Err(err) = state.sessions.delete_for_user(id).await {
|
||||||
|
tracing::error!(?err, "failed to delete sessions for deactivated admin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = match latest {
|
||||||
|
Some(r) => r,
|
||||||
|
None => state
|
||||||
|
.users
|
||||||
|
.get(id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AdminApiError::NotFound(id))?,
|
||||||
|
};
|
||||||
|
Ok(Json(row.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_admin(
|
||||||
|
State(state): State<AdminsState>,
|
||||||
|
Path(id): Path<AdminUserId>,
|
||||||
|
) -> Result<StatusCode, AdminApiError> {
|
||||||
|
let target = state
|
||||||
|
.users
|
||||||
|
.get(id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AdminApiError::NotFound(id))?;
|
||||||
|
if target.is_active {
|
||||||
|
let remaining = state.users.count_active_excluding(id).await?;
|
||||||
|
if remaining == 0 {
|
||||||
|
return Err(AdminApiError::LastActiveAdmin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.users.delete(id).await?;
|
||||||
|
// Sessions cascade via FK; no explicit delete needed.
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Validation
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn validate_username(s: &str) -> Result<(), AdminApiError> {
|
||||||
|
if s.len() < USERNAME_MIN || s.len() > USERNAME_MAX {
|
||||||
|
return Err(AdminApiError::InvalidUsername(format!(
|
||||||
|
"username must be {USERNAME_MIN}-{USERNAME_MAX} characters"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !s
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-'))
|
||||||
|
{
|
||||||
|
return Err(AdminApiError::InvalidUsername(
|
||||||
|
"username may contain only lowercase letters, digits, dot, underscore, and hyphen"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_password(s: &str) -> Result<(), AdminApiError> {
|
||||||
|
if s.chars().count() < PASSWORD_MIN {
|
||||||
|
return Err(AdminApiError::InvalidPassword(format!(
|
||||||
|
"password must be at least {PASSWORD_MIN} characters"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AdminApiError {
|
||||||
|
#[error("admin user not found: {0}")]
|
||||||
|
NotFound(AdminUserId),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
InvalidUsername(String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
InvalidPassword(String),
|
||||||
|
|
||||||
|
#[error("cannot leave the system with zero active admins")]
|
||||||
|
LastActiveAdmin,
|
||||||
|
|
||||||
|
#[error("failed to hash password: {0}")]
|
||||||
|
Hash(String),
|
||||||
|
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Repo(#[from] AdminUserRepositoryError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AdminApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, message) = match &self {
|
||||||
|
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||||
|
Self::Repo(AdminUserRepositoryError::DuplicateUsername(_)) => {
|
||||||
|
(StatusCode::CONFLICT, self.to_string())
|
||||||
|
}
|
||||||
|
Self::InvalidUsername(_) | Self::InvalidPassword(_) | Self::LastActiveAdmin => {
|
||||||
|
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||||
|
}
|
||||||
|
Self::Repo(AdminUserRepositoryError::NotFound(_)) => {
|
||||||
|
(StatusCode::NOT_FOUND, self.to_string())
|
||||||
|
}
|
||||||
|
Self::Repo(AdminUserRepositoryError::Db(e)) => {
|
||||||
|
tracing::error!(error = %e, "admin_users db error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Hash(_) => {
|
||||||
|
tracing::error!(error = %self, "password hashing failed");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(json!({ "error": message }))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn username_validation_accepts_valid() {
|
||||||
|
for u in ["ab", "alice", "user.name", "a_b-c", "00bot00"] {
|
||||||
|
assert!(validate_username(u).is_ok(), "should accept {u}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn username_validation_rejects_invalid() {
|
||||||
|
for u in ["", "a", "Alice", "user name", "user@domain", "user!"] {
|
||||||
|
assert!(validate_username(u).is_err(), "should reject {u:?}");
|
||||||
|
}
|
||||||
|
let too_long = "x".repeat(33);
|
||||||
|
assert!(validate_username(&too_long).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn password_validation_enforces_min_length() {
|
||||||
|
assert!(validate_password("1234567").is_err());
|
||||||
|
assert!(validate_password("12345678").is_ok());
|
||||||
|
assert!(validate_password("a-very-long-password-with-spaces and stuff").is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,17 +5,18 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
routing::get,
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
|
AppId, ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
use crate::repo::{
|
use crate::repo::{
|
||||||
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
||||||
};
|
};
|
||||||
@@ -27,6 +28,9 @@ use crate::sandbox::{CeilingError, SandboxCeiling};
|
|||||||
pub struct AdminState<R, L> {
|
pub struct AdminState<R, L> {
|
||||||
pub repo: Arc<R>,
|
pub repo: Arc<R>,
|
||||||
pub logs: Arc<L>,
|
pub logs: Arc<L>,
|
||||||
|
/// App lookups: validates `app_id` on create, resolves `?app=<slug>`
|
||||||
|
/// filter on list. Trait-object so apps_repo can stay separate.
|
||||||
|
pub apps: Arc<dyn AppRepository>,
|
||||||
pub validator: Arc<dyn ScriptValidator>,
|
pub validator: Arc<dyn ScriptValidator>,
|
||||||
pub sandbox_ceiling: SandboxCeiling,
|
pub sandbox_ceiling: SandboxCeiling,
|
||||||
}
|
}
|
||||||
@@ -36,6 +40,7 @@ impl<R, L> Clone for AdminState<R, L> {
|
|||||||
Self {
|
Self {
|
||||||
repo: self.repo.clone(),
|
repo: self.repo.clone(),
|
||||||
logs: self.logs.clone(),
|
logs: self.logs.clone(),
|
||||||
|
apps: self.apps.clone(),
|
||||||
validator: self.validator.clone(),
|
validator: self.validator.clone(),
|
||||||
sandbox_ceiling: self.sandbox_ceiling,
|
sandbox_ceiling: self.sandbox_ceiling,
|
||||||
}
|
}
|
||||||
@@ -70,6 +75,9 @@ where
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateScriptRequest {
|
pub struct CreateScriptRequest {
|
||||||
|
/// Owning app. Required since Phase 3b — scripts cannot exist
|
||||||
|
/// outside an app. Use `/api/v1/admin/apps` to list known ids.
|
||||||
|
pub app_id: AppId,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub source: String,
|
pub source: String,
|
||||||
@@ -82,6 +90,14 @@ pub struct CreateScriptRequest {
|
|||||||
pub sandbox: ScriptSandbox,
|
pub sandbox: ScriptSandbox,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListScriptsQuery {
|
||||||
|
/// Optional filter: list scripts belonging to a single app, by id
|
||||||
|
/// or slug. Absent = all scripts across all apps (admin-global view).
|
||||||
|
#[serde(default)]
|
||||||
|
pub app: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateScriptRequest {
|
pub struct UpdateScriptRequest {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
@@ -113,8 +129,32 @@ where
|
|||||||
|
|
||||||
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Query(q): Query<ListScriptsQuery>,
|
||||||
) -> Result<Json<Vec<Script>>, ApiError> {
|
) -> Result<Json<Vec<Script>>, ApiError> {
|
||||||
|
if let Some(ident) = q.app {
|
||||||
|
let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
|
||||||
|
Ok(Json(state.repo.list_for_app(app).await?))
|
||||||
|
} else {
|
||||||
Ok(Json(state.repo.list().await?))
|
Ok(Json(state.repo.list().await?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept `?app=<uuid>` OR `?app=<slug>`. Slugs route through history
|
||||||
|
/// for redirects, but here we just need the live current id; if a
|
||||||
|
/// retired slug is given, we follow it to the current app silently.
|
||||||
|
async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApiError> {
|
||||||
|
if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||||
|
let id = AppId::from(uuid);
|
||||||
|
apps.get_by_id(id)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
let lookup = apps
|
||||||
|
.get_by_slug_or_history(ident)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
|
||||||
|
Ok(lookup.app.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
@@ -135,9 +175,15 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
) -> Result<(StatusCode, Json<Script>), ApiError> {
|
) -> Result<(StatusCode, Json<Script>), ApiError> {
|
||||||
state.validator.validate(&input.source)?;
|
state.validator.validate(&input.source)?;
|
||||||
state.sandbox_ceiling.check(&input.sandbox)?;
|
state.sandbox_ceiling.check(&input.sandbox)?;
|
||||||
|
// Refuse early if the app_id doesn't exist — a clean 422 beats a
|
||||||
|
// raw FK violation surfacing as 500.
|
||||||
|
if state.apps.get_by_id(input.app_id).await?.is_none() {
|
||||||
|
return Err(ApiError::AppNotFound(input.app_id.to_string()));
|
||||||
|
}
|
||||||
let created = state
|
let created = state
|
||||||
.repo
|
.repo
|
||||||
.create(NewScript {
|
.create(NewScript {
|
||||||
|
app_id: input.app_id,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
source: input.source,
|
source: input.source,
|
||||||
@@ -223,6 +269,9 @@ pub enum ApiError {
|
|||||||
#[error("script not found: {0}")]
|
#[error("script not found: {0}")]
|
||||||
NotFound(ScriptId),
|
NotFound(ScriptId),
|
||||||
|
|
||||||
|
#[error("app not found: {0}")]
|
||||||
|
AppNotFound(String),
|
||||||
|
|
||||||
#[error("conflict: {0}")]
|
#[error("conflict: {0}")]
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
|
|
||||||
@@ -240,6 +289,7 @@ impl IntoResponse for ApiError {
|
|||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, message) = match &self {
|
let (status, message) = match &self {
|
||||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||||
|
Self::AppNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
|
||||||
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
||||||
Self::Invalid(_) | Self::Ceiling(_) => {
|
Self::Invalid(_) | Self::Ceiling(_) => {
|
||||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||||
|
|||||||
92
crates/manager-core/src/app_bootstrap.rs
Normal file
92
crates/manager-core/src/app_bootstrap.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
//! Hello-World seed for fresh installs.
|
||||||
|
//!
|
||||||
|
//! Idempotent. Runs after migrations and after admin bootstrap. Only
|
||||||
|
//! seeds when the default app is empty (no scripts, no routes); on
|
||||||
|
//! upgrades it does nothing so existing content isn't polluted.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use picloud_shared::{App, AppId, HostKind, PathKind};
|
||||||
|
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError};
|
||||||
|
use crate::route_repo::{NewRoute, RouteRepository};
|
||||||
|
|
||||||
|
const HELLO_RHAI_SOURCE: &str = include_str!("../seeds/hello.rhai");
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum HelloWorldOutcome {
|
||||||
|
/// Default app already has scripts (or doesn't exist) — left alone.
|
||||||
|
SkippedExisting,
|
||||||
|
/// Inserted the hello.rhai script and the `/hello` route.
|
||||||
|
Seeded,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SeedError {
|
||||||
|
#[error("default app not found — did the migration run?")]
|
||||||
|
MissingDefaultApp,
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Repo(#[from] ScriptRepositoryError),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn seed_hello_world_if_fresh(
|
||||||
|
apps: Arc<dyn AppRepository>,
|
||||||
|
scripts: Arc<dyn ScriptRepository>,
|
||||||
|
routes: Arc<dyn RouteRepository>,
|
||||||
|
) -> Result<HelloWorldOutcome, SeedError> {
|
||||||
|
let default = apps
|
||||||
|
.get_by_slug("default")
|
||||||
|
.await?
|
||||||
|
.ok_or(SeedError::MissingDefaultApp)?;
|
||||||
|
|
||||||
|
// Idempotence: only seed when both scripts AND routes are empty.
|
||||||
|
// (Either alone is suspicious enough to skip — the operator may have
|
||||||
|
// already started shaping the default app.)
|
||||||
|
let existing_scripts = scripts.list_for_app(default.id).await?;
|
||||||
|
let existing_routes = routes.list_for_app(default.id).await?;
|
||||||
|
if !existing_scripts.is_empty() || !existing_routes.is_empty() {
|
||||||
|
return Ok(HelloWorldOutcome::SkippedExisting);
|
||||||
|
}
|
||||||
|
|
||||||
|
seed_into(&*scripts, &*routes, &default).await?;
|
||||||
|
Ok(HelloWorldOutcome::Seeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_into(
|
||||||
|
scripts: &dyn ScriptRepository,
|
||||||
|
routes: &dyn RouteRepository,
|
||||||
|
default: &App,
|
||||||
|
) -> Result<(), ScriptRepositoryError> {
|
||||||
|
let script = scripts
|
||||||
|
.create(NewScript {
|
||||||
|
app_id: default.id,
|
||||||
|
name: "hello".to_string(),
|
||||||
|
description: Some("Reference example: returns a greeting at GET /hello.".to_string()),
|
||||||
|
source: HELLO_RHAI_SOURCE.to_string(),
|
||||||
|
timeout_seconds: Some(5),
|
||||||
|
memory_limit_mb: None,
|
||||||
|
sandbox: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
routes
|
||||||
|
.create(NewRoute {
|
||||||
|
app_id: default.id,
|
||||||
|
script_id: script.id,
|
||||||
|
host_kind: HostKind::Any,
|
||||||
|
host: String::new(),
|
||||||
|
host_param_name: None,
|
||||||
|
path_kind: PathKind::Exact,
|
||||||
|
path: "/hello".to_string(),
|
||||||
|
// Accept any method so both `curl /hello` and
|
||||||
|
// `curl -d '{"name":"X"}' /hello` work out of the box.
|
||||||
|
method: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn _typecheck(_id: AppId) {} // suppress unused-import warnings if reshuffled
|
||||||
152
crates/manager-core/src/app_domain_repo.rs
Normal file
152
crates/manager-core/src/app_domain_repo.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
//! CRUD over the `app_domains` table.
|
||||||
|
//!
|
||||||
|
//! Parsing + shape_key derivation live in `orchestrator-core`'s
|
||||||
|
//! `routing::pattern::parse_app_domain` — this repo just stores what
|
||||||
|
//! the API handler hands it. Same-shape collisions surface as a unique
|
||||||
|
//! constraint violation on `shape_key`, mapped here to a clean error.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_shared::{AppDomain, AppId, DomainShape};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::repo::ScriptRepositoryError;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NewAppDomain {
|
||||||
|
pub app_id: AppId,
|
||||||
|
pub pattern: String,
|
||||||
|
pub shape: DomainShape,
|
||||||
|
pub shape_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AppDomainRepository: Send + Sync {
|
||||||
|
/// All domain claims across all apps — used by the orchestrator's
|
||||||
|
/// `AppDomainTable` to build its lookup cache at startup and after
|
||||||
|
/// every write.
|
||||||
|
async fn list_all(&self) -> Result<Vec<AppDomain>, ScriptRepositoryError>;
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<AppDomain>, ScriptRepositoryError>;
|
||||||
|
async fn get(&self, domain_id: Uuid) -> Result<Option<AppDomain>, ScriptRepositoryError>;
|
||||||
|
async fn create(&self, input: NewAppDomain) -> Result<AppDomain, ScriptRepositoryError>;
|
||||||
|
async fn delete(&self, domain_id: Uuid) -> Result<(), ScriptRepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresAppDomainRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresAppDomainRepository {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AppDomainRepository for PostgresAppDomainRepository {
|
||||||
|
async fn list_all(&self) -> Result<Vec<AppDomain>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, DomainRow>(
|
||||||
|
"SELECT id, app_id, pattern, shape, shape_key, created_at \
|
||||||
|
FROM app_domains ORDER BY pattern",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<AppDomain>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, DomainRow>(
|
||||||
|
"SELECT id, app_id, pattern, shape, shape_key, created_at \
|
||||||
|
FROM app_domains WHERE app_id = $1 ORDER BY pattern",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, domain_id: Uuid) -> Result<Option<AppDomain>, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, DomainRow>(
|
||||||
|
"SELECT id, app_id, pattern, shape, shape_key, created_at \
|
||||||
|
FROM app_domains WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(domain_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(&self, input: NewAppDomain) -> Result<AppDomain, ScriptRepositoryError> {
|
||||||
|
let res = sqlx::query_as::<_, DomainRow>(
|
||||||
|
"INSERT INTO app_domains (app_id, pattern, shape, shape_key) \
|
||||||
|
VALUES ($1, $2, $3, $4) \
|
||||||
|
RETURNING id, app_id, pattern, shape, shape_key, created_at",
|
||||||
|
)
|
||||||
|
.bind(input.app_id.into_inner())
|
||||||
|
.bind(&input.pattern)
|
||||||
|
.bind(shape_str(input.shape))
|
||||||
|
.bind(&input.shape_key)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(row) => Ok(row.into()),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"domain {:?} (or another claim of the same shape) is already claimed",
|
||||||
|
input.pattern
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, domain_id: Uuid) -> Result<(), ScriptRepositoryError> {
|
||||||
|
let res = sqlx::query("DELETE FROM app_domains WHERE id = $1")
|
||||||
|
.bind(domain_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"domain {domain_id} not found"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn shape_str(s: DomainShape) -> &'static str {
|
||||||
|
match s {
|
||||||
|
DomainShape::Exact => "exact",
|
||||||
|
DomainShape::Wildcard => "wildcard",
|
||||||
|
DomainShape::Parameterized => "parameterized",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct DomainRow {
|
||||||
|
id: Uuid,
|
||||||
|
app_id: Uuid,
|
||||||
|
pattern: String,
|
||||||
|
shape: String,
|
||||||
|
shape_key: String,
|
||||||
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DomainRow> for AppDomain {
|
||||||
|
fn from(r: DomainRow) -> Self {
|
||||||
|
Self {
|
||||||
|
id: r.id,
|
||||||
|
app_id: r.app_id.into(),
|
||||||
|
pattern: r.pattern,
|
||||||
|
shape: match r.shape.as_str() {
|
||||||
|
"wildcard" => DomainShape::Wildcard,
|
||||||
|
"parameterized" => DomainShape::Parameterized,
|
||||||
|
_ => DomainShape::Exact,
|
||||||
|
},
|
||||||
|
shape_key: r.shape_key,
|
||||||
|
created_at: r.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
380
crates/manager-core/src/app_repo.rs
Normal file
380
crates/manager-core/src/app_repo.rs
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
//! CRUD over the `apps` and `app_slug_history` tables.
|
||||||
|
//!
|
||||||
|
//! Slug validation (regex, reserved-word check) lives in the API
|
||||||
|
//! handler; this repo enforces only what Postgres enforces (uniqueness,
|
||||||
|
//! FK). The slug-rename flow is exposed as a single `rename_slug` call
|
||||||
|
//! that writes the history row in the same transaction.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_shared::{App, AppId};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::repo::ScriptRepositoryError;
|
||||||
|
|
||||||
|
/// Result of looking up an app by slug or via the redirect history.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppLookup {
|
||||||
|
pub app: App,
|
||||||
|
/// `true` when the slug was found in `app_slug_history` rather than
|
||||||
|
/// directly on `apps`. Dashboards should issue a redirect.
|
||||||
|
pub redirected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AppRepository: Send + Sync {
|
||||||
|
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||||
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
|
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
|
async fn get_by_slug_or_history(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<Option<AppLookup>, ScriptRepositoryError>;
|
||||||
|
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
|
/// Create that also consumes a matching `app_slug_history` row, if
|
||||||
|
/// any. Used after the operator has confirmed they want to break old
|
||||||
|
/// redirects.
|
||||||
|
async fn create_with_takeover(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
id: AppId,
|
||||||
|
name: Option<&str>,
|
||||||
|
description: Option<Option<&str>>,
|
||||||
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
|
/// Rename and record the old slug in `app_slug_history` (so
|
||||||
|
/// retired URLs keep redirecting). If `take_over_history` is true,
|
||||||
|
/// any existing history row for `new_slug` is consumed.
|
||||||
|
async fn rename_slug(
|
||||||
|
&self,
|
||||||
|
id: AppId,
|
||||||
|
new_slug: &str,
|
||||||
|
take_over_history: bool,
|
||||||
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
|
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
|
||||||
|
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresAppRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresAppRepository {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AppRepository for PostgresAppRepository {
|
||||||
|
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT id, slug, name, description, created_at, updated_at \
|
||||||
|
FROM apps ORDER BY name",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT id, slug, name, description, created_at, updated_at \
|
||||||
|
FROM apps WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT id, slug, name, description, created_at, updated_at \
|
||||||
|
FROM apps WHERE slug = $1",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_slug_or_history(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<Option<AppLookup>, ScriptRepositoryError> {
|
||||||
|
if let Some(app) = self.get_by_slug(slug).await? {
|
||||||
|
return Ok(Some(AppLookup {
|
||||||
|
app,
|
||||||
|
redirected: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if let Some(app) = self.slug_in_history(slug).await? {
|
||||||
|
return Ok(Some(AppLookup {
|
||||||
|
app,
|
||||||
|
redirected: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||||
|
FROM app_slug_history h \
|
||||||
|
JOIN apps a ON a.id = h.current_app_id \
|
||||||
|
WHERE h.slug = $1",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
let res = sqlx::query_as::<_, AppRow>(
|
||||||
|
"INSERT INTO apps (slug, name, description) \
|
||||||
|
VALUES ($1, $2, $3) \
|
||||||
|
RETURNING id, slug, name, description, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.bind(name)
|
||||||
|
.bind(description)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(row) => Ok(row.into()),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||||
|
ScriptRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
|
||||||
|
),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_with_takeover(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||||
|
.bind(slug)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"INSERT INTO apps (slug, name, description) \
|
||||||
|
VALUES ($1, $2, $3) \
|
||||||
|
RETURNING id, slug, name, description, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.bind(name)
|
||||||
|
.bind(description)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await;
|
||||||
|
let row = match row {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"slug {slug:?} is already in use"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(row.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
id: AppId,
|
||||||
|
name: Option<&str>,
|
||||||
|
description: Option<Option<&str>>,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"UPDATE apps SET \
|
||||||
|
name = COALESCE($2, name), \
|
||||||
|
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||||
|
updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, slug, name, description, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(name)
|
||||||
|
.bind(description.is_some())
|
||||||
|
.bind(description.and_then(|d| d))
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(Into::into)
|
||||||
|
.ok_or_else(|| ScriptRepositoryError::Conflict(format!("app {id} not found")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rename_slug(
|
||||||
|
&self,
|
||||||
|
id: AppId,
|
||||||
|
new_slug: &str,
|
||||||
|
take_over_history: bool,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
|
||||||
|
// 1. Read the current slug (so we can record it in history).
|
||||||
|
let current: Option<(String,)> = sqlx::query_as("SELECT slug FROM apps WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let Some((current_slug,)) = current else {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"app {id} not found"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
if current_slug == new_slug {
|
||||||
|
// No-op rename; just return the row.
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT id, slug, name, description, created_at, updated_at \
|
||||||
|
FROM apps WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
return Ok(row.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If renaming back to this app's own retired slug, just
|
||||||
|
// consume the history row silently (no warning, no takeover
|
||||||
|
// flag required).
|
||||||
|
let owns_history: Option<(uuid::Uuid,)> =
|
||||||
|
sqlx::query_as("SELECT current_app_id FROM app_slug_history WHERE slug = $1")
|
||||||
|
.bind(new_slug)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match owns_history {
|
||||||
|
Some((owner,)) if owner == id.into_inner() => {
|
||||||
|
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||||
|
.bind(new_slug)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Some(_) if take_over_history => {
|
||||||
|
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||||
|
.bind(new_slug)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"slug {new_slug:?} is in history; rename with takeover to claim it"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Record the current slug in history (replacing any older
|
||||||
|
// entry — the same slug can pass through history multiple
|
||||||
|
// times across many renames).
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO app_slug_history (slug, current_app_id) \
|
||||||
|
VALUES ($1, $2) \
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET current_app_id = EXCLUDED.current_app_id, \
|
||||||
|
retired_at = NOW()",
|
||||||
|
)
|
||||||
|
.bind(¤t_slug)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 4. Apply the rename. Unique violation = another live app
|
||||||
|
// already holds this slug.
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"UPDATE apps SET slug = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, slug, name, description, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(new_slug)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await;
|
||||||
|
let row = match row {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"slug {new_slug:?} is already in use by another app"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(row.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
|
||||||
|
let res = sqlx::query("DELETE FROM apps WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await;
|
||||||
|
match res {
|
||||||
|
Ok(r) if r.rows_affected() == 0 => Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"app {id} not found"
|
||||||
|
))),
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||||
|
// ON DELETE RESTRICT on scripts.app_id — surface a clean
|
||||||
|
// "has dependents" error rather than a raw SQL message.
|
||||||
|
Err(ScriptRepositoryError::Conflict(
|
||||||
|
"app still contains scripts; delete or move them first".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError> {
|
||||||
|
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scripts WHERE app_id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AppRow {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
slug: String,
|
||||||
|
name: String,
|
||||||
|
description: Option<String>,
|
||||||
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppRow> for App {
|
||||||
|
fn from(r: AppRow) -> Self {
|
||||||
|
Self {
|
||||||
|
id: r.id.into(),
|
||||||
|
slug: r.slug,
|
||||||
|
name: r.name,
|
||||||
|
description: r.description,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
510
crates/manager-core/src/apps_api.rs
Normal file
510
crates/manager-core/src/apps_api.rs
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
//! `/api/v1/admin/apps/*` — app + domain claim CRUD.
|
||||||
|
//!
|
||||||
|
//! All endpoints are guarded by `require_admin`. Per-app permissions
|
||||||
|
//! are deferred (every authenticated admin can act on every app); the
|
||||||
|
//! middleware seam exists for when that lands.
|
||||||
|
//!
|
||||||
|
//! Slug validation: regex `^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
|
||||||
|
//! list rejected. Slug renames record the old slug in
|
||||||
|
//! `app_slug_history` for permanent 301 redirects; reclaiming a
|
||||||
|
//! historical slug requires `"force_takeover": true` in the request.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::{delete, get, post};
|
||||||
|
use axum::Router;
|
||||||
|
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
||||||
|
use picloud_shared::{App, AppDomain, AppId};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::repo::ScriptRepositoryError;
|
||||||
|
use crate::route_repo::RouteRepository;
|
||||||
|
|
||||||
|
const SLUG_MIN: usize = 1;
|
||||||
|
const SLUG_MAX: usize = 63;
|
||||||
|
const RESERVED_SLUGS: &[&str] = &[
|
||||||
|
"new", "api", "admin", "admins", "healthz", "version", "login", "logout", "apps",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppsState {
|
||||||
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
pub domains: Arc<dyn AppDomainRepository>,
|
||||||
|
pub routes: Arc<dyn RouteRepository>,
|
||||||
|
/// Cached host → app_id lookup; replaced after every domain CRUD
|
||||||
|
/// operation so the orchestrator sees changes immediately.
|
||||||
|
pub domain_table: Arc<AppDomainTable>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apps_router(state: AppsState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/apps", get(list_apps).post(create_app))
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}",
|
||||||
|
get(get_app).patch(patch_app).delete(delete_app),
|
||||||
|
)
|
||||||
|
.route("/apps/{id_or_slug}/slug:check", post(slug_check))
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}/domains",
|
||||||
|
get(list_domains).post(create_domain),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}/domains/{domain_id}",
|
||||||
|
delete(delete_domain),
|
||||||
|
)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// DTOs
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AppDto {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub app: App,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateAppRequest {
|
||||||
|
pub slug: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Set to `true` to consume an existing `app_slug_history` row for
|
||||||
|
/// the requested slug (breaking old redirects).
|
||||||
|
#[serde(default)]
|
||||||
|
pub force_takeover: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PatchAppRequest {
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_optional")]
|
||||||
|
#[allow(clippy::option_option)]
|
||||||
|
pub description: Option<Option<String>>,
|
||||||
|
pub slug: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub force_takeover: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::option_option)]
|
||||||
|
fn deserialize_optional_optional<'de, D>(d: D) -> Result<Option<Option<String>>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Option::<String>::deserialize(d).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SlugCheckRequest {
|
||||||
|
pub new_slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SlugCheckResponse {
|
||||||
|
pub ok: bool,
|
||||||
|
pub conflict_kind: Option<&'static str>,
|
||||||
|
pub current_app: Option<App>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateDomainRequest {
|
||||||
|
pub pattern: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AppLookupResponse {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub app: App,
|
||||||
|
/// When the operator hits the API with a retired slug, this points
|
||||||
|
/// at the live slug so dashboards can redirect.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub redirect_to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_apps(State(s): State<AppsState>) -> Result<Json<Vec<App>>, AppsApiError> {
|
||||||
|
Ok(Json(s.apps.list().await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_app(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Json(input): Json<CreateAppRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<App>), AppsApiError> {
|
||||||
|
validate_slug(&input.slug)?;
|
||||||
|
|
||||||
|
// Historical-slug check before insert: if the slug is in history
|
||||||
|
// and the caller hasn't asked to force takeover, surface a clean
|
||||||
|
// 409 so the dashboard can present a "this will break old links"
|
||||||
|
// confirmation.
|
||||||
|
if !input.force_takeover {
|
||||||
|
if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
|
||||||
|
return Err(AppsApiError::SlugInHistory(current));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = if input.force_takeover {
|
||||||
|
s.apps
|
||||||
|
.create_with_takeover(&input.slug, &input.name, input.description.as_deref())
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
s.apps
|
||||||
|
.create(&input.slug, &input.name, input.description.as_deref())
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_app(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<AppLookupResponse>, AppsApiError> {
|
||||||
|
let lookup = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
let redirect_to = if lookup.redirected {
|
||||||
|
Some(lookup.app.slug.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(Json(AppLookupResponse {
|
||||||
|
app: lookup.app,
|
||||||
|
redirect_to,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_app(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<PatchAppRequest>,
|
||||||
|
) -> Result<Json<App>, AppsApiError> {
|
||||||
|
let current = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
|
||||||
|
// Edits to name/description go first (separate from rename so we
|
||||||
|
// don't conflate the two errors).
|
||||||
|
let after_meta = if input.name.is_some() || input.description.is_some() {
|
||||||
|
s.apps
|
||||||
|
.update(
|
||||||
|
current.id,
|
||||||
|
input.name.as_deref(),
|
||||||
|
input.description.as_ref().map(|d| d.as_deref()),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
current
|
||||||
|
};
|
||||||
|
|
||||||
|
// Slug rename is a separate operation; the rename method does its
|
||||||
|
// own history bookkeeping in a transaction.
|
||||||
|
let after_rename = if let Some(new_slug) = input.slug.as_deref() {
|
||||||
|
validate_slug(new_slug)?;
|
||||||
|
match s
|
||||||
|
.apps
|
||||||
|
.rename_slug(after_meta.id, new_slug, input.force_takeover)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(app) => app,
|
||||||
|
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
|
||||||
|
if let Some(current) = s.apps.slug_in_history(new_slug).await? {
|
||||||
|
return Err(AppsApiError::SlugInHistory(current));
|
||||||
|
}
|
||||||
|
return Err(AppsApiError::Conflict(msg));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
after_meta
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(after_rename))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_app(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<StatusCode, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
|
||||||
|
// Soft pre-check for a clean error; the DB FK is the real guard
|
||||||
|
// (ON DELETE RESTRICT on scripts.app_id).
|
||||||
|
let n_scripts = s.apps.count_scripts_in_app(app.id).await?;
|
||||||
|
if n_scripts > 0 {
|
||||||
|
return Err(AppsApiError::HasScripts(n_scripts));
|
||||||
|
}
|
||||||
|
|
||||||
|
s.apps.delete(app.id).await?;
|
||||||
|
refresh_domain_cache(&s).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn slug_check(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Path(_id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<SlugCheckRequest>,
|
||||||
|
) -> Result<Json<SlugCheckResponse>, AppsApiError> {
|
||||||
|
match validate_slug(&input.new_slug) {
|
||||||
|
Err(AppsApiError::InvalidSlug(reason)) => {
|
||||||
|
return Ok(Json(SlugCheckResponse {
|
||||||
|
ok: false,
|
||||||
|
conflict_kind: Some("invalid"),
|
||||||
|
current_app: None,
|
||||||
|
reason: Some(reason),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Err(other) => return Err(other),
|
||||||
|
Ok(()) => {}
|
||||||
|
}
|
||||||
|
if let Some(app) = s.apps.get_by_slug(&input.new_slug).await? {
|
||||||
|
return Ok(Json(SlugCheckResponse {
|
||||||
|
ok: false,
|
||||||
|
conflict_kind: Some("current"),
|
||||||
|
current_app: Some(app),
|
||||||
|
reason: Some("another app currently uses this slug".into()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if let Some(app) = s.apps.slug_in_history(&input.new_slug).await? {
|
||||||
|
return Ok(Json(SlugCheckResponse {
|
||||||
|
ok: false,
|
||||||
|
conflict_kind: Some("historical"),
|
||||||
|
current_app: Some(app),
|
||||||
|
reason: Some("slug is a retired redirect; using it will break old links".into()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(Json(SlugCheckResponse {
|
||||||
|
ok: true,
|
||||||
|
conflict_kind: None,
|
||||||
|
current_app: None,
|
||||||
|
reason: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_domains(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<AppDomain>>, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
Ok(Json(s.domains.list_for_app(app.id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_domain(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<CreateDomainRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
let parsed = pattern::parse_app_domain(&input.pattern)?;
|
||||||
|
let created = s
|
||||||
|
.domains
|
||||||
|
.create(NewAppDomain {
|
||||||
|
app_id: app.id,
|
||||||
|
pattern: input.pattern,
|
||||||
|
shape: parsed.shape,
|
||||||
|
shape_key: parsed.shape_key,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
refresh_domain_cache(&s).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_domain(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Path((id_or_slug, domain_id)): Path<(String, Uuid)>,
|
||||||
|
) -> Result<StatusCode, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
let Some(domain) = s.domains.get(domain_id).await? else {
|
||||||
|
return Err(AppsApiError::DomainNotFound(domain_id));
|
||||||
|
};
|
||||||
|
if domain.app_id != app.id {
|
||||||
|
return Err(AppsApiError::DomainNotFound(domain_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: routes inside this app may reference this exact host
|
||||||
|
// pattern. The host-kind on the route is `strict` or `wildcard`
|
||||||
|
// (Any routes don't pin a specific host). We block deletion in
|
||||||
|
// either case and let the operator clean up first.
|
||||||
|
let strict = s
|
||||||
|
.routes
|
||||||
|
.count_for_app_host(app.id, picloud_shared::HostKind::Strict, &domain.pattern)
|
||||||
|
.await?;
|
||||||
|
let wild_suffix = domain
|
||||||
|
.pattern
|
||||||
|
.split_once('.')
|
||||||
|
.map(|(_, s)| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let wild = if wild_suffix.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
s.routes
|
||||||
|
.count_for_app_host(app.id, picloud_shared::HostKind::Wildcard, &wild_suffix)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
if strict + wild > 0 {
|
||||||
|
return Err(AppsApiError::DomainHasRoutes(strict + wild));
|
||||||
|
}
|
||||||
|
|
||||||
|
s.domains.delete(domain_id).await?;
|
||||||
|
refresh_domain_cache(&s).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn resolve_app(
|
||||||
|
apps: &dyn AppRepository,
|
||||||
|
ident: &str,
|
||||||
|
) -> Result<crate::app_repo::AppLookup, AppsApiError> {
|
||||||
|
if let Ok(uuid) = ident.parse::<Uuid>() {
|
||||||
|
if let Some(app) = apps.get_by_id(AppId::from(uuid)).await? {
|
||||||
|
return Ok(crate::app_repo::AppLookup {
|
||||||
|
app,
|
||||||
|
redirected: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Err(AppsApiError::AppNotFound(ident.to_string()));
|
||||||
|
}
|
||||||
|
apps.get_by_slug_or_history(ident)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppsApiError::AppNotFound(ident.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_slug(slug: &str) -> Result<(), AppsApiError> {
|
||||||
|
if slug.len() < SLUG_MIN || slug.len() > SLUG_MAX {
|
||||||
|
return Err(AppsApiError::InvalidSlug(format!(
|
||||||
|
"slug length must be between {SLUG_MIN} and {SLUG_MAX}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !slug
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.is_some_and(|c| c.is_ascii_alphanumeric())
|
||||||
|
{
|
||||||
|
return Err(AppsApiError::InvalidSlug(
|
||||||
|
"slug must start with [a-z0-9]".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for c in slug.chars() {
|
||||||
|
if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
|
||||||
|
return Err(AppsApiError::InvalidSlug(
|
||||||
|
"slug may only contain lowercase letters, digits, and '-'".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if RESERVED_SLUGS.contains(&slug) {
|
||||||
|
return Err(AppsApiError::InvalidSlug(format!(
|
||||||
|
"slug {slug:?} is reserved for system use"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the in-memory host → app_id cache used by the orchestrator.
|
||||||
|
/// Called after every domain CRUD operation.
|
||||||
|
pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError> {
|
||||||
|
let all = state.domains.list_all().await?;
|
||||||
|
let compiled = all
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|d| {
|
||||||
|
// Parse the stored pattern; skip on parse error rather than
|
||||||
|
// poisoning the entire cache. The handlers reject bad input,
|
||||||
|
// so this is purely defensive against a future migration
|
||||||
|
// that loosens the constraints.
|
||||||
|
pattern::parse_app_domain(&d.pattern)
|
||||||
|
.ok()
|
||||||
|
.map(|p| CompiledAppDomain {
|
||||||
|
app_id: d.app_id,
|
||||||
|
pattern: p.pattern,
|
||||||
|
shape_key: p.shape_key,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
state.domain_table.replace(compiled);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppsApiError {
|
||||||
|
#[error("app not found: {0}")]
|
||||||
|
AppNotFound(String),
|
||||||
|
|
||||||
|
#[error("domain not found: {0}")]
|
||||||
|
DomainNotFound(Uuid),
|
||||||
|
|
||||||
|
#[error("invalid slug: {0}")]
|
||||||
|
InvalidSlug(String),
|
||||||
|
|
||||||
|
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
|
||||||
|
SlugInHistory(App),
|
||||||
|
|
||||||
|
#[error("app still contains {0} script(s); delete or move them first")]
|
||||||
|
HasScripts(i64),
|
||||||
|
|
||||||
|
#[error("domain has {0} route(s) bound to it; delete the routes first")]
|
||||||
|
DomainHasRoutes(i64),
|
||||||
|
|
||||||
|
#[error("invalid pattern: {0}")]
|
||||||
|
Pattern(#[from] pattern::ParseError),
|
||||||
|
|
||||||
|
#[error("conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
|
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Repo(#[from] ScriptRepositoryError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppsApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, body) = match &self {
|
||||||
|
Self::AppNotFound(_)
|
||||||
|
| Self::DomainNotFound(_)
|
||||||
|
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||||
|
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
|
Self::InvalidSlug(_) | Self::Pattern(_) => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
json!({ "error": self.to_string() }),
|
||||||
|
),
|
||||||
|
Self::SlugInHistory(current) => (
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
json!({
|
||||||
|
"error": self.to_string(),
|
||||||
|
"conflict_kind": "historical",
|
||||||
|
"current_app": current,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Self::HasScripts(n) => (
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
json!({ "error": self.to_string(), "script_count": n }),
|
||||||
|
),
|
||||||
|
Self::DomainHasRoutes(n) => (
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
json!({ "error": self.to_string(), "route_count": n }),
|
||||||
|
),
|
||||||
|
Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => {
|
||||||
|
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
|
Self::Repo(ScriptRepositoryError::Db(e)) => {
|
||||||
|
tracing::error!(error = %e, "apps api db error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
132
crates/manager-core/src/auth.rs
Normal file
132
crates/manager-core/src/auth.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
//! Pure auth helpers: password hashing, session-token generation, and
|
||||||
|
//! token-to-hash conversion. No DB, no HTTP — repos and middleware live
|
||||||
|
//! in their own modules. Keeping this surface pure also keeps the unit
|
||||||
|
//! tests fast (no Postgres needed).
|
||||||
|
//!
|
||||||
|
//! Hash algorithm is Argon2id with the OWASP default parameters
|
||||||
|
//! (`Argon2::default()`). Tokens are 32 cryptographically random bytes
|
||||||
|
//! base64-url-encoded for the wire; their SHA-256 (hex) is what hits the
|
||||||
|
//! sessions table.
|
||||||
|
|
||||||
|
use argon2::password_hash::rand_core::OsRng as ArgonRng;
|
||||||
|
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||||
|
use argon2::Argon2;
|
||||||
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
|
use base64::Engine as _;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// Returned when the supplied password hash string isn't a valid PHC
|
||||||
|
/// Argon2id encoding. Only surfaces at bootstrap time when the operator
|
||||||
|
/// passes `PICLOUD_ADMIN_PASSWORD_HASH`.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[error("invalid Argon2id PHC hash")]
|
||||||
|
pub struct InvalidPasswordHash;
|
||||||
|
|
||||||
|
/// Hash a raw password into an Argon2id PHC-formatted string suitable
|
||||||
|
/// for `admin_users.password_hash`. The output already encodes the salt
|
||||||
|
/// and parameters; nothing else needs to be persisted alongside it.
|
||||||
|
pub fn hash_password(raw: &str) -> Result<String, argon2::password_hash::Error> {
|
||||||
|
let salt = SaltString::generate(&mut ArgonRng);
|
||||||
|
let hash = Argon2::default().hash_password(raw.as_bytes(), &salt)?;
|
||||||
|
Ok(hash.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constant-ish-time verify of a raw password against a PHC hash.
|
||||||
|
/// Returns `false` for any error (including malformed stored hash) —
|
||||||
|
/// callers should treat that case identically to "wrong password" so
|
||||||
|
/// nothing leaks about why auth failed.
|
||||||
|
#[must_use]
|
||||||
|
pub fn verify_password(stored_hash: &str, raw: &str) -> bool {
|
||||||
|
let Ok(parsed) = PasswordHash::new(stored_hash) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(raw.as_bytes(), &parsed)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that a string parses as a PHC Argon2id hash — used at
|
||||||
|
/// bootstrap to fail fast on malformed `PICLOUD_ADMIN_PASSWORD_HASH`
|
||||||
|
/// rather than write garbage into the DB and discover it at first login.
|
||||||
|
pub fn validate_password_hash(stored_hash: &str) -> Result<(), InvalidPasswordHash> {
|
||||||
|
PasswordHash::new(stored_hash).map_err(|_| InvalidPasswordHash)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newly minted session token: `raw` goes to the client (cookie + JSON
|
||||||
|
/// response), `hash` is what gets stored. Raw is unrecoverable from hash
|
||||||
|
/// even if the DB leaks.
|
||||||
|
pub struct GeneratedToken {
|
||||||
|
pub raw: String,
|
||||||
|
pub hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a fresh session token (32 random bytes base64-url-encoded).
|
||||||
|
/// Always succeeds — `OsRng::fill_bytes` panics on entropy failure
|
||||||
|
/// instead of returning, but that's a non-recoverable system condition.
|
||||||
|
#[must_use]
|
||||||
|
pub fn generate_session_token() -> GeneratedToken {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
let raw = URL_SAFE_NO_PAD.encode(bytes);
|
||||||
|
let hash = hash_token(&raw);
|
||||||
|
GeneratedToken { raw, hash }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SHA-256(raw) as lower-case hex. Stable lookup key for
|
||||||
|
/// `admin_sessions.token_hash`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn hash_token(raw: &str) -> String {
|
||||||
|
let digest = Sha256::digest(raw.as_bytes());
|
||||||
|
hex(&digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||||
|
let mut out = String::with_capacity(bytes.len() * 2);
|
||||||
|
for &b in bytes {
|
||||||
|
out.push(HEX[(b >> 4) as usize] as char);
|
||||||
|
out.push(HEX[(b & 0x0f) as usize] as char);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_verify_roundtrip() {
|
||||||
|
let h = hash_password("correct horse battery staple").unwrap();
|
||||||
|
assert!(verify_password(&h, "correct horse battery staple"));
|
||||||
|
assert!(!verify_password(&h, "wrong"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_returns_false_on_malformed_hash() {
|
||||||
|
assert!(!verify_password("not-a-phc-string", "anything"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_password_hash_accepts_phc() {
|
||||||
|
let h = hash_password("pw").unwrap();
|
||||||
|
assert!(validate_password_hash(&h).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_password_hash_rejects_garbage() {
|
||||||
|
assert!(validate_password_hash("not a hash").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_token_unique_and_hash_stable() {
|
||||||
|
let a = generate_session_token();
|
||||||
|
let b = generate_session_token();
|
||||||
|
assert_ne!(a.raw, b.raw, "tokens must be unique");
|
||||||
|
assert_ne!(a.hash, b.hash, "hashes must differ");
|
||||||
|
assert_eq!(a.hash, hash_token(&a.raw), "hash must be reproducible");
|
||||||
|
assert_eq!(a.hash.len(), 64, "sha256-hex is 64 chars");
|
||||||
|
}
|
||||||
|
}
|
||||||
233
crates/manager-core/src/auth_api.rs
Normal file
233
crates/manager-core/src/auth_api.rs
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
//! `/api/v1/admin/auth/*` — login, logout, who-am-I.
|
||||||
|
//!
|
||||||
|
//! Login mints an opaque session token, stores its SHA-256, sets the
|
||||||
|
//! `picloud_session` HttpOnly cookie, and also returns the raw token in
|
||||||
|
//! the JSON body for non-browser clients. The same token works as
|
||||||
|
//! `Authorization: Bearer …` afterward; there is no separate "API
|
||||||
|
//! token" concept yet.
|
||||||
|
//!
|
||||||
|
//! Logout deletes the session row regardless of whether the supplied
|
||||||
|
//! token matched anything (idempotent). `me` returns the row that the
|
||||||
|
//! middleware already attached to the request extensions.
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::{Extension, Request, State};
|
||||||
|
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||||
|
use axum::middleware::from_fn_with_state;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::Router;
|
||||||
|
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||||
|
use picloud_shared::AdminUserId;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::auth::{generate_session_token, hash_token, verify_password};
|
||||||
|
use crate::auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE};
|
||||||
|
|
||||||
|
pub fn auth_router(state: AuthState) -> Router {
|
||||||
|
// /login + /logout are unguarded (login is how you get in; logout
|
||||||
|
// is idempotent). /me is guarded — by definition it needs to know
|
||||||
|
// who you are, so the middleware must run first.
|
||||||
|
let guarded = Router::new()
|
||||||
|
.route("/auth/me", get(me))
|
||||||
|
.route_layer(from_fn_with_state(state.clone(), require_admin));
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.route("/auth/login", post(login))
|
||||||
|
.route("/auth/logout", post(logout))
|
||||||
|
.merge(guarded)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// DTOs
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LoginResponse {
|
||||||
|
pub user: AdminUserDto,
|
||||||
|
pub token: String,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AdminUserDto {
|
||||||
|
pub id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>) -> Response {
|
||||||
|
// Always perform a verify, even on missing/inactive users, to flatten
|
||||||
|
// timing and prevent username enumeration. The dummy hash is a real
|
||||||
|
// Argon2id PHC string for "x" — the verify will simply fail.
|
||||||
|
const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$dGltaW5nLWZsYXR0ZW4$Ux6dgPqgX1Mhg5fRgIeKZF3MWdYqJplKEz/cKLcSdks";
|
||||||
|
|
||||||
|
let creds = match state
|
||||||
|
.users
|
||||||
|
.get_credentials_by_username(&input.username)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_users credentials lookup failed");
|
||||||
|
return internal_error();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (stored_hash, user_id, username, is_active) = match creds {
|
||||||
|
Some(c) => (c.password_hash, Some(c.id), c.username, c.is_active),
|
||||||
|
None => (DUMMY_HASH.to_string(), None, String::new(), false),
|
||||||
|
};
|
||||||
|
|
||||||
|
let password_ok = verify_password(&stored_hash, &input.password);
|
||||||
|
if !password_ok || user_id.is_none() || !is_active {
|
||||||
|
return invalid_credentials();
|
||||||
|
}
|
||||||
|
let user_id = user_id.unwrap();
|
||||||
|
|
||||||
|
let token = generate_session_token();
|
||||||
|
let expires_at = Utc::now()
|
||||||
|
+ ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
|
||||||
|
|
||||||
|
if let Err(err) = state
|
||||||
|
.sessions
|
||||||
|
.create(user_id, &token.hash, expires_at)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(?err, "admin_sessions insert failed");
|
||||||
|
return internal_error();
|
||||||
|
}
|
||||||
|
if let Err(err) = state.users.touch_last_login(user_id).await {
|
||||||
|
// Non-fatal — log and continue. Login itself succeeded.
|
||||||
|
tracing::warn!(?err, "failed to touch admin last_login_at");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
HeaderValue::from_str(&build_cookie(&token.raw, state.ttl)).unwrap_or_else(|_| {
|
||||||
|
// Cookie text is ASCII-clean by construction; this branch is
|
||||||
|
// unreachable in practice but the type signature requires it.
|
||||||
|
HeaderValue::from_static("")
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
headers,
|
||||||
|
Json(LoginResponse {
|
||||||
|
user: AdminUserDto {
|
||||||
|
id: user_id,
|
||||||
|
username,
|
||||||
|
},
|
||||||
|
token: token.raw,
|
||||||
|
expires_at,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn logout(State(state): State<AuthState>, req: Request<Body>) -> Response {
|
||||||
|
// Pull token without requiring a valid session (logout is idempotent
|
||||||
|
// and we still want to clear the cookie on the client side).
|
||||||
|
let token = extract_token_for_logout(&req);
|
||||||
|
if let Some(raw) = token {
|
||||||
|
let hash = hash_token(&raw);
|
||||||
|
if let Err(err) = state.sessions.delete(&hash).await {
|
||||||
|
tracing::error!(?err, "admin_sessions delete failed");
|
||||||
|
// Still clear the cookie below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
HeaderValue::from_static("picloud_session=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0"),
|
||||||
|
);
|
||||||
|
(StatusCode::NO_CONTENT, headers).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn me(Extension(admin): Extension<AuthedAdmin>) -> Json<AdminUserDto> {
|
||||||
|
Json(AdminUserDto {
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn build_cookie(raw_token: &str, ttl: std::time::Duration) -> String {
|
||||||
|
// Secure is on by default; flip to off for HTTP-only dev with
|
||||||
|
// PICLOUD_COOKIE_SECURE=0. The header-injected bearer token works
|
||||||
|
// either way, so this is purely for browsers that prefer the cookie
|
||||||
|
// path (e.g., direct API hits without the dashboard's auth.ts).
|
||||||
|
let secure = std::env::var("PICLOUD_COOKIE_SECURE").ok().is_none_or(|v| {
|
||||||
|
!matches!(
|
||||||
|
v.to_ascii_lowercase().as_str(),
|
||||||
|
"0" | "false" | "no" | "off"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let secure_attr = if secure { "; Secure" } else { "" };
|
||||||
|
format!(
|
||||||
|
"{SESSION_COOKIE}={raw_token}; HttpOnly{secure_attr}; SameSite=Lax; Path=/; Max-Age={}",
|
||||||
|
ttl.as_secs()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_token_for_logout(req: &Request<Body>) -> Option<String> {
|
||||||
|
// Same precedence as the middleware — Authorization first, cookie
|
||||||
|
// fallback. Duplicated here because logout has to read the request
|
||||||
|
// before any middleware would run.
|
||||||
|
if let Some(value) = req.headers().get(header::AUTHORIZATION) {
|
||||||
|
if let Ok(s) = value.to_str() {
|
||||||
|
if let Some(token) = s.strip_prefix("Bearer ") {
|
||||||
|
let trimmed = token.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = req.headers().get(header::COOKIE) {
|
||||||
|
if let Ok(s) = value.to_str() {
|
||||||
|
for chunk in s.split(';') {
|
||||||
|
let chunk = chunk.trim();
|
||||||
|
if let Some(rest) = chunk.strip_prefix(&format!("{SESSION_COOKIE}=")) {
|
||||||
|
if !rest.is_empty() {
|
||||||
|
return Some(rest.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_credentials() -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(json!({ "error": "invalid credentials" })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal_error() -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": "internal error" })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
293
crates/manager-core/src/auth_bootstrap.rs
Normal file
293
crates/manager-core/src/auth_bootstrap.rs
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
//! First-run admin seeding from env vars. Idempotent: if any admin
|
||||||
|
//! already exists, this is a no-op (and a warning is logged when the
|
||||||
|
//! env vars are also set, so the operator notices the inert state).
|
||||||
|
//!
|
||||||
|
//! On a fresh install, exactly one row is inserted from:
|
||||||
|
//! - `PICLOUD_ADMIN_USERNAME` (required)
|
||||||
|
//! - `PICLOUD_ADMIN_PASSWORD_HASH` (preferred — pre-computed PHC) OR
|
||||||
|
//! - `PICLOUD_ADMIN_PASSWORD` (fallback — raw, hashed on the way in)
|
||||||
|
//!
|
||||||
|
//! After that initial seed, the env vars become inert. This is
|
||||||
|
//! deliberate: the env var is a one-time setup hatch, not a permanent
|
||||||
|
//! override (which would let anyone with systemd/compose access change
|
||||||
|
//! any admin's password without authentication). Recovery is the CLI
|
||||||
|
//! subcommand `picloud admin reset-password <username>`.
|
||||||
|
//!
|
||||||
|
//! The env-var reading is factored into `BootstrapEnv::from_process`
|
||||||
|
//! so the core logic stays pure (and testable) — the only side effect
|
||||||
|
//! in `bootstrap_first_admin` is the DB write and a tracing log.
|
||||||
|
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::admin_user_repo::AdminUserRepository;
|
||||||
|
use crate::auth::{hash_password, validate_password_hash};
|
||||||
|
|
||||||
|
pub const ENV_USERNAME: &str = "PICLOUD_ADMIN_USERNAME";
|
||||||
|
pub const ENV_PASSWORD: &str = "PICLOUD_ADMIN_PASSWORD";
|
||||||
|
pub const ENV_PASSWORD_HASH: &str = "PICLOUD_ADMIN_PASSWORD_HASH";
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum BootstrapError {
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Repo(#[from] crate::admin_user_repo::AdminUserRepositoryError),
|
||||||
|
|
||||||
|
#[error("{ENV_USERNAME} not set (required to bootstrap the first admin)")]
|
||||||
|
MissingUsername,
|
||||||
|
|
||||||
|
#[error(
|
||||||
|
"no admin password env var set; provide {ENV_PASSWORD_HASH} (preferred) or {ENV_PASSWORD}"
|
||||||
|
)]
|
||||||
|
MissingPassword,
|
||||||
|
|
||||||
|
#[error("{ENV_PASSWORD_HASH} is not a valid Argon2id PHC string")]
|
||||||
|
InvalidHash,
|
||||||
|
|
||||||
|
#[error("failed to hash password: {0}")]
|
||||||
|
HashFailure(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captured-at-call-site env values. The fields map 1:1 to the bootstrap
|
||||||
|
/// env vars. Read from the live process with `from_process`, or build
|
||||||
|
/// directly in tests to keep them free of process-env races.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct BootstrapEnv {
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub password_hash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BootstrapEnv {
|
||||||
|
/// Snapshot the bootstrap env vars from the current process.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_process() -> Self {
|
||||||
|
Self {
|
||||||
|
username: std::env::var(ENV_USERNAME).ok(),
|
||||||
|
password: std::env::var(ENV_PASSWORD).ok(),
|
||||||
|
password_hash: std::env::var(ENV_PASSWORD_HASH).ok(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn any_set(&self) -> bool {
|
||||||
|
self.username.is_some() || self.password.is_some() || self.password_hash.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the bootstrap. Reads env vars from the live process — the
|
||||||
|
/// canonical wiring for the binary.
|
||||||
|
pub async fn bootstrap_first_admin<R: AdminUserRepository + ?Sized>(
|
||||||
|
repo: &R,
|
||||||
|
) -> Result<(), BootstrapError> {
|
||||||
|
bootstrap_first_admin_with(repo, BootstrapEnv::from_process()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the bootstrap against an explicit env. Used by tests to keep
|
||||||
|
/// the bootstrap logic independent of process state.
|
||||||
|
pub async fn bootstrap_first_admin_with<R: AdminUserRepository + ?Sized>(
|
||||||
|
repo: &R,
|
||||||
|
env: BootstrapEnv,
|
||||||
|
) -> Result<(), BootstrapError> {
|
||||||
|
if repo.count_active().await? > 0 {
|
||||||
|
if env.any_set() {
|
||||||
|
warn!(
|
||||||
|
"{ENV_USERNAME}/{ENV_PASSWORD}/{ENV_PASSWORD_HASH} set but admin_users \
|
||||||
|
already populated — env values ignored. Use \
|
||||||
|
`picloud admin reset-password <user>` to change a password."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let username = env.username.ok_or(BootstrapError::MissingUsername)?;
|
||||||
|
|
||||||
|
let password_hash = match (env.password_hash, env.password) {
|
||||||
|
(Some(hash), maybe_raw) => {
|
||||||
|
if maybe_raw.is_some() {
|
||||||
|
warn!(
|
||||||
|
"both {ENV_PASSWORD_HASH} and {ENV_PASSWORD} set — \
|
||||||
|
using the pre-computed hash; raw password ignored."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validate_password_hash(&hash).map_err(|_| BootstrapError::InvalidHash)?;
|
||||||
|
hash
|
||||||
|
}
|
||||||
|
(None, Some(raw)) => {
|
||||||
|
hash_password(&raw).map_err(|e| BootstrapError::HashFailure(e.to_string()))?
|
||||||
|
}
|
||||||
|
(None, None) => return Err(BootstrapError::MissingPassword),
|
||||||
|
};
|
||||||
|
|
||||||
|
repo.create(&username, &password_hash).await?;
|
||||||
|
info!(username = %username, "bootstrapped initial admin user");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
//! These tests use an in-memory `AdminUserRepository` and the
|
||||||
|
//! `bootstrap_first_admin_with` overload so they never touch
|
||||||
|
//! process-global env vars. They can run in parallel safely.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::Utc;
|
||||||
|
use picloud_shared::AdminUserId;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::admin_user_repo::{AdminUserCredentials, AdminUserRepositoryError, AdminUserRow};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct InMemoryRepo {
|
||||||
|
rows: Mutex<Vec<AdminUserRow>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AdminUserRepository for InMemoryRepo {
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
_id: AdminUserId,
|
||||||
|
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn get_by_username(
|
||||||
|
&self,
|
||||||
|
_u: &str,
|
||||||
|
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn get_credentials_by_username(
|
||||||
|
&self,
|
||||||
|
_u: &str,
|
||||||
|
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
_password_hash: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let row = AdminUserRow {
|
||||||
|
id: AdminUserId::new(),
|
||||||
|
username: username.to_string(),
|
||||||
|
is_active: true,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
last_login_at: None,
|
||||||
|
};
|
||||||
|
self.rows.lock().unwrap().push(row.clone());
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
async fn update_username(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
_u: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn update_password_hash(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
_h: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn set_active(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
_a: bool,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn delete(&self, _i: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn touch_last_login(&self, _i: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn count_active(&self) -> Result<i64, AdminUserRepositoryError> {
|
||||||
|
Ok(i64::try_from(self.rows.lock().unwrap().len()).unwrap_or(i64::MAX))
|
||||||
|
}
|
||||||
|
async fn count_active_excluding(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
) -> Result<i64, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_db_creates_admin_from_raw_password() {
|
||||||
|
let repo = InMemoryRepo::default();
|
||||||
|
let env = BootstrapEnv {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: Some("supersecret".into()),
|
||||||
|
password_hash: None,
|
||||||
|
};
|
||||||
|
bootstrap_first_admin_with(&repo, env).await.unwrap();
|
||||||
|
assert_eq!(repo.rows.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_db_with_pre_hashed_password_succeeds() {
|
||||||
|
let repo = InMemoryRepo::default();
|
||||||
|
let prehashed = hash_password("pw").unwrap();
|
||||||
|
let env = BootstrapEnv {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: None,
|
||||||
|
password_hash: Some(prehashed),
|
||||||
|
};
|
||||||
|
bootstrap_first_admin_with(&repo, env).await.unwrap();
|
||||||
|
assert_eq!(repo.rows.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn populated_db_is_noop() {
|
||||||
|
let repo = InMemoryRepo::default();
|
||||||
|
repo.create("seeded", "x").await.unwrap();
|
||||||
|
let env = BootstrapEnv {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: Some("supersecret".into()),
|
||||||
|
password_hash: None,
|
||||||
|
};
|
||||||
|
bootstrap_first_admin_with(&repo, env).await.unwrap();
|
||||||
|
assert_eq!(repo.rows.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_username_fails() {
|
||||||
|
let repo = InMemoryRepo::default();
|
||||||
|
let env = BootstrapEnv {
|
||||||
|
username: None,
|
||||||
|
password: Some("supersecret".into()),
|
||||||
|
password_hash: None,
|
||||||
|
};
|
||||||
|
let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err();
|
||||||
|
assert!(matches!(err, BootstrapError::MissingUsername));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_password_fails() {
|
||||||
|
let repo = InMemoryRepo::default();
|
||||||
|
let env = BootstrapEnv {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: None,
|
||||||
|
password_hash: None,
|
||||||
|
};
|
||||||
|
let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err();
|
||||||
|
assert!(matches!(err, BootstrapError::MissingPassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_hash_fails() {
|
||||||
|
let repo = InMemoryRepo::default();
|
||||||
|
let env = BootstrapEnv {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: None,
|
||||||
|
password_hash: Some("not a phc hash".into()),
|
||||||
|
};
|
||||||
|
let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err();
|
||||||
|
assert!(matches!(err, BootstrapError::InvalidHash));
|
||||||
|
}
|
||||||
|
}
|
||||||
185
crates/manager-core/src/auth_middleware.rs
Normal file
185
crates/manager-core/src/auth_middleware.rs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
//! `require_admin` axum middleware: gates a router on a valid admin
|
||||||
|
//! session. Accepts the token from either the `picloud_session` cookie
|
||||||
|
//! or an `Authorization: Bearer …` header — same token system serves
|
||||||
|
//! the dashboard and CLI/CI clients.
|
||||||
|
//!
|
||||||
|
//! On success, injects `AuthedAdmin` as a request extension so handlers
|
||||||
|
//! can `Extension<AuthedAdmin>` to know who's calling. On failure,
|
||||||
|
//! returns 401 with a generic JSON body (no enumeration about whether
|
||||||
|
//! the token was wrong vs. the user was deactivated).
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::{Request, State};
|
||||||
|
use axum::http::{header, StatusCode};
|
||||||
|
use axum::middleware::Next;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use chrono::Utc;
|
||||||
|
use picloud_shared::AdminUserId;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::admin_session_repo::AdminSessionRepository;
|
||||||
|
use crate::admin_user_repo::AdminUserRepository;
|
||||||
|
use crate::auth::hash_token;
|
||||||
|
|
||||||
|
pub const SESSION_COOKIE: &str = "picloud_session";
|
||||||
|
|
||||||
|
/// Shared state for auth: the two repos plus the configured sliding
|
||||||
|
/// session TTL. Cheap to clone (`Arc` everywhere).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthState {
|
||||||
|
pub users: Arc<dyn AdminUserRepository>,
|
||||||
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||||
|
pub ttl: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request-extension type that authenticated handlers extract via
|
||||||
|
/// `Extension<AuthedAdmin>`. Available only inside guarded routers.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuthedAdmin {
|
||||||
|
pub id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Middleware function. Wire with
|
||||||
|
/// `axum::middleware::from_fn_with_state(auth_state, require_admin)`.
|
||||||
|
pub async fn require_admin(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
mut req: Request<Body>,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
let Some(token) = extract_token(&req) else {
|
||||||
|
return unauthorized();
|
||||||
|
};
|
||||||
|
let token_hash = hash_token(&token);
|
||||||
|
|
||||||
|
let lookup = match state.sessions.lookup(&token_hash).await {
|
||||||
|
Ok(Some(lookup)) => lookup,
|
||||||
|
Ok(None) => return unauthorized(),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_sessions lookup failed");
|
||||||
|
return internal_error();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve the user. A deleted user is impossible here (FK cascade
|
||||||
|
// wipes their sessions), but a deactivated user still needs to be
|
||||||
|
// rejected — and so does the edge case of a session predating the
|
||||||
|
// deactivate (we wipe their sessions on deactivate, but a race
|
||||||
|
// could land a request in flight).
|
||||||
|
let user = match state.users.get(lookup.user_id).await {
|
||||||
|
Ok(Some(u)) if u.is_active => u,
|
||||||
|
Ok(_) => return unauthorized(),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_users lookup failed");
|
||||||
|
return internal_error();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sliding window bump. Inline (not fire-and-forget) so a DB blip
|
||||||
|
// surfaces as a request error rather than silent stale sessions.
|
||||||
|
let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default();
|
||||||
|
if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await {
|
||||||
|
tracing::error!(?err, "admin_sessions touch failed");
|
||||||
|
return internal_error();
|
||||||
|
}
|
||||||
|
|
||||||
|
req.extensions_mut().insert(AuthedAdmin {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
});
|
||||||
|
next.run(req).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the bearer token out of an `Authorization` header (preferred)
|
||||||
|
/// or the `picloud_session` cookie (fallback for browser clients).
|
||||||
|
fn extract_token(req: &Request<Body>) -> Option<String> {
|
||||||
|
if let Some(value) = req.headers().get(header::AUTHORIZATION) {
|
||||||
|
if let Ok(s) = value.to_str() {
|
||||||
|
if let Some(token) = s.strip_prefix("Bearer ") {
|
||||||
|
let trimmed = token.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = req.headers().get(header::COOKIE) {
|
||||||
|
if let Ok(s) = value.to_str() {
|
||||||
|
for chunk in s.split(';') {
|
||||||
|
let chunk = chunk.trim();
|
||||||
|
if let Some(rest) = chunk.strip_prefix(&format!("{SESSION_COOKIE}=")) {
|
||||||
|
if !rest.is_empty() {
|
||||||
|
return Some(rest.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unauthorized() -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(json!({ "error": "authentication required" })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal_error() -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": "internal error" })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::http::Request;
|
||||||
|
|
||||||
|
fn req_with_header(name: &str, value: &str) -> Request<Body> {
|
||||||
|
Request::builder()
|
||||||
|
.header(name, value)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_bearer_token() {
|
||||||
|
let r = req_with_header("authorization", "Bearer abc123");
|
||||||
|
assert_eq!(extract_token(&r).as_deref(), Some("abc123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_bearer_with_no_token() {
|
||||||
|
let r = req_with_header("authorization", "Bearer ");
|
||||||
|
assert_eq!(extract_token(&r), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_cookie_token() {
|
||||||
|
let r = req_with_header("cookie", "foo=bar; picloud_session=xyz; baz=qux");
|
||||||
|
assert_eq!(extract_token(&r).as_deref(), Some("xyz"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bearer_wins_over_cookie() {
|
||||||
|
let r = Request::builder()
|
||||||
|
.header("authorization", "Bearer header-token")
|
||||||
|
.header("cookie", "picloud_session=cookie-token")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(extract_token(&r).as_deref(), Some("header-token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_none_when_neither_present() {
|
||||||
|
let r = Request::builder().body(Body::empty()).unwrap();
|
||||||
|
assert_eq!(extract_token(&r), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,18 @@
|
|||||||
//! the same DB for now; once we add caching and per-node ingress, the
|
//! the same DB for now; once we add caching and per-node ingress, the
|
||||||
//! manager will publish change events.
|
//! manager will publish change events.
|
||||||
|
|
||||||
|
pub mod admin_session_repo;
|
||||||
|
pub mod admin_user_repo;
|
||||||
|
pub mod admin_users_api;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
|
pub mod app_bootstrap;
|
||||||
|
pub mod app_domain_repo;
|
||||||
|
pub mod app_repo;
|
||||||
|
pub mod apps_api;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod auth_api;
|
||||||
|
pub mod auth_bootstrap;
|
||||||
|
pub mod auth_middleware;
|
||||||
pub mod log_sink;
|
pub mod log_sink;
|
||||||
pub mod migrations;
|
pub mod migrations;
|
||||||
pub mod repo;
|
pub mod repo;
|
||||||
@@ -13,7 +24,25 @@ pub mod route_repo;
|
|||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
|
|
||||||
|
pub use admin_session_repo::{
|
||||||
|
AdminSessionLookup, AdminSessionRepository, AdminSessionRepositoryError,
|
||||||
|
PostgresAdminSessionRepository,
|
||||||
|
};
|
||||||
|
pub use admin_user_repo::{
|
||||||
|
AdminUserCredentials, AdminUserRepository, AdminUserRepositoryError, AdminUserRow,
|
||||||
|
PostgresAdminUserRepository,
|
||||||
|
};
|
||||||
|
pub use admin_users_api::{admins_router, AdminsState};
|
||||||
pub use api::{admin_router, AdminState};
|
pub use api::{admin_router, AdminState};
|
||||||
|
pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
|
||||||
|
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
|
||||||
|
pub use app_repo::{AppLookup, AppRepository, PostgresAppRepository};
|
||||||
|
pub use apps_api::{apps_router, AppsState};
|
||||||
|
pub use auth_api::auth_router;
|
||||||
|
pub use auth_bootstrap::{
|
||||||
|
bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError,
|
||||||
|
};
|
||||||
|
pub use auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE};
|
||||||
pub use log_sink::PostgresExecutionLogSink;
|
pub use log_sink::PostgresExecutionLogSink;
|
||||||
pub use repo::{
|
pub use repo::{
|
||||||
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
|
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
|
||||||
|
|||||||
@@ -28,15 +28,16 @@ impl ExecutionLogSink for PostgresExecutionLogSink {
|
|||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO execution_logs ( \
|
"INSERT INTO execution_logs ( \
|
||||||
id, script_id, request_id, \
|
id, app_id, script_id, request_id, \
|
||||||
request_path, request_headers, request_body, \
|
request_path, request_headers, request_body, \
|
||||||
response_code, response_body, \
|
response_code, response_body, \
|
||||||
logs, duration_ms, status, created_at \
|
logs, duration_ms, status, created_at \
|
||||||
) VALUES ( \
|
) VALUES ( \
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 \
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 \
|
||||||
)",
|
)",
|
||||||
)
|
)
|
||||||
.bind(log.id)
|
.bind(log.id)
|
||||||
|
.bind(log.app_id.into_inner())
|
||||||
.bind(log.script_id.into_inner())
|
.bind(log.script_id.into_inner())
|
||||||
.bind(log.request_id.into_inner())
|
.bind(log.request_id.into_inner())
|
||||||
.bind(&log.request_path)
|
.bind(&log.request_path)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ use std::collections::BTreeMap;
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
||||||
use picloud_shared::{ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox};
|
use picloud_shared::{
|
||||||
|
AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
|
||||||
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -21,7 +23,10 @@ pub enum ScriptRepositoryError {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ScriptRepository: Send + Sync {
|
pub trait ScriptRepository: Send + Sync {
|
||||||
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError>;
|
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError>;
|
||||||
|
/// Every script across all apps. Mostly for tests and admin
|
||||||
|
/// "global" views; the dashboard reaches scripts via `list_for_app`.
|
||||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
|
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
|
||||||
async fn update(
|
async fn update(
|
||||||
&self,
|
&self,
|
||||||
@@ -35,6 +40,7 @@ pub trait ScriptRepository: Send + Sync {
|
|||||||
/// constraints; the repo enforces them in the DB regardless.
|
/// constraints; the repo enforces them in the DB regardless.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NewScript {
|
pub struct NewScript {
|
||||||
|
pub app_id: AppId,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub source: String,
|
pub source: String,
|
||||||
@@ -78,7 +84,7 @@ impl PostgresScriptRepository {
|
|||||||
impl ScriptRepository for PostgresScriptRepository {
|
impl ScriptRepository for PostgresScriptRepository {
|
||||||
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||||
let row = sqlx::query_as::<_, ScriptRow>(
|
let row = sqlx::query_as::<_, ScriptRow>(
|
||||||
"SELECT id, name, description, version, source, \
|
"SELECT id, app_id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||||
FROM scripts WHERE id = $1",
|
FROM scripts WHERE id = $1",
|
||||||
)
|
)
|
||||||
@@ -90,7 +96,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
|
|
||||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||||
"SELECT id, name, description, version, source, \
|
"SELECT id, app_id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||||
FROM scripts ORDER BY name",
|
FROM scripts ORDER BY name",
|
||||||
)
|
)
|
||||||
@@ -99,17 +105,30 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
Ok(rows.into_iter().map(Into::into).collect())
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||||
|
"SELECT id, app_id, name, description, version, source, \
|
||||||
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||||
|
FROM scripts WHERE app_id = $1 ORDER BY name",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
||||||
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
||||||
.unwrap_or_else(|_| serde_json::json!({}));
|
.unwrap_or_else(|_| serde_json::json!({}));
|
||||||
let res = sqlx::query_as::<_, ScriptRow>(
|
let res = sqlx::query_as::<_, ScriptRow>(
|
||||||
"INSERT INTO scripts ( \
|
"INSERT INTO scripts ( \
|
||||||
name, description, source, \
|
app_id, name, description, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox \
|
timeout_seconds, memory_limit_mb, sandbox \
|
||||||
) VALUES ($1, $2, $3, COALESCE($4, 30), COALESCE($5, 256), $6) \
|
) VALUES ($1, $2, $3, $4, COALESCE($5, 30), COALESCE($6, 256), $7) \
|
||||||
RETURNING id, name, description, version, source, \
|
RETURNING id, app_id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||||
)
|
)
|
||||||
|
.bind(input.app_id.into_inner())
|
||||||
.bind(&input.name)
|
.bind(&input.name)
|
||||||
.bind(input.description.as_deref())
|
.bind(input.description.as_deref())
|
||||||
.bind(&input.source)
|
.bind(&input.source)
|
||||||
@@ -123,7 +142,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
Ok(row) => Ok(row.into()),
|
Ok(row) => Ok(row.into()),
|
||||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
Err(ScriptRepositoryError::Conflict(format!(
|
Err(ScriptRepositoryError::Conflict(format!(
|
||||||
"a script named {:?} already exists",
|
"a script named {:?} already exists in this app",
|
||||||
input.name
|
input.name
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
@@ -141,12 +160,13 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
|
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
|
||||||
// Sandbox is replaced wholesale when present; per-field merging
|
// Sandbox is replaced wholesale when present; per-field merging
|
||||||
// happens in the API layer (clearer semantics for a "PUT a new
|
// happens in the API layer (clearer semantics for a "PUT a new
|
||||||
// sandbox config" call).
|
// sandbox config" call). app_id is immutable — moving a script
|
||||||
|
// to another app is a copy-and-delete, not an in-place edit.
|
||||||
let sandbox_json = patch
|
let sandbox_json = patch
|
||||||
.sandbox
|
.sandbox
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
||||||
let row = sqlx::query_as::<_, ScriptRow>(
|
let res = sqlx::query_as::<_, ScriptRow>(
|
||||||
"UPDATE scripts SET \
|
"UPDATE scripts SET \
|
||||||
name = COALESCE($2, name), \
|
name = COALESCE($2, name), \
|
||||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||||
@@ -157,7 +177,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
version = version + 1, \
|
version = version + 1, \
|
||||||
updated_at = NOW() \
|
updated_at = NOW() \
|
||||||
WHERE id = $1 \
|
WHERE id = $1 \
|
||||||
RETURNING id, name, description, version, source, \
|
RETURNING id, app_id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
@@ -169,10 +189,18 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
.bind(patch.memory_limit_mb)
|
.bind(patch.memory_limit_mb)
|
||||||
.bind(sandbox_json)
|
.bind(sandbox_json)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?;
|
.await;
|
||||||
|
|
||||||
row.map(Into::into)
|
match res {
|
||||||
.ok_or(ScriptRepositoryError::NotFound(id))
|
Ok(Some(row)) => Ok(row.into()),
|
||||||
|
Ok(None) => Err(ScriptRepositoryError::NotFound(id)),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
Err(ScriptRepositoryError::Conflict(
|
||||||
|
"a script with that name already exists in this app".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError> {
|
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError> {
|
||||||
@@ -191,6 +219,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct ScriptRow {
|
struct ScriptRow {
|
||||||
id: uuid::Uuid,
|
id: uuid::Uuid,
|
||||||
|
app_id: uuid::Uuid,
|
||||||
name: String,
|
name: String,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
version: i32,
|
version: i32,
|
||||||
@@ -211,6 +240,7 @@ impl From<ScriptRow> for Script {
|
|||||||
let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default();
|
let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default();
|
||||||
Self {
|
Self {
|
||||||
id: r.id.into(),
|
id: r.id.into(),
|
||||||
|
app_id: r.app_id.into(),
|
||||||
name: r.name,
|
name: r.name,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
version: r.version,
|
version: r.version,
|
||||||
@@ -284,7 +314,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
|
|||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> {
|
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, ExecutionLogRow>(
|
let rows = sqlx::query_as::<_, ExecutionLogRow>(
|
||||||
"SELECT id, script_id, request_id, \
|
"SELECT id, app_id, script_id, request_id, \
|
||||||
request_path, request_headers, request_body, \
|
request_path, request_headers, request_body, \
|
||||||
response_code, response_body, \
|
response_code, response_body, \
|
||||||
logs, duration_ms, status, created_at \
|
logs, duration_ms, status, created_at \
|
||||||
@@ -306,6 +336,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
|
|||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct ExecutionLogRow {
|
struct ExecutionLogRow {
|
||||||
id: uuid::Uuid,
|
id: uuid::Uuid,
|
||||||
|
app_id: uuid::Uuid,
|
||||||
script_id: uuid::Uuid,
|
script_id: uuid::Uuid,
|
||||||
request_id: uuid::Uuid,
|
request_id: uuid::Uuid,
|
||||||
request_path: Option<String>,
|
request_path: Option<String>,
|
||||||
@@ -331,6 +362,7 @@ impl From<ExecutionLogRow> for ExecutionLog {
|
|||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
app_id: r.app_id.into(),
|
||||||
script_id: r.script_id.into(),
|
script_id: r.script_id.into(),
|
||||||
request_id: RequestId::from(r.request_id),
|
request_id: RequestId::from(r.request_id),
|
||||||
request_path: r.request_path.unwrap_or_default(),
|
request_path: r.request_path.unwrap_or_default(),
|
||||||
|
|||||||
@@ -13,39 +13,49 @@ use axum::{
|
|||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
|
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
|
||||||
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
|
use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::repo::ScriptRepositoryError;
|
use crate::app_domain_repo::AppDomainRepository;
|
||||||
|
use crate::repo::{ScriptRepository, ScriptRepositoryError};
|
||||||
use crate::route_repo::{NewRoute, RouteRepository};
|
use crate::route_repo::{NewRoute, RouteRepository};
|
||||||
|
|
||||||
pub struct RouteAdminState<RR> {
|
pub struct RouteAdminState<RR, SR> {
|
||||||
pub routes: Arc<RR>,
|
pub routes: Arc<RR>,
|
||||||
|
/// Used to resolve `script_id → app_id` when creating routes (the
|
||||||
|
/// route inherits the script's app) and to scope conflict checks.
|
||||||
|
pub scripts: Arc<SR>,
|
||||||
|
/// Used to validate the route's host against the parent app's
|
||||||
|
/// declared domain claims.
|
||||||
|
pub domains: Arc<dyn AppDomainRepository>,
|
||||||
pub table: Arc<RouteTable>,
|
pub table: Arc<RouteTable>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<RR> Clone for RouteAdminState<RR> {
|
impl<RR, SR> Clone for RouteAdminState<RR, SR> {
|
||||||
fn clone(&self) -> Self {
|
fn clone(&self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
routes: self.routes.clone(),
|
routes: self.routes.clone(),
|
||||||
|
scripts: self.scripts.clone(),
|
||||||
|
domains: self.domains.clone(),
|
||||||
table: self.table.clone(),
|
table: self.table.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn route_admin_router<RR>(state: RouteAdminState<RR>) -> Router
|
pub fn route_admin_router<RR, SR>(state: RouteAdminState<RR, SR>) -> Router
|
||||||
where
|
where
|
||||||
RR: RouteRepository + 'static,
|
RR: RouteRepository + 'static,
|
||||||
|
SR: ScriptRepository + 'static,
|
||||||
{
|
{
|
||||||
Router::new()
|
Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/scripts/{id}/routes",
|
"/scripts/{id}/routes",
|
||||||
get(list_routes::<RR>).post(create_route::<RR>),
|
get(list_routes::<RR, SR>).post(create_route::<RR, SR>),
|
||||||
)
|
)
|
||||||
.route("/routes/{route_id}", delete(delete_route::<RR>))
|
.route("/routes/{route_id}", delete(delete_route::<RR, SR>))
|
||||||
.route("/routes:check", post(check_route::<RR>))
|
.route("/routes:check", post(check_route::<RR, SR>))
|
||||||
.route("/routes:match", post(match_route::<RR>))
|
.route("/routes:match", post(match_route::<RR, SR>))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +77,10 @@ pub struct CreateRouteRequest {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CheckRouteRequest {
|
pub struct CheckRouteRequest {
|
||||||
|
/// Required: which app's route table this hypothetical route would
|
||||||
|
/// join. Conflict checks are strictly intra-app (cross-app route
|
||||||
|
/// errors would leak tenant info — see blueprint §11.5).
|
||||||
|
pub app_id: AppId,
|
||||||
pub host_kind: HostKind,
|
pub host_kind: HostKind,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub host: String,
|
pub host: String,
|
||||||
@@ -84,6 +98,9 @@ pub struct CheckRouteResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct MatchRouteRequest {
|
pub struct MatchRouteRequest {
|
||||||
|
/// Which app's route table to dispatch against. The dashboard's
|
||||||
|
/// route-preview tester always knows the current app context.
|
||||||
|
pub app_id: AppId,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
#[serde(default = "default_method")]
|
#[serde(default = "default_method")]
|
||||||
pub method: String,
|
pub method: String,
|
||||||
@@ -111,15 +128,15 @@ pub struct MatchedRoute {
|
|||||||
// Handlers
|
// Handlers
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
async fn list_routes<RR: RouteRepository>(
|
async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
Path(script_id): Path<ScriptId>,
|
Path(script_id): Path<ScriptId>,
|
||||||
) -> Result<Json<Vec<Route>>, RouteApiError> {
|
) -> Result<Json<Vec<Route>>, RouteApiError> {
|
||||||
Ok(Json(state.routes.list_for_script(script_id).await?))
|
Ok(Json(state.routes.list_for_script(script_id).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_route<RR: RouteRepository>(
|
async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
Path(script_id): Path<ScriptId>,
|
Path(script_id): Path<ScriptId>,
|
||||||
Json(input): Json<CreateRouteRequest>,
|
Json(input): Json<CreateRouteRequest>,
|
||||||
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
|
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
|
||||||
@@ -130,8 +147,22 @@ async fn create_route<RR: RouteRepository>(
|
|||||||
input.host_param_name.as_deref(),
|
input.host_param_name.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Within-kind conflict check against existing routes.
|
// Look up the script's owning app — every route inherits it.
|
||||||
let existing = state.routes.list_all().await?;
|
let script = state
|
||||||
|
.scripts
|
||||||
|
.get(script_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||||
|
let app_id = script.app_id;
|
||||||
|
|
||||||
|
// Validate the route's host is consistent with one of the app's
|
||||||
|
// domain claims. `HostKind::Any` is always permitted (catches every
|
||||||
|
// host the app already owns). Specific hosts must match a claim.
|
||||||
|
validate_route_host_against_app(state.domains.as_ref(), app_id, input.host_kind, &input.host)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Within-app conflict check (cross-app is impossible by construction).
|
||||||
|
let existing = state.routes.list_for_app(app_id).await?;
|
||||||
if let Some((conflicting, reason)) = first_conflict(
|
if let Some((conflicting, reason)) = first_conflict(
|
||||||
&existing,
|
&existing,
|
||||||
input.host_kind,
|
input.host_kind,
|
||||||
@@ -149,6 +180,7 @@ async fn create_route<RR: RouteRepository>(
|
|||||||
let created = state
|
let created = state
|
||||||
.routes
|
.routes
|
||||||
.create(NewRoute {
|
.create(NewRoute {
|
||||||
|
app_id,
|
||||||
script_id,
|
script_id,
|
||||||
host_kind: input.host_kind,
|
host_kind: input.host_kind,
|
||||||
host: input.host,
|
host: input.host,
|
||||||
@@ -162,8 +194,8 @@ async fn create_route<RR: RouteRepository>(
|
|||||||
Ok((StatusCode::CREATED, Json(created)))
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_route<RR: RouteRepository>(
|
async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
Path(route_id): Path<Uuid>,
|
Path(route_id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, RouteApiError> {
|
) -> Result<StatusCode, RouteApiError> {
|
||||||
state.routes.delete(route_id).await?;
|
state.routes.delete(route_id).await?;
|
||||||
@@ -171,14 +203,14 @@ async fn delete_route<RR: RouteRepository>(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_route<RR: RouteRepository>(
|
async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
Json(input): Json<CheckRouteRequest>,
|
Json(input): Json<CheckRouteRequest>,
|
||||||
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
|
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
|
||||||
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
|
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
|
||||||
pattern::parse_host(input.host_kind, &input.host, None)?;
|
pattern::parse_host(input.host_kind, &input.host, None)?;
|
||||||
|
|
||||||
let existing = state.routes.list_all().await?;
|
let existing = state.routes.list_for_app(input.app_id).await?;
|
||||||
let conflict = first_conflict(
|
let conflict = first_conflict(
|
||||||
&existing,
|
&existing,
|
||||||
input.host_kind,
|
input.host_kind,
|
||||||
@@ -201,8 +233,8 @@ async fn check_route<RR: RouteRepository>(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn match_route<RR: RouteRepository>(
|
async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
Json(input): Json<MatchRouteRequest>,
|
Json(input): Json<MatchRouteRequest>,
|
||||||
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
|
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
|
||||||
let parsed = url::Url::parse(&input.url)
|
let parsed = url::Url::parse(&input.url)
|
||||||
@@ -210,7 +242,9 @@ async fn match_route<RR: RouteRepository>(
|
|||||||
let host = parsed.host_str().unwrap_or("").to_string();
|
let host = parsed.host_str().unwrap_or("").to_string();
|
||||||
let path = parsed.path().to_string();
|
let path = parsed.path().to_string();
|
||||||
|
|
||||||
let result = state.table.match_request(&host, &input.method, &path);
|
let result = state
|
||||||
|
.table
|
||||||
|
.match_request_for_app(input.app_id, &host, &input.method, &path);
|
||||||
Ok(Json(MatchRouteResponse {
|
Ok(Json(MatchRouteResponse {
|
||||||
matched: result.map(|r| MatchedRoute {
|
matched: result.map(|r| MatchedRoute {
|
||||||
route_id: r.matched.route_id,
|
route_id: r.matched.route_id,
|
||||||
@@ -263,12 +297,12 @@ fn first_conflict(
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn refresh_table<RR: RouteRepository>(
|
async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
state: &RouteAdminState<RR>,
|
state: &RouteAdminState<RR, SR>,
|
||||||
) -> Result<(), RouteApiError> {
|
) -> Result<(), RouteApiError> {
|
||||||
let rows = state.routes.list_all().await?;
|
let rows = state.routes.list_all().await?;
|
||||||
let compiled = compile_routes(&rows)?;
|
let compiled = compile_routes(&rows)?;
|
||||||
state.table.replace(compiled);
|
state.table.replace_all(compiled);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +311,7 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
|
|||||||
.map(|r| {
|
.map(|r| {
|
||||||
Ok(CompiledRoute {
|
Ok(CompiledRoute {
|
||||||
route_id: r.id,
|
route_id: r.id,
|
||||||
|
app_id: r.app_id,
|
||||||
script_id: r.script_id,
|
script_id: r.script_id,
|
||||||
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
|
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
|
||||||
path: pattern::parse_path(r.path_kind, &r.path)?,
|
path: pattern::parse_path(r.path_kind, &r.path)?,
|
||||||
@@ -286,6 +321,79 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate that a new route's (host_kind, host) is consistent with at
|
||||||
|
/// least one of the parent app's domain claims. `HostKind::Any` is
|
||||||
|
/// always permitted — it catches every host the app already owns.
|
||||||
|
async fn validate_route_host_against_app(
|
||||||
|
domains: &dyn AppDomainRepository,
|
||||||
|
app_id: AppId,
|
||||||
|
host_kind: HostKind,
|
||||||
|
host: &str,
|
||||||
|
) -> Result<(), RouteApiError> {
|
||||||
|
if matches!(host_kind, HostKind::Any) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let claims = domains.list_for_app(app_id).await?;
|
||||||
|
if claims.is_empty() {
|
||||||
|
return Err(RouteApiError::HostNotClaimed {
|
||||||
|
host: host.to_string(),
|
||||||
|
available_claims: vec![],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let host_lower = host.to_ascii_lowercase();
|
||||||
|
for claim in &claims {
|
||||||
|
let claim_lower = claim.pattern.to_ascii_lowercase();
|
||||||
|
match (host_kind, claim.shape) {
|
||||||
|
// Strict route under exact claim: must match exactly.
|
||||||
|
(HostKind::Strict, picloud_shared::DomainShape::Exact) => {
|
||||||
|
if host_lower == claim_lower {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strict route under wildcard/parameterized: must end with
|
||||||
|
// ".<suffix>" where the claim's suffix is the part after
|
||||||
|
// `*.` or `{...}.`.
|
||||||
|
(
|
||||||
|
HostKind::Strict,
|
||||||
|
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
|
||||||
|
) => {
|
||||||
|
let suffix = claim_lower
|
||||||
|
.split_once('.')
|
||||||
|
.map(|(_, s)| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let needle = format!(".{suffix}");
|
||||||
|
if !suffix.is_empty() && host_lower.ends_with(&needle) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wildcard route: must match a wildcard or parameterized
|
||||||
|
// claim with identical suffix.
|
||||||
|
(
|
||||||
|
HostKind::Wildcard,
|
||||||
|
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
|
||||||
|
) => {
|
||||||
|
let claim_suffix = claim_lower
|
||||||
|
.split_once('.')
|
||||||
|
.map(|(_, s)| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if claim_suffix == host_lower {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wildcard route under exact claim: not allowed (would
|
||||||
|
// shadow other apps' subdomains the operator didn't claim).
|
||||||
|
(HostKind::Wildcard, picloud_shared::DomainShape::Exact) => {}
|
||||||
|
(HostKind::Any, _) => unreachable!("handled above"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(RouteApiError::HostNotClaimed {
|
||||||
|
host: host.to_string(),
|
||||||
|
available_claims: claims.into_iter().map(|c| c.pattern).collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Errors
|
// Errors
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -304,6 +412,15 @@ pub enum RouteApiError {
|
|||||||
#[error("bad request: {0}")]
|
#[error("bad request: {0}")]
|
||||||
BadRequest(String),
|
BadRequest(String),
|
||||||
|
|
||||||
|
#[error("script not found: {0}")]
|
||||||
|
ScriptNotFound(ScriptId),
|
||||||
|
|
||||||
|
#[error("host {host:?} is not claimed by this app")]
|
||||||
|
HostNotClaimed {
|
||||||
|
host: String,
|
||||||
|
available_claims: Vec<String>,
|
||||||
|
},
|
||||||
|
|
||||||
#[error("repository error: {0}")]
|
#[error("repository error: {0}")]
|
||||||
Repo(#[from] ScriptRepositoryError),
|
Repo(#[from] ScriptRepositoryError),
|
||||||
}
|
}
|
||||||
@@ -326,10 +443,21 @@ impl IntoResponse for RouteApiError {
|
|||||||
StatusCode::UNPROCESSABLE_ENTITY,
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
serde_json::json!({ "error": self.to_string() }),
|
serde_json::json!({ "error": self.to_string() }),
|
||||||
),
|
),
|
||||||
Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
Self::ScriptNotFound(_) | Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
serde_json::json!({ "error": self.to_string() }),
|
serde_json::json!({ "error": self.to_string() }),
|
||||||
),
|
),
|
||||||
|
Self::HostNotClaimed {
|
||||||
|
host,
|
||||||
|
available_claims,
|
||||||
|
} => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
serde_json::json!({
|
||||||
|
"error": self.to_string(),
|
||||||
|
"host": host,
|
||||||
|
"available_claims": available_claims,
|
||||||
|
}),
|
||||||
|
),
|
||||||
Self::Repo(ScriptRepositoryError::Conflict(_)) => (
|
Self::Repo(ScriptRepositoryError::Conflict(_)) => (
|
||||||
StatusCode::CONFLICT,
|
StatusCode::CONFLICT,
|
||||||
serde_json::json!({ "error": self.to_string() }),
|
serde_json::json!({ "error": self.to_string() }),
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
//! CRUD over the `routes` table.
|
//! CRUD over the `routes` table.
|
||||||
//!
|
//!
|
||||||
//! The orchestrator's `RouteTable` is repopulated from this repo after
|
//! The orchestrator's `AppRouteTables` is repopulated from this repo
|
||||||
//! every write — see the route_admin module for the binding.
|
//! after every write — see the route_admin module for the binding.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
|
use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ use crate::repo::ScriptRepositoryError;
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NewRoute {
|
pub struct NewRoute {
|
||||||
|
pub app_id: AppId,
|
||||||
pub script_id: ScriptId,
|
pub script_id: ScriptId,
|
||||||
pub host_kind: HostKind,
|
pub host_kind: HostKind,
|
||||||
pub host: String,
|
pub host: String,
|
||||||
@@ -24,12 +25,21 @@ pub struct NewRoute {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait RouteRepository: Send + Sync {
|
pub trait RouteRepository: Send + Sync {
|
||||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
|
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||||
async fn list_for_script(
|
async fn list_for_script(
|
||||||
&self,
|
&self,
|
||||||
script_id: ScriptId,
|
script_id: ScriptId,
|
||||||
) -> Result<Vec<Route>, ScriptRepositoryError>;
|
) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError>;
|
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError>;
|
||||||
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError>;
|
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError>;
|
||||||
|
/// Count routes whose host_kind/host pair matches a pattern in
|
||||||
|
/// `app_id`. Used by the domain-claim delete guard.
|
||||||
|
async fn count_for_app_host(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
host_kind: HostKind,
|
||||||
|
host: &str,
|
||||||
|
) -> Result<i64, ScriptRepositoryError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PostgresRouteRepository {
|
pub struct PostgresRouteRepository {
|
||||||
@@ -47,7 +57,7 @@ impl PostgresRouteRepository {
|
|||||||
impl RouteRepository for PostgresRouteRepository {
|
impl RouteRepository for PostgresRouteRepository {
|
||||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
|
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, RouteRow>(
|
let rows = sqlx::query_as::<_, RouteRow>(
|
||||||
"SELECT id, script_id, host_kind, host, host_param_name, \
|
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method, created_at \
|
path_kind, path, method, created_at \
|
||||||
FROM routes ORDER BY created_at",
|
FROM routes ORDER BY created_at",
|
||||||
)
|
)
|
||||||
@@ -56,12 +66,24 @@ impl RouteRepository for PostgresRouteRepository {
|
|||||||
Ok(rows.into_iter().map(Into::into).collect())
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, RouteRow>(
|
||||||
|
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
|
path_kind, path, method, created_at \
|
||||||
|
FROM routes WHERE app_id = $1 ORDER BY created_at",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_for_script(
|
async fn list_for_script(
|
||||||
&self,
|
&self,
|
||||||
script_id: ScriptId,
|
script_id: ScriptId,
|
||||||
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, RouteRow>(
|
let rows = sqlx::query_as::<_, RouteRow>(
|
||||||
"SELECT id, script_id, host_kind, host, host_param_name, \
|
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method, created_at \
|
path_kind, path, method, created_at \
|
||||||
FROM routes WHERE script_id = $1 ORDER BY created_at",
|
FROM routes WHERE script_id = $1 ORDER BY created_at",
|
||||||
)
|
)
|
||||||
@@ -74,12 +96,13 @@ impl RouteRepository for PostgresRouteRepository {
|
|||||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
|
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
|
||||||
let res = sqlx::query_as::<_, RouteRow>(
|
let res = sqlx::query_as::<_, RouteRow>(
|
||||||
"INSERT INTO routes ( \
|
"INSERT INTO routes ( \
|
||||||
script_id, host_kind, host, host_param_name, \
|
app_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method \
|
path_kind, path, method \
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
|
||||||
RETURNING id, script_id, host_kind, host, host_param_name, \
|
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method, created_at",
|
path_kind, path, method, created_at",
|
||||||
)
|
)
|
||||||
|
.bind(input.app_id.into_inner())
|
||||||
.bind(input.script_id.into_inner())
|
.bind(input.script_id.into_inner())
|
||||||
.bind(host_kind_str(input.host_kind))
|
.bind(host_kind_str(input.host_kind))
|
||||||
.bind(&input.host)
|
.bind(&input.host)
|
||||||
@@ -112,6 +135,24 @@ impl RouteRepository for PostgresRouteRepository {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn count_for_app_host(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
host_kind: HostKind,
|
||||||
|
host: &str,
|
||||||
|
) -> Result<i64, ScriptRepositoryError> {
|
||||||
|
let count: (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*) FROM routes \
|
||||||
|
WHERE app_id = $1 AND host_kind = $2 AND host = $3",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(host_kind_str(host_kind))
|
||||||
|
.bind(host)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count.0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn host_kind_str(k: HostKind) -> &'static str {
|
const fn host_kind_str(k: HostKind) -> &'static str {
|
||||||
@@ -133,6 +174,7 @@ const fn path_kind_str(k: PathKind) -> &'static str {
|
|||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct RouteRow {
|
struct RouteRow {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
|
app_id: Uuid,
|
||||||
script_id: Uuid,
|
script_id: Uuid,
|
||||||
host_kind: String,
|
host_kind: String,
|
||||||
host: String,
|
host: String,
|
||||||
@@ -147,6 +189,7 @@ impl From<RouteRow> for Route {
|
|||||||
fn from(r: RouteRow) -> Self {
|
fn from(r: RouteRow) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
app_id: r.app_id.into(),
|
||||||
script_id: r.script_id.into(),
|
script_id: r.script_id.into(),
|
||||||
host_kind: match r.host_kind.as_str() {
|
host_kind: match r.host_kind.as_str() {
|
||||||
"strict" => HostKind::Strict,
|
"strict" => HostKind::Strict,
|
||||||
|
|||||||
@@ -3,6 +3,43 @@
|
|||||||
|
|
||||||
## tables
|
## tables
|
||||||
|
|
||||||
|
table: admin_sessions
|
||||||
|
token_hash: text NOT NULL
|
||||||
|
user_id: uuid NOT NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
expires_at: timestamp with time zone NOT NULL
|
||||||
|
last_used_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: admin_users
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
username: text NOT NULL
|
||||||
|
password_hash: text NOT NULL
|
||||||
|
is_active: boolean NOT NULL default=true
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
last_login_at: timestamp with time zone NULL
|
||||||
|
|
||||||
|
table: app_domains
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
pattern: text NOT NULL
|
||||||
|
shape: text NOT NULL
|
||||||
|
shape_key: text NOT NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: app_slug_history
|
||||||
|
slug: text NOT NULL
|
||||||
|
current_app_id: uuid NOT NULL
|
||||||
|
retired_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: apps
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
slug: text NOT NULL
|
||||||
|
name: text NOT NULL
|
||||||
|
description: text NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
table: execution_logs
|
table: execution_logs
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
script_id: uuid NOT NULL
|
script_id: uuid NOT NULL
|
||||||
@@ -16,6 +53,7 @@ table: execution_logs
|
|||||||
duration_ms: integer NOT NULL default=0
|
duration_ms: integer NOT NULL default=0
|
||||||
status: text NOT NULL
|
status: text NOT NULL
|
||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
|
||||||
table: routes
|
table: routes
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
@@ -27,6 +65,7 @@ table: routes
|
|||||||
path: text NOT NULL
|
path: text NOT NULL
|
||||||
method: text NULL
|
method: text NULL
|
||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
|
||||||
table: scripts
|
table: scripts
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
@@ -39,42 +78,94 @@ table: scripts
|
|||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
updated_at: timestamp with time zone NOT NULL default=now()
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
sandbox: jsonb NOT NULL default='{}'::jsonb
|
sandbox: jsonb NOT NULL default='{}'::jsonb
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
|
||||||
## indexes
|
## indexes
|
||||||
|
|
||||||
|
indexes on admin_sessions:
|
||||||
|
admin_sessions_expiry_idx: public.admin_sessions USING btree (expires_at)
|
||||||
|
admin_sessions_pkey: public.admin_sessions USING btree (token_hash)
|
||||||
|
admin_sessions_user_idx: public.admin_sessions USING btree (user_id)
|
||||||
|
|
||||||
|
indexes on admin_users:
|
||||||
|
admin_users_pkey: public.admin_users USING btree (id)
|
||||||
|
admin_users_username_key: public.admin_users USING btree (username)
|
||||||
|
|
||||||
|
indexes on app_domains:
|
||||||
|
app_domains_app_id_idx: public.app_domains USING btree (app_id)
|
||||||
|
app_domains_pkey: public.app_domains USING btree (id)
|
||||||
|
app_domains_shape_key_key: public.app_domains USING btree (shape_key)
|
||||||
|
|
||||||
|
indexes on app_slug_history:
|
||||||
|
app_slug_history_pkey: public.app_slug_history USING btree (slug)
|
||||||
|
|
||||||
|
indexes on apps:
|
||||||
|
apps_pkey: public.apps USING btree (id)
|
||||||
|
apps_slug_key: public.apps USING btree (slug)
|
||||||
|
|
||||||
indexes on execution_logs:
|
indexes on execution_logs:
|
||||||
|
execution_logs_app_id_created_at_idx: public.execution_logs USING btree (app_id, created_at DESC)
|
||||||
execution_logs_pkey: public.execution_logs USING btree (id)
|
execution_logs_pkey: public.execution_logs USING btree (id)
|
||||||
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
|
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
|
||||||
|
|
||||||
indexes on routes:
|
indexes on routes:
|
||||||
|
routes_app_id_idx: public.routes USING btree (app_id)
|
||||||
routes_lookup_idx: public.routes USING btree (host_kind, host)
|
routes_lookup_idx: public.routes USING btree (host_kind, host)
|
||||||
routes_pkey: public.routes USING btree (id)
|
routes_pkey: public.routes USING btree (id)
|
||||||
routes_script_id_idx: public.routes USING btree (script_id)
|
routes_script_id_idx: public.routes USING btree (script_id)
|
||||||
routes_unique_binding_idx: public.routes USING btree (host_kind, host, path_kind, path, COALESCE(method, ''::text))
|
routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text))
|
||||||
|
|
||||||
indexes on scripts:
|
indexes on scripts:
|
||||||
scripts_name_uidx: public.scripts USING btree (lower(name))
|
scripts_app_id_idx: public.scripts USING btree (app_id)
|
||||||
|
scripts_name_uidx: public.scripts USING btree (app_id, lower(name))
|
||||||
scripts_pkey: public.scripts USING btree (id)
|
scripts_pkey: public.scripts USING btree (id)
|
||||||
|
|
||||||
## constraints
|
## constraints
|
||||||
|
|
||||||
|
constraints on admin_sessions:
|
||||||
|
[FOREIGN KEY] admin_sessions_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] admin_sessions_pkey: PRIMARY KEY (token_hash)
|
||||||
|
|
||||||
|
constraints on admin_users:
|
||||||
|
[PRIMARY KEY] admin_users_pkey: PRIMARY KEY (id)
|
||||||
|
[UNIQUE] admin_users_username_key: UNIQUE (username)
|
||||||
|
|
||||||
|
constraints on app_domains:
|
||||||
|
[CHECK] app_domains_shape_check: CHECK ((shape = ANY (ARRAY['exact'::text, 'wildcard'::text, 'parameterized'::text])))
|
||||||
|
[FOREIGN KEY] app_domains_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] app_domains_pkey: PRIMARY KEY (id)
|
||||||
|
[UNIQUE] app_domains_shape_key_key: UNIQUE (shape_key)
|
||||||
|
|
||||||
|
constraints on app_slug_history:
|
||||||
|
[FOREIGN KEY] app_slug_history_current_app_id_fkey: FOREIGN KEY (current_app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] app_slug_history_pkey: PRIMARY KEY (slug)
|
||||||
|
|
||||||
|
constraints on apps:
|
||||||
|
[PRIMARY KEY] apps_pkey: PRIMARY KEY (id)
|
||||||
|
[UNIQUE] apps_slug_key: UNIQUE (slug)
|
||||||
|
|
||||||
constraints on execution_logs:
|
constraints on execution_logs:
|
||||||
[CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text])))
|
[CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text])))
|
||||||
|
[FOREIGN KEY] execution_logs_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
constraints on routes:
|
constraints on routes:
|
||||||
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
||||||
[CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
|
[CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
|
||||||
|
[FOREIGN KEY] routes_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
[FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
[FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
constraints on scripts:
|
constraints on scripts:
|
||||||
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
|
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
|
||||||
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
|
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
|
||||||
|
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
|
||||||
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
## applied migrations
|
## applied migrations
|
||||||
0001: init
|
0001: init
|
||||||
0002: sandbox
|
0002: sandbox
|
||||||
0003: routes
|
0003: routes
|
||||||
|
0004: admin auth
|
||||||
|
0005: apps
|
||||||
|
|||||||
@@ -17,22 +17,26 @@ use axum::{
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
|
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
|
AppId, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
|
||||||
};
|
};
|
||||||
use serde_json::Value as Json_;
|
use serde_json::Value as Json_;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::client::ExecutorClient;
|
use crate::client::ExecutorClient;
|
||||||
use crate::resolver::{ResolverError, ScriptResolver};
|
use crate::resolver::{ResolverError, ScriptResolver};
|
||||||
use crate::routing::RouteTable;
|
use crate::routing::{AppDomainTable, RouteTable};
|
||||||
|
|
||||||
/// State shared by data-plane handlers.
|
/// State shared by data-plane handlers.
|
||||||
pub struct DataPlaneState<E, R> {
|
pub struct DataPlaneState<E, R> {
|
||||||
pub executor: Arc<E>,
|
pub executor: Arc<E>,
|
||||||
pub resolver: Arc<R>,
|
pub resolver: Arc<R>,
|
||||||
pub log_sink: Arc<dyn ExecutionLogSink>,
|
pub log_sink: Arc<dyn ExecutionLogSink>,
|
||||||
/// Routing table for user-defined paths. Shared with the manager
|
/// Host → app_id resolver. Run before `routes` to filter to the
|
||||||
/// (admin router writes; this side reads).
|
/// owning app's slice. Shared with the manager (writes invalidate
|
||||||
|
/// the cache by replacing the table).
|
||||||
|
pub app_domains: Arc<AppDomainTable>,
|
||||||
|
/// Routing table for user-defined paths, partitioned per app.
|
||||||
|
/// Shared with the manager (admin router writes; this side reads).
|
||||||
pub routes: Arc<RouteTable>,
|
pub routes: Arc<RouteTable>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +46,7 @@ impl<E, R> Clone for DataPlaneState<E, R> {
|
|||||||
executor: self.executor.clone(),
|
executor: self.executor.clone(),
|
||||||
resolver: self.resolver.clone(),
|
resolver: self.resolver.clone(),
|
||||||
log_sink: self.log_sink.clone(),
|
log_sink: self.log_sink.clone(),
|
||||||
|
app_domains: self.app_domains.clone(),
|
||||||
routes: self.routes.clone(),
|
routes: self.routes.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +114,7 @@ where
|
|||||||
// audit-visible platform — but a sink failure must not mask the
|
// audit-visible platform — but a sink failure must not mask the
|
||||||
// user-facing result, so we only log a warning if it fails.
|
// user-facing result, so we only log a warning if it fails.
|
||||||
let log = build_execution_log(
|
let log = build_execution_log(
|
||||||
|
script.app_id,
|
||||||
id,
|
id,
|
||||||
request_id,
|
request_id,
|
||||||
request_path,
|
request_path,
|
||||||
@@ -145,7 +151,23 @@ where
|
|||||||
.to_string();
|
.to_string();
|
||||||
let headers = request.headers().clone();
|
let headers = request.headers().clone();
|
||||||
|
|
||||||
let Some(matched) = state.routes.match_request(&host, &method, &path) else {
|
// Two-phase dispatch (blueprint §11.5): first resolve Host → app_id,
|
||||||
|
// then run the existing matcher on that app's slice. No app claims
|
||||||
|
// this host → flat 404; the path doesn't get the chance to fire.
|
||||||
|
let Some(app_id) = state.app_domains.resolve_app(&host) else {
|
||||||
|
return Ok((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": format!("no app claims host {host:?}")
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(matched) = state
|
||||||
|
.routes
|
||||||
|
.match_request_for_app(app_id, &host, &method, &path)
|
||||||
|
else {
|
||||||
return Ok((
|
return Ok((
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
@@ -191,6 +213,7 @@ where
|
|||||||
let finished = Utc::now();
|
let finished = Utc::now();
|
||||||
|
|
||||||
let log = build_execution_log(
|
let log = build_execution_log(
|
||||||
|
script.app_id,
|
||||||
matched.matched.script_id,
|
matched.matched.script_id,
|
||||||
request_id,
|
request_id,
|
||||||
request_path,
|
request_path,
|
||||||
@@ -292,6 +315,7 @@ fn exec_response_to_http(resp: ExecResponse) -> Response {
|
|||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn build_execution_log(
|
fn build_execution_log(
|
||||||
|
app_id: AppId,
|
||||||
script_id: ScriptId,
|
script_id: ScriptId,
|
||||||
request_id: RequestId,
|
request_id: RequestId,
|
||||||
request_path: String,
|
request_path: String,
|
||||||
@@ -336,6 +360,7 @@ fn build_execution_log(
|
|||||||
|
|
||||||
ExecutionLog {
|
ExecutionLog {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
|
app_id,
|
||||||
script_id,
|
script_id,
|
||||||
request_id,
|
request_id,
|
||||||
request_path,
|
request_path,
|
||||||
|
|||||||
165
crates/orchestrator-core/src/routing/app_domains.rs
Normal file
165
crates/orchestrator-core/src/routing/app_domains.rs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
//! Host → app_id resolver. The first phase of the orchestrator's
|
||||||
|
//! two-phase dispatch (the second phase is the per-app route matcher
|
||||||
|
//! in `routing::table::RouteTable`).
|
||||||
|
//!
|
||||||
|
//! Cached in memory; the manager rebuilds the table after each
|
||||||
|
//! domain-claim CRUD operation (same pattern as `RouteTable`).
|
||||||
|
|
||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
use picloud_shared::AppId;
|
||||||
|
|
||||||
|
use super::pattern::{HostPattern, HostSpecificity};
|
||||||
|
|
||||||
|
/// A parsed domain claim ready for runtime matching.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CompiledAppDomain {
|
||||||
|
pub app_id: AppId,
|
||||||
|
pub pattern: HostPattern,
|
||||||
|
pub shape_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct AppDomainTable {
|
||||||
|
inner: RwLock<Vec<CompiledAppDomain>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppDomainTable {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic full replacement; called at startup and after every
|
||||||
|
/// domain CRUD operation.
|
||||||
|
pub fn replace(&self, domains: Vec<CompiledAppDomain>) {
|
||||||
|
let mut guard = self.inner.write().expect("app domain table poisoned");
|
||||||
|
*guard = domains;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a request's `Host` header to an `AppId`. Most-specific
|
||||||
|
/// claim wins: exact > longest wildcard > shorter wildcard. Returns
|
||||||
|
/// `None` when no claim covers `host` (orchestrator should 404).
|
||||||
|
#[must_use]
|
||||||
|
pub fn resolve_app(&self, host: &str) -> Option<AppId> {
|
||||||
|
let host = strip_port(host).to_ascii_lowercase();
|
||||||
|
let guard = self.inner.read().expect("app domain table poisoned");
|
||||||
|
let mut best: Option<(HostSpecificity, AppId)> = None;
|
||||||
|
for claim in guard.iter() {
|
||||||
|
if let Some(()) = host_matches(&claim.pattern, &host) {
|
||||||
|
let s = claim.pattern.specificity();
|
||||||
|
if best.is_none_or(|(prev, _)| s > prev) {
|
||||||
|
best = Some((s, claim.app_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(_, app_id)| app_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn snapshot(&self) -> Vec<CompiledAppDomain> {
|
||||||
|
self.inner
|
||||||
|
.read()
|
||||||
|
.expect("app domain table poisoned")
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_port(host: &str) -> &str {
|
||||||
|
host.split(':').next().unwrap_or(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_matches(pattern: &HostPattern, host: &str) -> Option<()> {
|
||||||
|
match pattern {
|
||||||
|
HostPattern::Any => Some(()),
|
||||||
|
HostPattern::Strict(s) => {
|
||||||
|
if s.eq_ignore_ascii_case(host) {
|
||||||
|
Some(())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HostPattern::Wildcard { suffix, .. } => {
|
||||||
|
let dotted = format!(".{}", suffix.to_ascii_lowercase());
|
||||||
|
host.strip_suffix(&dotted)
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::routing::pattern::parse_app_domain;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn id() -> AppId {
|
||||||
|
AppId::from(Uuid::new_v4())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile(app_id: AppId, raw: &str) -> CompiledAppDomain {
|
||||||
|
let d = parse_app_domain(raw).unwrap();
|
||||||
|
CompiledAppDomain {
|
||||||
|
app_id,
|
||||||
|
pattern: d.pattern,
|
||||||
|
shape_key: d.shape_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_exact_over_wildcard() {
|
||||||
|
let app_a = id();
|
||||||
|
let app_b = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![
|
||||||
|
compile(app_a, "foo.example.com"),
|
||||||
|
compile(app_b, "*.example.com"),
|
||||||
|
]);
|
||||||
|
assert_eq!(table.resolve_app("foo.example.com"), Some(app_a));
|
||||||
|
assert_eq!(table.resolve_app("bar.example.com"), Some(app_b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn longer_wildcard_beats_shorter() {
|
||||||
|
let inner = id();
|
||||||
|
let outer = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![
|
||||||
|
compile(inner, "*.api.example.com"),
|
||||||
|
compile(outer, "*.example.com"),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
table.resolve_app("v1.api.example.com"),
|
||||||
|
Some(inner),
|
||||||
|
"more-specific wildcard should win"
|
||||||
|
);
|
||||||
|
assert_eq!(table.resolve_app("v1.example.com"), Some(outer));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parameterized_resolves_like_wildcard() {
|
||||||
|
let app = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![compile(app, "{tenant}.example.com")]);
|
||||||
|
assert_eq!(table.resolve_app("acme.example.com"), Some(app));
|
||||||
|
assert!(table.resolve_app("example.com").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_none_when_no_claim() {
|
||||||
|
let app = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![compile(app, "foo.example.com")]);
|
||||||
|
assert!(table.resolve_app("nope.com").is_none());
|
||||||
|
assert!(table.resolve_app("").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_port() {
|
||||||
|
let app = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![compile(app, "localhost")]);
|
||||||
|
assert_eq!(table.resolve_app("localhost:18080"), Some(app));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,10 +40,13 @@ pub struct Matched {
|
|||||||
pub script_id: picloud_shared::ScriptId,
|
pub script_id: picloud_shared::ScriptId,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single route ready for matching.
|
/// A single route ready for matching. `app_id` is carried so the
|
||||||
|
/// caller (the orchestrator's `AppRouteTables`) can partition the
|
||||||
|
/// table; the matcher itself doesn't read it.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CompiledRoute {
|
pub struct CompiledRoute {
|
||||||
pub route_id: uuid::Uuid,
|
pub route_id: uuid::Uuid,
|
||||||
|
pub app_id: picloud_shared::AppId,
|
||||||
pub script_id: picloud_shared::ScriptId,
|
pub script_id: picloud_shared::ScriptId,
|
||||||
pub host: HostPattern,
|
pub host: HostPattern,
|
||||||
pub path: PathPattern,
|
pub path: PathPattern,
|
||||||
@@ -298,12 +301,13 @@ fn match_param(segs: &[PathSegment], request_path: &str) -> Option<BTreeMap<Stri
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::super::pattern::parse_path;
|
use super::super::pattern::parse_path;
|
||||||
use super::*;
|
use super::*;
|
||||||
use picloud_shared::{PathKind, ScriptId};
|
use picloud_shared::{AppId, PathKind, ScriptId};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn route(host: HostPattern, path_kind: PathKind, raw: &str) -> CompiledRoute {
|
fn route(host: HostPattern, path_kind: PathKind, raw: &str) -> CompiledRoute {
|
||||||
CompiledRoute {
|
CompiledRoute {
|
||||||
route_id: Uuid::new_v4(),
|
route_id: Uuid::new_v4(),
|
||||||
|
app_id: AppId::new(),
|
||||||
script_id: ScriptId::new(),
|
script_id: ScriptId::new(),
|
||||||
host,
|
host,
|
||||||
path: parse_path(path_kind, raw).unwrap(),
|
path: parse_path(path_kind, raw).unwrap(),
|
||||||
|
|||||||
@@ -17,12 +17,16 @@
|
|||||||
//! * **Host dispatch** — `strict > wildcard > any`; longest matching
|
//! * **Host dispatch** — `strict > wildcard > any`; longest matching
|
||||||
//! wildcard suffix breaks ties between wildcards.
|
//! wildcard suffix breaks ties between wildcards.
|
||||||
|
|
||||||
|
pub mod app_domains;
|
||||||
pub mod conflict;
|
pub mod conflict;
|
||||||
pub mod matcher;
|
pub mod matcher;
|
||||||
pub mod pattern;
|
pub mod pattern;
|
||||||
pub mod table;
|
pub mod table;
|
||||||
|
|
||||||
|
pub use app_domains::{AppDomainTable, CompiledAppDomain};
|
||||||
pub use conflict::{conflicts, ConflictReason};
|
pub use conflict::{conflicts, ConflictReason};
|
||||||
pub use matcher::{MatchResult, Matched};
|
pub use matcher::{MatchResult, Matched};
|
||||||
pub use pattern::{HostPattern, ParseError, PathPattern, PathSegment};
|
pub use pattern::{
|
||||||
|
parse_app_domain, HostPattern, ParseError, ParsedAppDomain, PathPattern, PathSegment,
|
||||||
|
};
|
||||||
pub use table::RouteTable;
|
pub use table::RouteTable;
|
||||||
|
|||||||
@@ -251,6 +251,106 @@ pub fn parse_host(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// App-domain patterns
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
use picloud_shared::DomainShape;
|
||||||
|
|
||||||
|
/// Result of parsing a user-supplied app domain claim. Carries the
|
||||||
|
/// host pattern (used at request time), the shape (used at write time
|
||||||
|
/// for collision checks), and the normalized shape_key.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ParsedAppDomain {
|
||||||
|
pub pattern: HostPattern,
|
||||||
|
pub shape: DomainShape,
|
||||||
|
/// Collision key: `"exact:<host>"` for exact; `"wildcard:<suffix>"`
|
||||||
|
/// for both wildcard AND parameterized — they share a shape per
|
||||||
|
/// blueprint §11.5 ("`{tenant}` has the same shape as `*` for this
|
||||||
|
/// check").
|
||||||
|
pub shape_key: String,
|
||||||
|
/// Captured binding name for parameterized claims, e.g., `Some("tenant")`
|
||||||
|
/// for `{tenant}.example.com`. Currently informational; the binding
|
||||||
|
/// is surfaced into request context in a future iteration.
|
||||||
|
pub binding: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a user-supplied app domain claim. Accepts:
|
||||||
|
/// * `app.example.com` — exact host
|
||||||
|
/// * `*.example.com` — wildcard suffix
|
||||||
|
/// * `{tenant}.example.com` — parameterized; same shape as wildcard
|
||||||
|
///
|
||||||
|
/// Distinct from `parse_host` (which is for route host fields): the
|
||||||
|
/// route parser still rejects `{...}` syntax — see
|
||||||
|
/// `ParseError::ReservedHostBraceSyntax`.
|
||||||
|
pub fn parse_app_domain(raw: &str) -> Result<ParsedAppDomain, ParseError> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(ParseError::EmptyHost);
|
||||||
|
}
|
||||||
|
let lowered = trimmed.to_ascii_lowercase();
|
||||||
|
|
||||||
|
// Wildcard: starts with "*."
|
||||||
|
if let Some(suffix) = lowered.strip_prefix("*.") {
|
||||||
|
if suffix.is_empty() {
|
||||||
|
return Err(ParseError::EmptyWildcardSuffix);
|
||||||
|
}
|
||||||
|
return Ok(ParsedAppDomain {
|
||||||
|
pattern: HostPattern::Wildcard {
|
||||||
|
suffix: suffix.to_string(),
|
||||||
|
capture: None,
|
||||||
|
},
|
||||||
|
shape: DomainShape::Wildcard,
|
||||||
|
shape_key: format!("wildcard:{suffix}"),
|
||||||
|
binding: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameterized: starts with "{name}." where `name` is an ident.
|
||||||
|
if let Some(stripped) = lowered.strip_prefix('{') {
|
||||||
|
let (binding, rest) = stripped
|
||||||
|
.split_once('}')
|
||||||
|
.ok_or(ParseError::ReservedHostBraceSyntax)?;
|
||||||
|
if binding.is_empty()
|
||||||
|
|| !binding
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||||
|
|| !binding.chars().next().unwrap().is_ascii_alphabetic()
|
||||||
|
{
|
||||||
|
return Err(ParseError::InvalidParamName(binding.to_string()));
|
||||||
|
}
|
||||||
|
let suffix = rest
|
||||||
|
.strip_prefix('.')
|
||||||
|
.ok_or(ParseError::ReservedHostBraceSyntax)?;
|
||||||
|
if suffix.is_empty() || suffix.contains('{') || suffix.contains('}') {
|
||||||
|
return Err(ParseError::ReservedHostBraceSyntax);
|
||||||
|
}
|
||||||
|
return Ok(ParsedAppDomain {
|
||||||
|
pattern: HostPattern::Wildcard {
|
||||||
|
suffix: suffix.to_string(),
|
||||||
|
capture: Some(binding.to_string()),
|
||||||
|
},
|
||||||
|
shape: DomainShape::Parameterized,
|
||||||
|
// Same shape_key as the equivalent wildcard — parameter
|
||||||
|
// name is a binding, not a discriminator.
|
||||||
|
shape_key: format!("wildcard:{suffix}"),
|
||||||
|
binding: Some(binding.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything else: exact host. Reject braces anywhere in the body
|
||||||
|
// (they'd be a malformed parameterized form).
|
||||||
|
if lowered.contains('{') || lowered.contains('}') {
|
||||||
|
return Err(ParseError::ReservedHostBraceSyntax);
|
||||||
|
}
|
||||||
|
Ok(ParsedAppDomain {
|
||||||
|
pattern: HostPattern::Strict(lowered.clone()),
|
||||||
|
shape: DomainShape::Exact,
|
||||||
|
shape_key: format!("exact:{lowered}"),
|
||||||
|
binding: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -393,6 +493,49 @@ mod tests {
|
|||||||
assert_eq!(e, ParseError::ReservedHostBraceSyntax);
|
assert_eq!(e, ParseError::ReservedHostBraceSyntax);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_app_domain_exact() {
|
||||||
|
let d = parse_app_domain("App.Example.COM").unwrap();
|
||||||
|
assert_eq!(d.shape, DomainShape::Exact);
|
||||||
|
assert_eq!(d.shape_key, "exact:app.example.com");
|
||||||
|
assert_eq!(d.pattern, HostPattern::Strict("app.example.com".into()));
|
||||||
|
assert!(d.binding.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_app_domain_wildcard_and_parameterized_share_shape_key() {
|
||||||
|
let w = parse_app_domain("*.example.com").unwrap();
|
||||||
|
let p = parse_app_domain("{tenant}.example.com").unwrap();
|
||||||
|
assert_eq!(w.shape, DomainShape::Wildcard);
|
||||||
|
assert_eq!(p.shape, DomainShape::Parameterized);
|
||||||
|
// Same shape_key — they collide at claim time (blueprint §11.5).
|
||||||
|
assert_eq!(w.shape_key, "wildcard:example.com");
|
||||||
|
assert_eq!(p.shape_key, "wildcard:example.com");
|
||||||
|
assert_eq!(p.binding.as_deref(), Some("tenant"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_app_domain_rejects_garbage() {
|
||||||
|
assert!(matches!(parse_app_domain(""), Err(ParseError::EmptyHost)));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_app_domain("*."),
|
||||||
|
Err(ParseError::EmptyWildcardSuffix)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_app_domain("{}.example.com"),
|
||||||
|
Err(ParseError::InvalidParamName(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_app_domain("{1tenant}.example.com"),
|
||||||
|
Err(ParseError::InvalidParamName(_))
|
||||||
|
));
|
||||||
|
// Mid-host braces — disallowed.
|
||||||
|
assert!(matches!(
|
||||||
|
parse_app_domain("foo.{tenant}.example.com"),
|
||||||
|
Err(ParseError::ReservedHostBraceSyntax)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn leading_literal_count_works() {
|
fn leading_literal_count_works() {
|
||||||
let exact = parse_path(PathKind::Exact, "/foo/users").unwrap();
|
let exact = parse_path(PathKind::Exact, "/foo/users").unwrap();
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
//! In-memory snapshot of compiled routes, shared by manager (writes)
|
//! In-memory snapshot of compiled routes, partitioned by `app_id`.
|
||||||
//! and orchestrator (reads).
|
|
||||||
//!
|
//!
|
||||||
//! Holds an `arc-swap`-style lock-free hand-off so the dispatcher can
|
//! The orchestrator looks up the app's slice by id after `AppDomainTable`
|
||||||
//! read without contending against the writer; in MVP-single-process
|
//! has resolved Host → app_id, then runs the existing matcher on that
|
||||||
//! we just use `RwLock` and accept the cheap contention.
|
//! slice. The matcher is unchanged; this type is just a per-app bucket.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
use picloud_shared::AppId;
|
||||||
|
|
||||||
use super::matcher::{r#match, CompiledRoute, MatchResult};
|
use super::matcher::{r#match, CompiledRoute, MatchResult};
|
||||||
|
|
||||||
|
/// Per-app compiled-route tables. Single MVP-mode writer (the manager,
|
||||||
|
/// via `replace_all`); contention against readers is minimal so a plain
|
||||||
|
/// `RwLock` is fine.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct RouteTable {
|
pub struct RouteTable {
|
||||||
inner: RwLock<Vec<CompiledRoute>>,
|
inner: RwLock<HashMap<AppId, Vec<CompiledRoute>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RouteTable {
|
impl RouteTable {
|
||||||
@@ -20,24 +25,54 @@ impl RouteTable {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the whole table atomically. The manager calls this after
|
/// Replace every per-app slice atomically. The manager calls this
|
||||||
/// each successful route CRUD operation (by re-reading from DB).
|
/// after each successful route CRUD operation; in cluster mode the
|
||||||
pub fn replace(&self, routes: Vec<CompiledRoute>) {
|
/// orchestrator's HTTP-fed receiver will too.
|
||||||
|
pub fn replace_all(&self, routes: Vec<CompiledRoute>) {
|
||||||
|
let mut by_app: HashMap<AppId, Vec<CompiledRoute>> = HashMap::new();
|
||||||
|
for r in routes {
|
||||||
|
by_app.entry(r.app_id).or_default().push(r);
|
||||||
|
}
|
||||||
let mut guard = self.inner.write().expect("route table poisoned");
|
let mut guard = self.inner.write().expect("route table poisoned");
|
||||||
*guard = routes;
|
*guard = by_app;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dispatch a request to a matching route, or `None`.
|
/// Dispatch a request to a matching route within `app_id`, or
|
||||||
|
/// `None`. Returns `None` when the app has no routes at all.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn match_request(&self, host: &str, method: &str, path: &str) -> Option<MatchResult> {
|
pub fn match_request_for_app(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
host: &str,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
) -> Option<MatchResult> {
|
||||||
let guard = self.inner.read().expect("route table poisoned");
|
let guard = self.inner.read().expect("route table poisoned");
|
||||||
r#match(guard.iter(), host, method, path)
|
let slice = guard.get(&app_id)?;
|
||||||
|
r#match(slice.iter(), host, method, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a clone of the currently compiled routes; intended for
|
/// Returns a clone of the currently compiled routes for `app_id`;
|
||||||
/// the dashboard's "list routes" admin endpoint.
|
/// intended for admin endpoints like "list this app's routes".
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn snapshot(&self) -> Vec<CompiledRoute> {
|
pub fn snapshot_for_app(&self, app_id: AppId) -> Vec<CompiledRoute> {
|
||||||
self.inner.read().expect("route table poisoned").clone()
|
self.inner
|
||||||
|
.read()
|
||||||
|
.expect("route table poisoned")
|
||||||
|
.get(&app_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All compiled routes across all apps. Used by tests and the
|
||||||
|
/// global admin "every route on this install" view.
|
||||||
|
#[must_use]
|
||||||
|
pub fn snapshot_all(&self) -> Vec<CompiledRoute> {
|
||||||
|
self.inner
|
||||||
|
.read()
|
||||||
|
.expect("route table poisoned")
|
||||||
|
.values()
|
||||||
|
.flat_map(|v| v.iter().cloned())
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,19 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::middleware::from_fn_with_state;
|
||||||
use axum::{routing::get, Json, Router};
|
use axum::{routing::get, Json, Router};
|
||||||
use picloud_executor_core::{Engine, Limits};
|
use picloud_executor_core::{Engine, Limits};
|
||||||
use picloud_manager_core::{
|
use picloud_manager_core::{
|
||||||
admin_router, compile_routes, migrations, route_admin_router, AdminState,
|
admin_router, admins_router, apps_api, apps_router, auth_router, compile_routes, migrations,
|
||||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
require_admin, route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository,
|
||||||
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
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::{
|
use picloud_orchestrator_core::{
|
||||||
data_plane_router, user_routes_router, DataPlaneState, LocalExecutorClient,
|
data_plane_router, user_routes_router, DataPlaneState, LocalExecutorClient,
|
||||||
};
|
};
|
||||||
@@ -24,6 +29,38 @@ use sqlx::postgres::PgPoolOptions;
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
|
/// Default session TTL when `PICLOUD_SESSION_TTL_HOURS` isn't set.
|
||||||
|
const DEFAULT_SESSION_TTL_HOURS: u64 = 24;
|
||||||
|
|
||||||
|
/// Bundles the auth-related dependencies that both `build_app` and the
|
||||||
|
/// startup bootstrap need. Built once in `main.rs` from the shared pool.
|
||||||
|
pub struct AuthDeps {
|
||||||
|
pub users: Arc<dyn AdminUserRepository>,
|
||||||
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||||
|
pub ttl: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthDeps {
|
||||||
|
/// Construct from a pool with the binary's standard defaults.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_pool(pool: PgPool) -> Self {
|
||||||
|
Self {
|
||||||
|
users: Arc::new(PostgresAdminUserRepository::new(pool.clone())),
|
||||||
|
sessions: Arc::new(PostgresAdminSessionRepository::new(pool)),
|
||||||
|
ttl: read_session_ttl(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_session_ttl() -> Duration {
|
||||||
|
let hours = std::env::var("PICLOUD_SESSION_TTL_HOURS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
|
.filter(|h| *h > 0)
|
||||||
|
.unwrap_or(DEFAULT_SESSION_TTL_HOURS);
|
||||||
|
Duration::from_secs(hours * 3600)
|
||||||
|
}
|
||||||
|
|
||||||
/// Compose the manager + orchestrator routes on top of a shared
|
/// Compose the manager + orchestrator routes on top of a shared
|
||||||
/// Postgres pool, returning an Axum router ready to be served.
|
/// Postgres pool, returning an Axum router ready to be served.
|
||||||
///
|
///
|
||||||
@@ -31,20 +68,48 @@ use tower_http::trace::TraceLayer;
|
|||||||
/// is mounted by Caddy at `/admin/*` (its base path). Anything else
|
/// is mounted by Caddy at `/admin/*` (its base path). Anything else
|
||||||
/// falls through to the user-route table — user scripts can bind to
|
/// falls through to the user-route table — user scripts can bind to
|
||||||
/// arbitrary paths (subject to the reserved-prefix list).
|
/// arbitrary paths (subject to the reserved-prefix list).
|
||||||
pub async fn build_app(pool: PgPool) -> anyhow::Result<Router> {
|
///
|
||||||
|
/// `auth` carries the admin user/session repositories and the
|
||||||
|
/// configured session TTL. The manager-side admin endpoints
|
||||||
|
/// (`/api/v1/admin/scripts/*`, `/api/v1/admin/routes/*`,
|
||||||
|
/// `/api/v1/admin/admins/*`, `/api/v1/admin/auth/me`) are guarded by
|
||||||
|
/// the `require_admin` middleware. The data plane
|
||||||
|
/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`,
|
||||||
|
/// `/version`) stays open — it's the public ingress for user scripts.
|
||||||
|
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||||
let engine = Arc::new(Engine::new(Limits::default()));
|
let engine = Arc::new(Engine::new(Limits::default()));
|
||||||
|
|
||||||
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
||||||
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
|
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
|
||||||
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
|
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
|
||||||
let route_repo = Arc::new(PostgresRouteRepository::new(pool));
|
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
||||||
|
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
||||||
|
let domains_repo: Arc<dyn AppDomainRepository> =
|
||||||
|
Arc::new(PostgresAppDomainRepository::new(pool));
|
||||||
|
|
||||||
// Compile the routes table once at startup; admin writes refresh it.
|
// Compile the routes table once at startup; admin writes refresh it.
|
||||||
let route_table = Arc::new(RouteTable::new());
|
let route_table = Arc::new(RouteTable::new());
|
||||||
let initial = route_repo.list_all().await?;
|
let initial = route_repo.list_all().await?;
|
||||||
let compiled = compile_routes(&initial)
|
let compiled = compile_routes(&initial)
|
||||||
.map_err(|e| anyhow::anyhow!("failed to compile stored routes: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("failed to compile stored routes: {e}"))?;
|
||||||
route_table.replace(compiled);
|
route_table.replace_all(compiled);
|
||||||
|
|
||||||
|
// Same shape for app domains (Host → app_id cache).
|
||||||
|
let app_domain_table = Arc::new(AppDomainTable::new());
|
||||||
|
let initial_domains = domains_repo.list_all().await?;
|
||||||
|
let compiled_domains: Vec<_> = initial_domains
|
||||||
|
.iter()
|
||||||
|
.filter_map(|d| {
|
||||||
|
picloud_orchestrator_core::routing::parse_app_domain(&d.pattern)
|
||||||
|
.ok()
|
||||||
|
.map(|p| picloud_orchestrator_core::routing::CompiledAppDomain {
|
||||||
|
app_id: d.app_id,
|
||||||
|
pattern: p.pattern,
|
||||||
|
shape_key: p.shape_key,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
app_domain_table.replace(compiled_domains);
|
||||||
|
|
||||||
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
|
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
|
||||||
script_repo.clone(),
|
script_repo.clone(),
|
||||||
@@ -52,25 +117,60 @@ pub async fn build_app(pool: PgPool) -> anyhow::Result<Router> {
|
|||||||
let executor = Arc::new(LocalExecutorClient::new(engine.clone()));
|
let executor = Arc::new(LocalExecutorClient::new(engine.clone()));
|
||||||
|
|
||||||
let admin = AdminState {
|
let admin = AdminState {
|
||||||
repo: Arc::new(PostgresScriptRepoHandle(script_repo)),
|
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
|
||||||
logs: log_repo,
|
logs: log_repo,
|
||||||
|
apps: apps_repo.clone(),
|
||||||
validator: engine as Arc<dyn ScriptValidator>,
|
validator: engine as Arc<dyn ScriptValidator>,
|
||||||
sandbox_ceiling: SandboxCeiling::from_env(),
|
sandbox_ceiling: SandboxCeiling::from_env(),
|
||||||
};
|
};
|
||||||
let route_admin = RouteAdminState {
|
let route_admin = RouteAdminState {
|
||||||
routes: route_repo,
|
routes: route_repo.clone(),
|
||||||
|
scripts: Arc::new(PostgresScriptRepoHandle(script_repo)),
|
||||||
|
domains: domains_repo.clone(),
|
||||||
table: route_table.clone(),
|
table: route_table.clone(),
|
||||||
};
|
};
|
||||||
let data_plane = DataPlaneState {
|
let data_plane = DataPlaneState {
|
||||||
executor,
|
executor,
|
||||||
resolver,
|
resolver,
|
||||||
log_sink,
|
log_sink,
|
||||||
|
app_domains: app_domain_table.clone(),
|
||||||
routes: route_table,
|
routes: route_table,
|
||||||
};
|
};
|
||||||
|
let apps_state = AppsState {
|
||||||
|
apps: apps_repo,
|
||||||
|
domains: domains_repo,
|
||||||
|
routes: route_repo,
|
||||||
|
domain_table: app_domain_table,
|
||||||
|
};
|
||||||
|
|
||||||
|
let auth_state = AuthState {
|
||||||
|
users: auth.users.clone(),
|
||||||
|
sessions: auth.sessions.clone(),
|
||||||
|
ttl: auth.ttl,
|
||||||
|
};
|
||||||
|
let admins_state = AdminsState {
|
||||||
|
users: auth.users,
|
||||||
|
sessions: auth.sessions,
|
||||||
|
};
|
||||||
|
|
||||||
|
// /admin/auth/login + /logout are unguarded by design (login is how
|
||||||
|
// you get in). /admin/auth/me applies the middleware internally so
|
||||||
|
// the same Router::with_state machinery composes cleanly. Everything
|
||||||
|
// else under /admin gets the require_admin layer.
|
||||||
|
let guarded_admin = Router::new()
|
||||||
|
.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()
|
let api_v1 = Router::new()
|
||||||
.nest("/admin", admin_router(admin))
|
.nest("/admin", auth_router(auth_state))
|
||||||
.nest("/admin", route_admin_router(route_admin))
|
.nest("/admin", guarded_admin)
|
||||||
.merge(data_plane_router(data_plane.clone()));
|
.merge(data_plane_router(data_plane.clone()));
|
||||||
|
|
||||||
Ok(Router::new()
|
Ok(Router::new()
|
||||||
@@ -138,6 +238,12 @@ impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
|
|||||||
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||||
self.0.list().await
|
self.0.list().await
|
||||||
}
|
}
|
||||||
|
async fn list_for_app(
|
||||||
|
&self,
|
||||||
|
app_id: picloud_shared::AppId,
|
||||||
|
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||||
|
self.0.list_for_app(app_id).await
|
||||||
|
}
|
||||||
async fn create(
|
async fn create(
|
||||||
&self,
|
&self,
|
||||||
input: picloud_manager_core::NewScript,
|
input: picloud_manager_core::NewScript,
|
||||||
|
|||||||
@@ -1,17 +1,38 @@
|
|||||||
//! PiCloud all-in-one binary — see `lib.rs` for the actual app
|
//! PiCloud all-in-one binary — see `lib.rs` for the actual app
|
||||||
//! composition; this file is only the runtime shell (env config,
|
//! composition; this file is only the runtime shell (env config,
|
||||||
//! logger, migrations, listener).
|
//! logger, migrations, listener) plus the small `admin` CLI subcommand
|
||||||
|
//! used for out-of-band password recovery.
|
||||||
|
|
||||||
|
use std::io::{BufRead, Write};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use picloud::{build_app, init_db};
|
use picloud::{build_app, init_db, AuthDeps};
|
||||||
use picloud_manager_core::migrations;
|
use picloud_manager_core::{
|
||||||
|
auth::{hash_password, validate_password_hash},
|
||||||
|
bootstrap_first_admin, migrations, seed_hello_world_if_fresh, AdminSessionRepository,
|
||||||
|
AdminUserRepository, HelloWorldOutcome, PostgresAppRepository, PostgresRouteRepository,
|
||||||
|
PostgresScriptRepository,
|
||||||
|
};
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
init_tracing();
|
init_tracing();
|
||||||
|
|
||||||
|
// Subcommand dispatch — `picloud admin reset-password <username>`.
|
||||||
|
// Kept handwritten to avoid pulling clap in just for one verb. Falls
|
||||||
|
// through to the server when no subcommand is given.
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
if args.get(1).map(String::as_str) == Some("admin") {
|
||||||
|
return run_admin_cli(&args[2..]).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
run_server().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_server() -> anyhow::Result<()> {
|
||||||
let addr: SocketAddr = std::env::var("PICLOUD_BIND")
|
let addr: SocketAddr = std::env::var("PICLOUD_BIND")
|
||||||
.unwrap_or_else(|_| "0.0.0.0:8080".into())
|
.unwrap_or_else(|_| "0.0.0.0:8080".into())
|
||||||
.parse()?;
|
.parse()?;
|
||||||
@@ -22,7 +43,32 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
migrations::run(&pool).await?;
|
migrations::run(&pool).await?;
|
||||||
tracing::info!("migrations applied");
|
tracing::info!("migrations applied");
|
||||||
|
|
||||||
let app = build_app(pool).await?;
|
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.
|
||||||
|
spawn_session_pruner(auth.sessions.clone());
|
||||||
|
|
||||||
|
let app = build_app(pool, auth).await?;
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
tracing::info!(%addr, "picloud all-in-one listening");
|
tracing::info!(%addr, "picloud all-in-one listening");
|
||||||
@@ -33,6 +79,112 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn spawn_session_pruner(sessions: Arc<dyn AdminSessionRepository>) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut ticker = tokio::time::interval(Duration::from_secs(600));
|
||||||
|
// First tick fires immediately; skip it so we don't race startup.
|
||||||
|
ticker.tick().await;
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
match sessions.prune_expired().await {
|
||||||
|
Ok(n) if n > 0 => tracing::debug!(pruned = n, "expired admin sessions pruned"),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) => tracing::warn!(?err, "admin session prune failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// `admin` subcommand
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn run_admin_cli(args: &[String]) -> anyhow::Result<()> {
|
||||||
|
match args.first().map(String::as_str) {
|
||||||
|
Some("reset-password") => {
|
||||||
|
let username = args.get(1).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"usage: picloud admin reset-password <username> [--password-hash <hash>]"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
// Optional inline hash via --password-hash <hash>; otherwise
|
||||||
|
// read a raw password from stdin.
|
||||||
|
let hash_arg = parse_flag(&args[2..], "--password-hash");
|
||||||
|
cmd_reset_password(username, hash_arg).await
|
||||||
|
}
|
||||||
|
Some(other) => Err(anyhow::anyhow!("unknown admin subcommand: {other}")),
|
||||||
|
None => Err(anyhow::anyhow!(
|
||||||
|
"usage: picloud admin reset-password <username>"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_flag(args: &[String], name: &str) -> Option<String> {
|
||||||
|
let mut it = args.iter();
|
||||||
|
while let Some(a) = it.next() {
|
||||||
|
if a == name {
|
||||||
|
return it.next().cloned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cmd_reset_password(username: &str, password_hash: Option<String>) -> anyhow::Result<()> {
|
||||||
|
let database_url =
|
||||||
|
std::env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL is required"))?;
|
||||||
|
let pool = init_db(&database_url).await?;
|
||||||
|
migrations::run(&pool).await?;
|
||||||
|
|
||||||
|
let users = picloud_manager_core::PostgresAdminUserRepository::new(pool.clone());
|
||||||
|
let sessions = picloud_manager_core::PostgresAdminSessionRepository::new(pool);
|
||||||
|
|
||||||
|
let target = users
|
||||||
|
.get_by_username(username)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no admin user named {username:?}"))?;
|
||||||
|
|
||||||
|
let hash = if let Some(h) = password_hash {
|
||||||
|
validate_password_hash(&h)
|
||||||
|
.map_err(|_| anyhow::anyhow!("--password-hash is not a valid Argon2id PHC string"))?;
|
||||||
|
h
|
||||||
|
} else {
|
||||||
|
let raw = prompt_password_from_stdin()?;
|
||||||
|
hash_password(&raw).map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
users.update_password_hash(target.id, &hash).await?;
|
||||||
|
// Recovery implies the operator already lost control of the account;
|
||||||
|
// re-activate it (so a deactivated admin can also recover) and wipe
|
||||||
|
// any pre-existing sessions in case the original holder is still
|
||||||
|
// signed in elsewhere.
|
||||||
|
if !target.is_active {
|
||||||
|
users.set_active(target.id, true).await?;
|
||||||
|
}
|
||||||
|
let dropped = sessions.delete_for_user(target.id).await?;
|
||||||
|
|
||||||
|
println!("Password reset for {username}. Sessions dropped: {dropped}. Active: true.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_password_from_stdin() -> anyhow::Result<String> {
|
||||||
|
eprint!("New password (will be read from stdin, no echo): ");
|
||||||
|
std::io::stderr().flush().ok();
|
||||||
|
let mut line = String::new();
|
||||||
|
std::io::stdin()
|
||||||
|
.lock()
|
||||||
|
.read_line(&mut line)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to read stdin: {e}"))?;
|
||||||
|
let pw = line.trim_end_matches(['\n', '\r']).to_string();
|
||||||
|
if pw.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("password must not be empty"));
|
||||||
|
}
|
||||||
|
Ok(pw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Misc
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
fn init_tracing() {
|
fn init_tracing() {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||||
|
|||||||
@@ -17,9 +17,67 @@ use axum_test::TestServer;
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
/// Build the all-in-one app over the test pool, seed a single admin
|
||||||
|
/// directly through the repo (bypassing the env-var bootstrap path so
|
||||||
|
/// tests don't contaminate the process environment), log in, and bake
|
||||||
|
/// 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 {
|
async fn server(pool: PgPool) -> TestServer {
|
||||||
let app = picloud::build_app(pool).await.expect("build_app");
|
let (server, _app_id) = server_with_app(pool).await;
|
||||||
TestServer::new(app).expect("TestServer should build")
|
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());
|
||||||
|
let hash = hash_password("test-pw").expect("hash");
|
||||||
|
auth.users
|
||||||
|
.create("test-admin", &hash)
|
||||||
|
.await
|
||||||
|
.expect("seed admin");
|
||||||
|
|
||||||
|
let app = picloud::build_app(pool, auth).await.expect("build_app");
|
||||||
|
let mut server = TestServer::new(app).expect("TestServer should build");
|
||||||
|
|
||||||
|
let resp = server
|
||||||
|
.post("/api/v1/admin/auth/login")
|
||||||
|
.json(&json!({ "username": "test-admin", "password": "test-pw" }))
|
||||||
|
.await;
|
||||||
|
resp.assert_status_ok();
|
||||||
|
let token = resp.json::<Value>()["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("login should return token")
|
||||||
|
.to_string();
|
||||||
|
server.add_header("authorization", format!("Bearer {token}"));
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -41,30 +99,37 @@ async fn healthz_responds_ok(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn create_script_returns_201_with_full_record(pool: PgPool) {
|
async fn create_script_returns_201_with_full_record(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "echo",
|
"name": "echo",
|
||||||
"description": "test",
|
"description": "test",
|
||||||
"source": "#{ statusCode: 200, body: 42 }",
|
"source": "#{ statusCode: 200, body: 42 }",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status(axum::http::StatusCode::CREATED);
|
r.assert_status(axum::http::StatusCode::CREATED);
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["name"], "echo");
|
assert_eq!(body["name"], "echo");
|
||||||
assert_eq!(body["version"], 1);
|
assert_eq!(body["version"], 1);
|
||||||
assert_eq!(body["timeout_seconds"], 30);
|
assert_eq!(body["timeout_seconds"], 30);
|
||||||
|
assert_eq!(body["app_id"], app_id);
|
||||||
assert!(body["id"].as_str().is_some());
|
assert!(body["id"].as_str().is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
|
async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
|
||||||
let r = server(pool)
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
.await
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "broken", "source": "@@@ not rhai @@@" }))
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({ "name": "broken", "source": "@@@ not rhai @@@" }),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
@@ -74,14 +139,14 @@ async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn duplicate_name_returns_409(pool: PgPool) {
|
async fn duplicate_name_returns_409(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
s.post("/api/v1/admin/scripts")
|
s.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "dup", "source": "42" }))
|
.json(&with_app(&app_id, json!({ "name": "dup", "source": "42" })))
|
||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "dup", "source": "43" }))
|
.json(&with_app(&app_id, json!({ "name": "dup", "source": "43" })))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status(axum::http::StatusCode::CONFLICT);
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
}
|
}
|
||||||
@@ -89,10 +154,10 @@ async fn duplicate_name_returns_409(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn list_returns_all_scripts(pool: PgPool) {
|
async fn list_returns_all_scripts(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
for name in ["alpha", "bravo", "charlie"] {
|
for name in ["alpha", "bravo", "charlie"] {
|
||||||
s.post("/api/v1/admin/scripts")
|
s.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": name, "source": "1" }))
|
.json(&with_app(&app_id, json!({ "name": name, "source": "1" })))
|
||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
}
|
}
|
||||||
@@ -107,10 +172,10 @@ async fn list_returns_all_scripts(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn update_bumps_version_and_persists_changes(pool: PgPool) {
|
async fn update_bumps_version_and_persists_changes(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "u", "source": "1" }))
|
.json(&with_app(&app_id, json!({ "name": "u", "source": "1" })))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -129,10 +194,10 @@ async fn update_bumps_version_and_persists_changes(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn update_with_invalid_source_returns_422(pool: PgPool) {
|
async fn update_with_invalid_source_returns_422(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "u", "source": "1" }))
|
.json(&with_app(&app_id, json!({ "name": "u", "source": "1" })))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -147,10 +212,10 @@ async fn update_with_invalid_source_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn delete_then_get_returns_404(pool: PgPool) {
|
async fn delete_then_get_returns_404(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "d", "source": "1" }))
|
.json(&with_app(&app_id, json!({ "name": "d", "source": "1" })))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -181,13 +246,16 @@ async fn get_nonexistent_returns_404(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn execute_echoes_body_back(pool: PgPool) {
|
async fn execute_echoes_body_back(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "echo",
|
"name": "echo",
|
||||||
"source": "#{ statusCode: 200, body: ctx.request.body }",
|
"source": "#{ statusCode: 200, body: ctx.request.body }",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -204,13 +272,16 @@ async fn execute_echoes_body_back(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn execute_passes_through_status_and_headers(pool: PgPool) {
|
async fn execute_passes_through_status_and_headers(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "header-test",
|
"name": "header-test",
|
||||||
"source": "#{ statusCode: 201, headers: #{ \"x-tag\": \"on\" }, body: 1 }",
|
"source": "#{ statusCode: 201, headers: #{ \"x-tag\": \"on\" }, body: 1 }",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -237,13 +308,16 @@ async fn execute_nonexistent_returns_404(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn execution_logs_capture_invocations(pool: PgPool) {
|
async fn execution_logs_capture_invocations(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "logger",
|
"name": "logger",
|
||||||
"source": "log::info(\"called\", #{ marker: 7 }); #{ statusCode: 200, body: \"done\" }",
|
"source": "log::info(\"called\", #{ marker: 7 }); #{ statusCode: 200, body: \"done\" }",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -294,10 +368,13 @@ async fn execution_logs_capture_invocations(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
|
async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "no-sandbox", "source": "1" }))
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({ "name": "no-sandbox", "source": "1" }),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
assert_eq!(created["sandbox"], json!({}));
|
assert_eq!(created["sandbox"], json!({}));
|
||||||
@@ -306,14 +383,17 @@ async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
|
async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "tight",
|
"name": "tight",
|
||||||
"source": "1",
|
"source": "1",
|
||||||
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -333,14 +413,17 @@ async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
|
|||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
|
async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
|
||||||
// Default conservative ceiling caps max_operations at 10_000_000.
|
// Default conservative ceiling caps max_operations at 10_000_000.
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "too-loose",
|
"name": "too-loose",
|
||||||
"source": "1",
|
"source": "1",
|
||||||
"sandbox": { "max_operations": 100_000_000 }
|
"sandbox": { "max_operations": 100_000_000 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
@@ -350,14 +433,17 @@ async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn sandbox_unknown_field_returns_422(pool: PgPool) {
|
async fn sandbox_unknown_field_returns_422(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "typo",
|
"name": "typo",
|
||||||
"source": "1",
|
"source": "1",
|
||||||
"sandbox": { "max_operashuns": 500 }
|
"sandbox": { "max_operashuns": 500 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
// serde's deny_unknown_fields causes axum to reject with 422 or
|
// serde's deny_unknown_fields causes axum to reject with 422 or
|
||||||
// 400 depending on extractor; the routing is irrelevant here, just
|
// 400 depending on extractor; the routing is irrelevant here, just
|
||||||
@@ -371,15 +457,18 @@ async fn sandbox_unknown_field_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
|
async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
// Tight max_operations on a loop the default would happily run.
|
// Tight max_operations on a loop the default would happily run.
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "tight-exec",
|
"name": "tight-exec",
|
||||||
"source": "let n = 0; for i in 0..10000 { n += 1; } n",
|
"source": "let n = 0; for i in 0..10000 { n += 1; } n",
|
||||||
"sandbox": { "max_operations": 500 }
|
"sandbox": { "max_operations": 500 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -396,14 +485,17 @@ async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn update_replaces_sandbox_wholesale(pool: PgPool) {
|
async fn update_replaces_sandbox_wholesale(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "patch-target",
|
"name": "patch-target",
|
||||||
"source": "1",
|
"source": "1",
|
||||||
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -429,10 +521,10 @@ async fn update_replaces_sandbox_wholesale(pool: PgPool) {
|
|||||||
// Custom routing
|
// Custom routing
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
async fn create_basic_script(s: &TestServer, name: &str, source: &str) -> String {
|
async fn create_basic_script(s: &TestServer, app_id: &str, name: &str, source: &str) -> String {
|
||||||
let v: Value = s
|
let v: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": name, "source": source }))
|
.json(&with_app(app_id, json!({ "name": name, "source": source })))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
v["id"].as_str().unwrap().to_string()
|
v["id"].as_str().unwrap().to_string()
|
||||||
@@ -441,9 +533,10 @@ async fn create_basic_script(s: &TestServer, name: &str, source: &str) -> String
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_exact_dispatches_to_script(pool: PgPool) {
|
async fn route_exact_dispatches_to_script(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(
|
let id = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"greet",
|
"greet",
|
||||||
"#{ statusCode: 200, body: #{ msg: \"hi\", path: ctx.request.path } }",
|
"#{ statusCode: 200, body: #{ msg: \"hi\", path: ctx.request.path } }",
|
||||||
)
|
)
|
||||||
@@ -457,7 +550,7 @@ async fn route_exact_dispatches_to_script(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
let r = s.get("/greet").await;
|
let r = s.get("/greet").add_header("host", "localhost").await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["msg"], "hi");
|
assert_eq!(body["msg"], "hi");
|
||||||
@@ -467,9 +560,10 @@ async fn route_exact_dispatches_to_script(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_param_captures_path_vars(pool: PgPool) {
|
async fn route_param_captures_path_vars(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(
|
let id = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"greet-name",
|
"greet-name",
|
||||||
"#{ statusCode: 200, body: #{ name: ctx.request.params.name } }",
|
"#{ statusCode: 200, body: #{ name: ctx.request.params.name } }",
|
||||||
)
|
)
|
||||||
@@ -483,7 +577,7 @@ async fn route_param_captures_path_vars(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
let r = s.get("/greet/alice").await;
|
let r = s.get("/greet/alice").add_header("host", "localhost").await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["name"], "alice");
|
assert_eq!(body["name"], "alice");
|
||||||
@@ -492,9 +586,10 @@ async fn route_param_captures_path_vars(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_prefix_captures_rest(pool: PgPool) {
|
async fn route_prefix_captures_rest(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(
|
let id = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"echo-prefix",
|
"echo-prefix",
|
||||||
"#{ statusCode: 200, body: #{ rest: ctx.request.rest } }",
|
"#{ statusCode: 200, body: #{ rest: ctx.request.rest } }",
|
||||||
)
|
)
|
||||||
@@ -508,19 +603,28 @@ async fn route_prefix_captures_rest(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
let r = s.get("/echo/foo/bar").await;
|
let r = s.get("/echo/foo/bar").add_header("host", "localhost").await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["rest"], "foo/bar");
|
assert_eq!(body["rest"], "foo/bar");
|
||||||
|
|
||||||
s.get("/echo").await.assert_status_not_found();
|
s.get("/echo")
|
||||||
|
.add_header("host", "localhost")
|
||||||
|
.await
|
||||||
|
.assert_status_not_found();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_query_string_exposed_to_script(pool: PgPool) {
|
async fn route_query_string_exposed_to_script(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "qs", "#{ statusCode: 200, body: ctx.request.query }").await;
|
let id = create_basic_script(
|
||||||
|
&s,
|
||||||
|
&app_id,
|
||||||
|
"qs",
|
||||||
|
"#{ statusCode: 200, body: ctx.request.query }",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"host_kind": "any",
|
"host_kind": "any",
|
||||||
@@ -530,7 +634,7 @@ async fn route_query_string_exposed_to_script(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
let r = s.get("/qs?a=1&b=two").await;
|
let r = s.get("/qs?a=1&b=two").add_header("host", "localhost").await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body, json!({ "a": "1", "b": "two" }));
|
assert_eq!(body, json!({ "a": "1", "b": "two" }));
|
||||||
@@ -539,8 +643,8 @@ async fn route_query_string_exposed_to_script(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_invalid_pattern_returns_422(pool: PgPool) {
|
async fn route_invalid_pattern_returns_422(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "x", "1").await;
|
let id = create_basic_script(&s, &app_id, "x", "1").await;
|
||||||
let r = s
|
let r = s
|
||||||
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
@@ -555,8 +659,8 @@ async fn route_invalid_pattern_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_conflict_returns_409(pool: PgPool) {
|
async fn route_conflict_returns_409(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "x", "1").await;
|
let id = create_basic_script(&s, &app_id, "x", "1").await;
|
||||||
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"host_kind": "any",
|
"host_kind": "any",
|
||||||
@@ -582,8 +686,8 @@ async fn route_conflict_returns_409(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_reserved_path_returns_422(pool: PgPool) {
|
async fn route_reserved_path_returns_422(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "x", "1").await;
|
let id = create_basic_script(&s, &app_id, "x", "1").await;
|
||||||
let r = s
|
let r = s
|
||||||
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
@@ -598,8 +702,8 @@ async fn route_reserved_path_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_match_preview_endpoint(pool: PgPool) {
|
async fn route_match_preview_endpoint(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "g", "1").await;
|
let id = create_basic_script(&s, &app_id, "g", "1").await;
|
||||||
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"host_kind": "any",
|
"host_kind": "any",
|
||||||
@@ -611,7 +715,11 @@ async fn route_match_preview_endpoint(pool: PgPool) {
|
|||||||
|
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/routes:match")
|
.post("/api/v1/admin/routes:match")
|
||||||
.json(&json!({ "url": "http://localhost:8000/greet/alice", "method": "GET" }))
|
.json(&json!({
|
||||||
|
"app_id": app_id,
|
||||||
|
"url": "http://localhost:8000/greet/alice",
|
||||||
|
"method": "GET"
|
||||||
|
}))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
@@ -622,8 +730,8 @@ async fn route_match_preview_endpoint(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_delete_removes_dispatch(pool: PgPool) {
|
async fn route_delete_removes_dispatch(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "g", "#{ statusCode: 200, body: 1 }").await;
|
let id = create_basic_script(&s, &app_id, "g", "#{ statusCode: 200, body: 1 }").await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
@@ -635,27 +743,35 @@ async fn route_delete_removes_dispatch(pool: PgPool) {
|
|||||||
.json();
|
.json();
|
||||||
let route_id = created["id"].as_str().unwrap();
|
let route_id = created["id"].as_str().unwrap();
|
||||||
|
|
||||||
s.get("/g").await.assert_status_ok();
|
s.get("/g")
|
||||||
|
.add_header("host", "localhost")
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
s.delete(&format!("/api/v1/admin/routes/{route_id}"))
|
s.delete(&format!("/api/v1/admin/routes/{route_id}"))
|
||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::NO_CONTENT);
|
.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
s.get("/g").await.assert_status_not_found();
|
s.get("/g")
|
||||||
|
.add_header("host", "localhost")
|
||||||
|
.await
|
||||||
|
.assert_status_not_found();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id_p = create_basic_script(
|
let id_p = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"by-param",
|
"by-param",
|
||||||
"#{ statusCode: 200, body: #{ tag: \"param\" } }",
|
"#{ statusCode: 200, body: #{ tag: \"param\" } }",
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let id_pr = create_basic_script(
|
let id_pr = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"by-prefix",
|
"by-prefix",
|
||||||
"#{ statusCode: 200, body: #{ tag: \"prefix\" } }",
|
"#{ statusCode: 200, body: #{ tag: \"prefix\" } }",
|
||||||
)
|
)
|
||||||
@@ -678,12 +794,12 @@ async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
|||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
// Single segment under /foo/ — both match; param wins by spec.
|
// Single segment under /foo/ — both match; param wins by spec.
|
||||||
let r = s.get("/foo/x").await;
|
let r = s.get("/foo/x").add_header("host", "localhost").await;
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["tag"], "param");
|
assert_eq!(body["tag"], "param");
|
||||||
|
|
||||||
// Two segments — only prefix matches.
|
// Two segments — only prefix matches.
|
||||||
let r2 = s.get("/foo/x/y").await;
|
let r2 = s.get("/foo/x/y").add_header("host", "localhost").await;
|
||||||
let body2: Value = r2.json();
|
let body2: Value = r2.json();
|
||||||
assert_eq!(body2["tag"], "prefix");
|
assert_eq!(body2["tag"], "prefix");
|
||||||
}
|
}
|
||||||
@@ -692,7 +808,7 @@ async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
|||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn root_returns_404_when_no_route(pool: PgPool) {
|
async fn root_returns_404_when_no_route(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let s = server(pool).await;
|
||||||
let r = s.get("/").await;
|
let r = s.get("/").add_header("host", "localhost").await;
|
||||||
r.assert_status_not_found();
|
r.assert_status_not_found();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,22 +821,325 @@ async fn version_includes_public_base_url(pool: PgPool) {
|
|||||||
let v: Value = r.json();
|
let v: Value = r.json();
|
||||||
assert!(v["public_base_url"].is_string());
|
assert!(v["public_base_url"].is_string());
|
||||||
assert_eq!(v["api"], 1);
|
assert_eq!(v["api"], 1);
|
||||||
assert_eq!(v["schema"], 3);
|
assert_eq!(v["schema"], 5);
|
||||||
assert_eq!(v["sdk"], "1.1");
|
assert_eq!(v["sdk"], "1.1");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// App scoping (Phase 3b)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn default_app_is_seeded_by_migration(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let r = s.get("/api/v1/admin/apps").await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
let apps: Vec<Value> = r.json();
|
||||||
|
let default = apps
|
||||||
|
.iter()
|
||||||
|
.find(|a| a["slug"] == "default")
|
||||||
|
.expect("default app must exist");
|
||||||
|
assert_eq!(default["name"], "Default");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn cross_app_isolation_at_dispatch(pool: PgPool) {
|
||||||
|
let (s, default_id) = server_with_app(pool).await;
|
||||||
|
|
||||||
|
// Two apps each create a script with the same name (per-app
|
||||||
|
// uniqueness — would have collided pre-3b).
|
||||||
|
let app_b: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "tenant-b", "name": "Tenant B" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let b_id = app_b["id"].as_str().unwrap();
|
||||||
|
s.post(&format!("/api/v1/admin/apps/{b_id}/domains"))
|
||||||
|
.json(&json!({ "pattern": "b.localhost" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
let id_default: String = s
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
&default_id,
|
||||||
|
json!({
|
||||||
|
"name": "echo",
|
||||||
|
"source": "#{ statusCode: 200, body: #{ from: \"default\" } }"
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.json::<Value>()["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
let id_b: String = s
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
b_id,
|
||||||
|
json!({
|
||||||
|
"name": "echo",
|
||||||
|
"source": "#{ statusCode: 200, body: #{ from: \"b\" } }"
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.json::<Value>()["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
s.post(&format!("/api/v1/admin/scripts/{id_default}/routes"))
|
||||||
|
.json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/echo" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
s.post(&format!("/api/v1/admin/scripts/{id_b}/routes"))
|
||||||
|
.json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/echo" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
// Same path, different host — routes land in different apps.
|
||||||
|
let from_default: Value = s.get("/echo").add_header("host", "localhost").await.json();
|
||||||
|
assert_eq!(from_default["from"], "default");
|
||||||
|
|
||||||
|
let from_b: Value = s
|
||||||
|
.get("/echo")
|
||||||
|
.add_header("host", "b.localhost")
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
assert_eq!(from_b["from"], "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn unknown_host_returns_404(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let r = s.get("/whatever").add_header("host", "nope.invalid").await;
|
||||||
|
r.assert_status_not_found();
|
||||||
|
let body: Value = r.json();
|
||||||
|
assert!(body["error"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("no app claims host"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn execute_by_id_works_without_host_claim(pool: PgPool) {
|
||||||
|
// The /api/v1/execute/{id} bypass is the implicit __internal__
|
||||||
|
// claim of every app — it MUST keep working for an app with zero
|
||||||
|
// public domain claims.
|
||||||
|
let (s, _) = server_with_app(pool).await;
|
||||||
|
let app: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "internal-only", "name": "Internal Only" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let app_id = app["id"].as_str().unwrap();
|
||||||
|
let script: Value = s
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
app_id,
|
||||||
|
json!({ "name": "x", "source": "#{ statusCode: 200, body: \"ok\" }" }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let id = script["id"].as_str().unwrap();
|
||||||
|
let r = s
|
||||||
|
.post(&format!("/api/v1/execute/{id}"))
|
||||||
|
.json(&json!({}))
|
||||||
|
.await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn duplicate_slug_creates_a_409(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
s.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "alpha", "name": "First" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let r = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "alpha", "name": "Second" }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn reserved_slug_rejected(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
for bad in ["new", "api", "admin", "login"] {
|
||||||
|
let r = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": bad, "name": "x" }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn slug_rename_keeps_old_as_redirect(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let app: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "old-slug", "name": "x" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let id = app["id"].as_str().unwrap();
|
||||||
|
s.patch(&format!("/api/v1/admin/apps/{id}"))
|
||||||
|
.json(&json!({ "slug": "new-slug" }))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
let resp: Value = s.get("/api/v1/admin/apps/old-slug").await.json();
|
||||||
|
// The old slug resolves via history and surfaces `redirect_to`.
|
||||||
|
assert_eq!(resp["redirect_to"], "new-slug");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn claiming_historical_slug_needs_force_takeover(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
// Set up a history row.
|
||||||
|
let first: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "soon-retired", "name": "x" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
s.patch(&format!(
|
||||||
|
"/api/v1/admin/apps/{}",
|
||||||
|
first["id"].as_str().unwrap()
|
||||||
|
))
|
||||||
|
.json(&json!({ "slug": "kept" }))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
|
// Plain create against the retired slug → 409.
|
||||||
|
let r = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "soon-retired", "name": "y" }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
|
|
||||||
|
// With force_takeover → 201.
|
||||||
|
let r = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "soon-retired", "name": "y", "force_takeover": true }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn shape_key_collision_rejected(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let a: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "a", "name": "A" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let b: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "b", "name": "B" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
s.post(&format!(
|
||||||
|
"/api/v1/admin/apps/{}/domains",
|
||||||
|
a["id"].as_str().unwrap()
|
||||||
|
))
|
||||||
|
.json(&json!({ "pattern": "*.example.com" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
// Parameterized form should collide with wildcard form.
|
||||||
|
let r = s
|
||||||
|
.post(&format!(
|
||||||
|
"/api/v1/admin/apps/{}/domains",
|
||||||
|
b["id"].as_str().unwrap()
|
||||||
|
))
|
||||||
|
.json(&json!({ "pattern": "{tenant}.example.com" }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn delete_app_with_scripts_returns_409(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let app: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "with-scripts", "name": "x" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let id = app["id"].as_str().unwrap();
|
||||||
|
s.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(id, json!({ "name": "s", "source": "1" })))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let r = s.delete(&format!("/api/v1/admin/apps/{id}")).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn list_scripts_filtered_by_app(pool: PgPool) {
|
||||||
|
let (s, default_id) = server_with_app(pool).await;
|
||||||
|
let other: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "filter-target", "name": "x" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let other_id = other["id"].as_str().unwrap();
|
||||||
|
|
||||||
|
s.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
&default_id,
|
||||||
|
json!({ "name": "in-default", "source": "1" }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
s.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
other_id,
|
||||||
|
json!({ "name": "in-other", "source": "1" }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
// Filter by id.
|
||||||
|
let filtered: Vec<Value> = s
|
||||||
|
.get(&format!("/api/v1/admin/scripts?app={other_id}"))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
assert_eq!(filtered.len(), 1);
|
||||||
|
assert_eq!(filtered[0]["name"], "in-other");
|
||||||
|
|
||||||
|
// Filter by slug.
|
||||||
|
let filtered_by_slug: Vec<Value> = s
|
||||||
|
.get("/api/v1/admin/scripts?app=filter-target")
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
assert_eq!(filtered_by_slug.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn execution_errors_are_still_logged(pool: PgPool) {
|
async fn execution_errors_are_still_logged(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "boom",
|
"name": "boom",
|
||||||
"source": "1 / 0",
|
"source": "1 / 0",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
|
|||||||
53
crates/shared/src/app.rs
Normal file
53
crates/shared/src/app.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
//! App scoping: top-level isolation boundary for scripts, routes,
|
||||||
|
//! domains, and (forward) data. Every script and route belongs to
|
||||||
|
//! exactly one app; cross-app references are not allowed.
|
||||||
|
//!
|
||||||
|
//! See blueprint §11.5. The orchestrator dispatches via two-phase
|
||||||
|
//! lookup: `Host → app_id → route trie`.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::AppId;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct App {
|
||||||
|
pub id: AppId,
|
||||||
|
/// URL-safe identifier; appears in dashboard paths. Mutable via the
|
||||||
|
/// slug-rename flow which preserves the old slug as a permanent 301
|
||||||
|
/// in `app_slug_history`.
|
||||||
|
pub slug: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum DomainShape {
|
||||||
|
/// Exact host: `app.example.com`.
|
||||||
|
Exact,
|
||||||
|
/// Wildcard suffix: `*.example.com` matches any subdomain.
|
||||||
|
Wildcard,
|
||||||
|
/// Parameterized wildcard: `{tenant}.example.com`. Same shape as
|
||||||
|
/// `Wildcard` for collision purposes; the binding name surfaces in
|
||||||
|
/// request context (future).
|
||||||
|
Parameterized,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AppDomain {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub app_id: AppId,
|
||||||
|
/// As the user typed it: `app.example.com`, `*.example.com`, or
|
||||||
|
/// `{tenant}.example.com`.
|
||||||
|
pub pattern: String,
|
||||||
|
pub shape: DomainShape,
|
||||||
|
/// Normalized collision key. `exact:<host>` for exact; `wildcard:<suffix>`
|
||||||
|
/// for both wildcard and parameterized (parameter name is a binding,
|
||||||
|
/// not a discriminator — per blueprint §11.5).
|
||||||
|
pub shape_key: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
@@ -11,4 +11,10 @@ pub enum Error {
|
|||||||
|
|
||||||
#[error("invalid script source: {0}")]
|
#[error("invalid script source: {0}")]
|
||||||
InvalidScript(String),
|
InvalidScript(String),
|
||||||
|
|
||||||
|
#[error("app not found: {0}")]
|
||||||
|
AppNotFound(crate::AppId),
|
||||||
|
|
||||||
|
#[error("domain claim conflict: {0}")]
|
||||||
|
DomainConflict(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{RequestId, ScriptId};
|
use crate::{AppId, RequestId, ScriptId};
|
||||||
|
|
||||||
/// One row in the `execution_logs` table. Same shape flows through the
|
/// One row in the `execution_logs` table. Same shape flows through the
|
||||||
/// `ExecutionLogSink` trait and the `GET /scripts/{id}/logs` response.
|
/// `ExecutionLogSink` trait and the `GET /scripts/{id}/logs` response.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ExecutionLog {
|
pub struct ExecutionLog {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
/// Owning app at the time of execution. Materialized at write time
|
||||||
|
/// so a future "move script to another app" doesn't retag history.
|
||||||
|
pub app_id: AppId,
|
||||||
pub script_id: ScriptId,
|
pub script_id: ScriptId,
|
||||||
pub request_id: RequestId,
|
pub request_id: RequestId,
|
||||||
|
|
||||||
|
|||||||
@@ -50,3 +50,5 @@ macro_rules! id_type {
|
|||||||
id_type!(ScriptId);
|
id_type!(ScriptId);
|
||||||
id_type!(ExecutionId);
|
id_type!(ExecutionId);
|
||||||
id_type!(RequestId);
|
id_type!(RequestId);
|
||||||
|
id_type!(AdminUserId);
|
||||||
|
id_type!(AppId);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//! that core's crate. Things here must be genuinely shared (IDs, the Script
|
//! that core's crate. Things here must be genuinely shared (IDs, the Script
|
||||||
//! entity, error roots, transport DTOs).
|
//! entity, error roots, transport DTOs).
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod execution_log;
|
pub mod execution_log;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
@@ -14,9 +15,10 @@ pub mod script;
|
|||||||
pub mod validator;
|
pub mod validator;
|
||||||
pub mod version;
|
pub mod version;
|
||||||
|
|
||||||
|
pub use app::{App, AppDomain, DomainShape};
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
pub use execution_log::{ExecutionLog, ExecutionStatus};
|
pub use execution_log::{ExecutionLog, ExecutionStatus};
|
||||||
pub use ids::{ExecutionId, RequestId, ScriptId};
|
pub use ids::{AdminUserId, AppId, ExecutionId, RequestId, ScriptId};
|
||||||
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
||||||
pub use route::{HostKind, PathKind, Route};
|
pub use route::{HostKind, PathKind, Route};
|
||||||
pub use sandbox::ScriptSandbox;
|
pub use sandbox::ScriptSandbox;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::ScriptId;
|
use crate::{AppId, ScriptId};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -40,6 +40,10 @@ pub enum PathKind {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Route {
|
pub struct Route {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
/// Owning app. Always equals `scripts.app_id` for the bound script.
|
||||||
|
/// Carried on the route row so the orchestrator can partition the
|
||||||
|
/// route table without joining back to scripts on every refresh.
|
||||||
|
pub app_id: AppId,
|
||||||
pub script_id: ScriptId,
|
pub script_id: ScriptId,
|
||||||
|
|
||||||
pub host_kind: HostKind,
|
pub host_kind: HostKind,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{ScriptId, ScriptSandbox};
|
use crate::{AppId, ScriptId, ScriptSandbox};
|
||||||
|
|
||||||
/// A user-uploaded Rhai script and its execution configuration.
|
/// A user-uploaded Rhai script and its execution configuration.
|
||||||
///
|
///
|
||||||
@@ -11,6 +11,10 @@ use crate::{ScriptId, ScriptSandbox};
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Script {
|
pub struct Script {
|
||||||
pub id: ScriptId,
|
pub id: ScriptId,
|
||||||
|
/// Owning app. Set on create, immutable thereafter — a "move to
|
||||||
|
/// another app" is a copy+delete, not an in-place edit (snapshot
|
||||||
|
/// semantics — see blueprint §11.5).
|
||||||
|
pub app_id: AppId,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub version: i32,
|
pub version: i32,
|
||||||
|
|||||||
622
dashboard/package-lock.json
generated
622
dashboard/package-lock.json
generated
@@ -1,12 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.1.0",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.1.0",
|
"version": "0.5.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.20.2",
|
||||||
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/search": "^6.7.0",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/view": "^6.43.0",
|
||||||
|
"@lezer/highlight": "^1.2.3",
|
||||||
|
"codemirror": "^6.0.2"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
@@ -23,7 +34,99 @@
|
|||||||
"svelte-check": "^4.1.4",
|
"svelte-check": "^4.1.4",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.20.0",
|
"typescript-eslint": "^8.20.0",
|
||||||
"vite": "^6.0.7"
|
"vite": "^6.0.7",
|
||||||
|
"vitest": "^3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/autocomplete": {
|
||||||
|
"version": "6.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz",
|
||||||
|
"integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/commands": {
|
||||||
|
"version": "6.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
|
||||||
|
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/view": "^6.27.0",
|
||||||
|
"@lezer/common": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lang-json": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@lezer/json": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/language": {
|
||||||
|
"version": "6.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||||
|
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.23.0",
|
||||||
|
"@lezer/common": "^1.5.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0",
|
||||||
|
"style-mod": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lint": {
|
||||||
|
"version": "6.9.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz",
|
||||||
|
"integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.42.0",
|
||||||
|
"crelt": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/search": {
|
||||||
|
"version": "6.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz",
|
||||||
|
"integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.37.0",
|
||||||
|
"crelt": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/state": {
|
||||||
|
"version": "6.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
||||||
|
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@marijn/find-cluster-break": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/view": {
|
||||||
|
"version": "6.43.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
|
||||||
|
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"crelt": "^1.0.6",
|
||||||
|
"style-mod": "^4.1.0",
|
||||||
|
"w3c-keyname": "^2.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
@@ -741,6 +844,47 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@lezer/common": {
|
||||||
|
"version": "1.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
||||||
|
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/highlight": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/json": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/lr": {
|
||||||
|
"version": "1.4.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||||
|
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@marijn/find-cluster-break": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@polka/url": {
|
"node_modules/@polka/url": {
|
||||||
"version": "1.0.0-next.29",
|
"version": "1.0.0-next.29",
|
||||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||||
@@ -1209,6 +1353,17 @@
|
|||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chai": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/deep-eql": "*",
|
||||||
|
"assertion-error": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/cookie": {
|
"node_modules/@types/cookie": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||||
@@ -1216,6 +1371,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/deep-eql": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -1532,6 +1694,121 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/expect": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/spy": "3.2.4",
|
||||||
|
"@vitest/utils": "3.2.4",
|
||||||
|
"chai": "^5.2.0",
|
||||||
|
"tinyrainbow": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/spy": "3.2.4",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^0.30.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"msw": "^2.4.9",
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"msw": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/pretty-format": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyrainbow": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/runner": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/utils": "3.2.4",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"strip-literal": "^3.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/snapshot": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "3.2.4",
|
||||||
|
"magic-string": "^0.30.17",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/spy": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyspy": "^4.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "3.2.4",
|
||||||
|
"loupe": "^3.1.4",
|
||||||
|
"tinyrainbow": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -1606,6 +1883,16 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/assertion-error": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axobject-query": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -1634,6 +1921,16 @@
|
|||||||
"concat-map": "0.0.1"
|
"concat-map": "0.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cac": {
|
||||||
|
"version": "6.7.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||||
|
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/callsites": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
@@ -1644,6 +1941,23 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chai": {
|
||||||
|
"version": "5.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||||
|
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"assertion-error": "^2.0.1",
|
||||||
|
"check-error": "^2.1.1",
|
||||||
|
"deep-eql": "^5.0.1",
|
||||||
|
"loupe": "^3.1.0",
|
||||||
|
"pathval": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -1661,6 +1975,16 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/check-error": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
@@ -1687,6 +2011,21 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codemirror": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/commands": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/lint": "^6.0.0",
|
||||||
|
"@codemirror/search": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -1724,6 +2063,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crelt": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -1770,6 +2115,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/deep-eql": {
|
||||||
|
"version": "5.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||||
|
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -1794,6 +2149,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/es-module-lexer": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -2082,6 +2444,16 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esutils": {
|
"node_modules/esutils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||||
@@ -2092,6 +2464,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expect-type": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -2310,6 +2692,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/js-tokens": {
|
||||||
|
"version": "9.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||||
|
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
@@ -2425,6 +2814,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/loupe": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -2584,6 +2980,23 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pathval": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2923,6 +3336,13 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/siginfo": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/sirv": {
|
"node_modules/sirv": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||||
@@ -2948,6 +3368,20 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackback": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/std-env": {
|
||||||
|
"version": "3.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
|
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/strip-json-comments": {
|
"node_modules/strip-json-comments": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||||
@@ -2961,6 +3395,25 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-literal": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"js-tokens": "^9.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/style-mod": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
@@ -3058,6 +3511,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinybench": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
@@ -3075,6 +3542,36 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinypool": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || >=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyrainbow": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyspy": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/totalist": {
|
"node_modules/totalist": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||||
@@ -3250,6 +3747,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-node": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cac": "^6.7.14",
|
||||||
|
"debug": "^4.4.1",
|
||||||
|
"es-module-lexer": "^1.7.0",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vite-node": "vite-node.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vitefu": {
|
"node_modules/vitefu": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
|
||||||
@@ -3270,6 +3790,85 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/expect": "3.2.4",
|
||||||
|
"@vitest/mocker": "3.2.4",
|
||||||
|
"@vitest/pretty-format": "^3.2.4",
|
||||||
|
"@vitest/runner": "3.2.4",
|
||||||
|
"@vitest/snapshot": "3.2.4",
|
||||||
|
"@vitest/spy": "3.2.4",
|
||||||
|
"@vitest/utils": "3.2.4",
|
||||||
|
"chai": "^5.2.0",
|
||||||
|
"debug": "^4.4.1",
|
||||||
|
"expect-type": "^1.2.1",
|
||||||
|
"magic-string": "^0.30.17",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"picomatch": "^4.0.2",
|
||||||
|
"std-env": "^3.9.0",
|
||||||
|
"tinybench": "^2.9.0",
|
||||||
|
"tinyexec": "^0.3.2",
|
||||||
|
"tinyglobby": "^0.2.14",
|
||||||
|
"tinypool": "^1.1.1",
|
||||||
|
"tinyrainbow": "^2.0.0",
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
|
||||||
|
"vite-node": "3.2.4",
|
||||||
|
"why-is-node-running": "^2.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vitest": "vitest.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@edge-runtime/vm": "*",
|
||||||
|
"@types/debug": "^4.1.12",
|
||||||
|
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||||
|
"@vitest/browser": "3.2.4",
|
||||||
|
"@vitest/ui": "3.2.4",
|
||||||
|
"happy-dom": "*",
|
||||||
|
"jsdom": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@edge-runtime/vm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/debug": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/ui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"happy-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsdom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -3286,6 +3885,23 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/why-is-node-running": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"siginfo": "^2.0.0",
|
||||||
|
"stackback": "0.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"why-is-node-running": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.5.0",
|
"version": "0.5.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -10,14 +10,15 @@
|
|||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lint": "prettier --check . && eslint ."
|
"lint": "prettier --check . && eslint .",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@types/node": "^22.10.5",
|
|
||||||
"@sveltejs/kit": "^2.17.0",
|
"@sveltejs/kit": "^2.17.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
|
"@types/node": "^22.10.5",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-svelte": "^3.0.0",
|
"eslint-plugin-svelte": "^3.0.0",
|
||||||
@@ -28,9 +29,21 @@
|
|||||||
"svelte-check": "^4.1.4",
|
"svelte-check": "^4.1.4",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.20.0",
|
"typescript-eslint": "^8.20.0",
|
||||||
"vite": "^6.0.7"
|
"vite": "^6.0.7",
|
||||||
|
"vitest": "^3.0.5"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"cookie": "^0.7.2"
|
"cookie": "^0.7.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.20.2",
|
||||||
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/search": "^6.7.0",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/view": "^6.43.0",
|
||||||
|
"@lezer/highlight": "^1.2.3",
|
||||||
|
"codemirror": "^6.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
dashboard/src/lib/CodeEditor.svelte
Normal file
107
dashboard/src/lib/CodeEditor.svelte
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<!--
|
||||||
|
CodeMirror-backed text editor for the dashboard.
|
||||||
|
|
||||||
|
Replaces our plain <textarea> with line numbers, syntax highlighting,
|
||||||
|
bracket matching, search/replace (Ctrl+F), and language-aware
|
||||||
|
autocomplete. Two-way bound via `value`; the parent treats it the
|
||||||
|
same as a textarea.
|
||||||
|
|
||||||
|
Languages: `rhai` (custom mode, ./rhai-mode.ts) and `json`
|
||||||
|
(@codemirror/lang-json).
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { basicSetup } from 'codemirror';
|
||||||
|
import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view';
|
||||||
|
import { EditorState } from '@codemirror/state';
|
||||||
|
import { indentWithTab } from '@codemirror/commands';
|
||||||
|
import { json as jsonLang } from '@codemirror/lang-json';
|
||||||
|
import { rhai as rhaiLang } from './rhai-mode';
|
||||||
|
import { dashboardSyntaxHighlighting, dashboardTheme } from './editor-theme';
|
||||||
|
|
||||||
|
type Language = 'rhai' | 'json';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
language = 'rhai' as Language,
|
||||||
|
placeholder = '',
|
||||||
|
minHeight = '12rem'
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
language?: Language;
|
||||||
|
placeholder?: string;
|
||||||
|
minHeight?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let host: HTMLDivElement | null = null;
|
||||||
|
let view: EditorView | null = null;
|
||||||
|
// Guard against the update-listener firing while we're pushing an
|
||||||
|
// external value into the editor — without this, parent-driven
|
||||||
|
// `value` changes would echo back through the listener and create a
|
||||||
|
// dispatch loop in Svelte 5 reactivity.
|
||||||
|
let pushingFromOutside = false;
|
||||||
|
|
||||||
|
function buildExtensions(lang: Language) {
|
||||||
|
const extensions = [
|
||||||
|
basicSetup,
|
||||||
|
lang === 'json' ? jsonLang() : rhaiLang(),
|
||||||
|
keymap.of([indentWithTab]),
|
||||||
|
dashboardSyntaxHighlighting,
|
||||||
|
dashboardTheme,
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged && !pushingFromOutside) {
|
||||||
|
value = update.state.doc.toString();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
|
if (placeholder) extensions.push(cmPlaceholder(placeholder));
|
||||||
|
return extensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!host) return;
|
||||||
|
view = new EditorView({
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: value,
|
||||||
|
extensions: buildExtensions(language)
|
||||||
|
}),
|
||||||
|
parent: host
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
view?.destroy();
|
||||||
|
view = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Push parent-driven `value` updates back into the editor (e.g.
|
||||||
|
// when the script is reloaded after Save, or "Format JSON" rewrites
|
||||||
|
// the body). We only dispatch when the document genuinely differs
|
||||||
|
// from the current `value`.
|
||||||
|
$effect(() => {
|
||||||
|
if (!view) return;
|
||||||
|
const current = view.state.doc.toString();
|
||||||
|
if (current !== value) {
|
||||||
|
pushingFromOutside = true;
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: current.length, insert: value }
|
||||||
|
});
|
||||||
|
pushingFromOutside = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={host} class="cm-host" style:min-height={minHeight}></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cm-host :global(.cm-editor) {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cm-host :global(.cm-scroller) {
|
||||||
|
font-family:
|
||||||
|
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,11 @@
|
|||||||
// the same Caddy upstream so the "Test invoke" panel can hit it
|
// the same Caddy upstream so the "Test invoke" panel can hit it
|
||||||
// without any cross-origin gymnastics.
|
// without any cross-origin gymnastics.
|
||||||
|
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { clearSession, getToken, setSession, type AdminUser } from './auth';
|
||||||
|
|
||||||
export interface ScriptSandbox {
|
export interface ScriptSandbox {
|
||||||
max_operations?: number;
|
max_operations?: number;
|
||||||
max_string_size?: number;
|
max_string_size?: number;
|
||||||
@@ -16,6 +21,7 @@ export interface ScriptSandbox {
|
|||||||
|
|
||||||
export interface Script {
|
export interface Script {
|
||||||
id: string;
|
id: string;
|
||||||
|
app_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
version: number;
|
version: number;
|
||||||
@@ -27,11 +33,64 @@ export interface Script {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface App {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DomainShape = 'exact' | 'wildcard' | 'parameterized';
|
||||||
|
|
||||||
|
export interface AppDomain {
|
||||||
|
id: string;
|
||||||
|
app_id: string;
|
||||||
|
pattern: string;
|
||||||
|
shape: DomainShape;
|
||||||
|
shape_key: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppLookupResponse {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
/// Present only when the requested slug was a retired redirect.
|
||||||
|
redirect_to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlugCheckResponse {
|
||||||
|
ok: boolean;
|
||||||
|
conflict_kind: 'current' | 'historical' | 'invalid' | 'reserved' | null;
|
||||||
|
current_app: App | null;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAppInput {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
force_takeover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatchAppInput {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
slug?: string;
|
||||||
|
force_takeover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type HostKind = 'any' | 'strict' | 'wildcard';
|
export type HostKind = 'any' | 'strict' | 'wildcard';
|
||||||
export type PathKind = 'exact' | 'prefix' | 'param';
|
export type PathKind = 'exact' | 'prefix' | 'param';
|
||||||
|
|
||||||
export interface Route {
|
export interface Route {
|
||||||
id: string;
|
id: string;
|
||||||
|
app_id: string;
|
||||||
script_id: string;
|
script_id: string;
|
||||||
host_kind: HostKind;
|
host_kind: HostKind;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -101,6 +160,7 @@ export interface ExecutionLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateScriptInput {
|
export interface CreateScriptInput {
|
||||||
|
app_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
@@ -134,12 +194,26 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function adminRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
async function adminRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(path, {
|
const headers: Record<string, string> = {
|
||||||
...init,
|
'content-type': 'application/json',
|
||||||
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) }
|
...((init?.headers as Record<string, string>) ?? {})
|
||||||
});
|
};
|
||||||
|
const tok = getToken();
|
||||||
|
if (tok && !headers['authorization']) {
|
||||||
|
headers['authorization'] = `Bearer ${tok}`;
|
||||||
|
}
|
||||||
|
const res = await fetch(path, { ...init, headers });
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
const parsed: unknown = text ? safeJson(text) : null;
|
const parsed: unknown = text ? safeJson(text) : null;
|
||||||
|
if (res.status === 401) {
|
||||||
|
// Token gone stale or never present. Drop any cached session
|
||||||
|
// and bounce to login — unless we're already on it, in which
|
||||||
|
// case throw and let the login form render the error.
|
||||||
|
clearSession();
|
||||||
|
if (browser && !window.location.pathname.endsWith('/login')) {
|
||||||
|
void goto(`${base}/login`);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const message =
|
const message =
|
||||||
(parsed && typeof parsed === 'object' && 'error' in parsed
|
(parsed && typeof parsed === 'object' && 'error' in parsed
|
||||||
@@ -158,11 +232,76 @@ function safeJson(text: string): unknown {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminUserRecord {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
last_login_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAdminInput {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatchAdminInput {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
user: AdminUser;
|
||||||
|
token: string;
|
||||||
|
expires_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
health: () => fetch('/healthz').then((r) => r.text()),
|
health: () => fetch('/healthz').then((r) => r.text()),
|
||||||
|
|
||||||
version: () => adminRequest<VersionInfo>('/version'),
|
version: () => adminRequest<VersionInfo>('/version'),
|
||||||
|
|
||||||
|
auth: {
|
||||||
|
login: async (username: string, password: string): Promise<AdminUser> => {
|
||||||
|
const r = await adminRequest<LoginResponse>('/api/v1/admin/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
setSession(r.user, r.token);
|
||||||
|
return r.user;
|
||||||
|
},
|
||||||
|
logout: async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await adminRequest<null>('/api/v1/admin/auth/logout', { method: 'POST' });
|
||||||
|
} finally {
|
||||||
|
// Always clear locally — logout is idempotent server-side
|
||||||
|
// and we don't want a network blip to strand the SPA in
|
||||||
|
// a "logged out on server, still logged in client-side"
|
||||||
|
// state.
|
||||||
|
clearSession();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
me: () => adminRequest<AdminUser>('/api/v1/admin/auth/me')
|
||||||
|
},
|
||||||
|
|
||||||
|
admins: {
|
||||||
|
list: () => adminRequest<AdminUserRecord[]>('/api/v1/admin/admins'),
|
||||||
|
get: (id: string) => adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`),
|
||||||
|
create: (input: CreateAdminInput) =>
|
||||||
|
adminRequest<AdminUserRecord>('/api/v1/admin/admins', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}),
|
||||||
|
update: (id: string, input: PatchAdminInput) =>
|
||||||
|
adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}),
|
||||||
|
remove: (id: string) =>
|
||||||
|
adminRequest<null>(`/api/v1/admin/admins/${id}`, { method: 'DELETE' })
|
||||||
|
},
|
||||||
|
|
||||||
routes: {
|
routes: {
|
||||||
listForScript: (scriptId: string) =>
|
listForScript: (scriptId: string) =>
|
||||||
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
|
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
|
||||||
@@ -173,20 +312,23 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
remove: (routeId: string) =>
|
remove: (routeId: string) =>
|
||||||
adminRequest<null>(`/api/v1/admin/routes/${routeId}`, { method: 'DELETE' }),
|
adminRequest<null>(`/api/v1/admin/routes/${routeId}`, { method: 'DELETE' }),
|
||||||
check: (input: RouteInput) =>
|
check: (appId: string, input: RouteInput) =>
|
||||||
adminRequest<CheckRouteResponse>('/api/v1/admin/routes:check', {
|
adminRequest<CheckRouteResponse>('/api/v1/admin/routes:check', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(input)
|
body: JSON.stringify({ ...input, app_id: appId })
|
||||||
}),
|
}),
|
||||||
match: (url: string, method = 'GET') =>
|
match: (appId: string, url: string, method = 'GET') =>
|
||||||
adminRequest<MatchRouteResponse>('/api/v1/admin/routes:match', {
|
adminRequest<MatchRouteResponse>('/api/v1/admin/routes:match', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ url, method })
|
body: JSON.stringify({ app_id: appId, url, method })
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
scripts: {
|
scripts: {
|
||||||
list: () => adminRequest<Script[]>('/api/v1/admin/scripts'),
|
list: (opts: { app?: string } = {}) => {
|
||||||
|
const qs = opts.app ? `?app=${encodeURIComponent(opts.app)}` : '';
|
||||||
|
return adminRequest<Script[]>(`/api/v1/admin/scripts${qs}`);
|
||||||
|
},
|
||||||
get: (id: string) => adminRequest<Script>(`/api/v1/admin/scripts/${id}`),
|
get: (id: string) => adminRequest<Script>(`/api/v1/admin/scripts/${id}`),
|
||||||
create: (input: CreateScriptInput) =>
|
create: (input: CreateScriptInput) =>
|
||||||
adminRequest<Script>('/api/v1/admin/scripts', {
|
adminRequest<Script>('/api/v1/admin/scripts', {
|
||||||
@@ -211,6 +353,51 @@ export const api = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
apps: {
|
||||||
|
list: () => adminRequest<App[]>('/api/v1/admin/apps'),
|
||||||
|
get: (idOrSlug: string) =>
|
||||||
|
adminRequest<AppLookupResponse>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`),
|
||||||
|
create: (input: CreateAppInput) =>
|
||||||
|
adminRequest<App>('/api/v1/admin/apps', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}),
|
||||||
|
update: (idOrSlug: string, input: PatchAppInput) =>
|
||||||
|
adminRequest<App>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}),
|
||||||
|
remove: (idOrSlug: string) =>
|
||||||
|
adminRequest<null>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
}),
|
||||||
|
slugCheck: (idOrSlug: string, newSlug: string) =>
|
||||||
|
adminRequest<SlugCheckResponse>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/slug:check`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ new_slug: newSlug })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
domains: {
|
||||||
|
listForApp: (idOrSlug: string) =>
|
||||||
|
adminRequest<AppDomain[]>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains`
|
||||||
|
),
|
||||||
|
create: (idOrSlug: string, pattern: string) =>
|
||||||
|
adminRequest<AppDomain>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ pattern }) }
|
||||||
|
),
|
||||||
|
remove: (idOrSlug: string, domainId: string) =>
|
||||||
|
adminRequest<null>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains/${domainId}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
execute: async (
|
execute: async (
|
||||||
id: string,
|
id: string,
|
||||||
body: unknown,
|
body: unknown,
|
||||||
|
|||||||
60
dashboard/src/lib/auth.ts
Normal file
60
dashboard/src/lib/auth.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// Session state for the dashboard. Backed by a pair of Svelte stores
|
||||||
|
// plus a tiny localStorage echo so a page reload doesn't sign you out.
|
||||||
|
//
|
||||||
|
// The bearer token doubles as the cookie value on the server side, so
|
||||||
|
// in browsers that honor the Set-Cookie response the cookie path "just
|
||||||
|
// works"; the token-in-localStorage path covers the rest (HTTP dev, API
|
||||||
|
// clients impersonating the dashboard) by being injected into the
|
||||||
|
// Authorization header in api.ts.
|
||||||
|
|
||||||
|
import { writable, get } from 'svelte/store';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'picloud.admin.token';
|
||||||
|
|
||||||
|
function readStoredToken(): string | null {
|
||||||
|
if (!browser) return null;
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredToken(value: string | null) {
|
||||||
|
if (!browser) return;
|
||||||
|
try {
|
||||||
|
if (value === null) localStorage.removeItem(TOKEN_KEY);
|
||||||
|
else localStorage.setItem(TOKEN_KEY, value);
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: localStorage can be disabled. The session will
|
||||||
|
// just not survive page reloads, but the in-memory store still
|
||||||
|
// works for the current SPA lifetime.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const token = writable<string | null>(readStoredToken());
|
||||||
|
export const currentUser = writable<AdminUser | null>(null);
|
||||||
|
|
||||||
|
token.subscribe((value) => writeStoredToken(value));
|
||||||
|
|
||||||
|
/** Snapshot of the current token without subscribing — used by the
|
||||||
|
* fetch wrapper. Returns null when no admin is logged in. */
|
||||||
|
export function getToken(): string | null {
|
||||||
|
return get(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSession(user: AdminUser, raw_token: string) {
|
||||||
|
currentUser.set(user);
|
||||||
|
token.set(raw_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSession() {
|
||||||
|
currentUser.set(null);
|
||||||
|
token.set(null);
|
||||||
|
}
|
||||||
159
dashboard/src/lib/editor-theme.ts
Normal file
159
dashboard/src/lib/editor-theme.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
// Dashboard-matching theme + highlight style for CodeMirror.
|
||||||
|
//
|
||||||
|
// Colors are pulled from the existing slate/sky palette used across
|
||||||
|
// the dashboard (#0f172a / #1e293b / #38bdf8) so the editor blends
|
||||||
|
// into the surrounding cards instead of looking like a third-party
|
||||||
|
// transplant. Stock dark themes like "One Dark" or "VS Code Dark"
|
||||||
|
// would each clash with the slate background by a few shades.
|
||||||
|
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||||
|
import { tags as t } from '@lezer/highlight';
|
||||||
|
|
||||||
|
const palette = {
|
||||||
|
bg: '#0b1220', // matches existing <input>/<textarea> backgrounds
|
||||||
|
bgGutter: '#0f172a',
|
||||||
|
border: '#334155',
|
||||||
|
text: '#e2e8f0',
|
||||||
|
textMuted: '#94a3b8',
|
||||||
|
cursor: '#38bdf8',
|
||||||
|
// Selection alpha was originally 30 (≈19%) — bumped to 55 (≈33%)
|
||||||
|
// so it stays clearly visible when it sits on top of (or under, in
|
||||||
|
// CodeMirror's case) the active-line tint. The default CM layering
|
||||||
|
// puts the selection layer behind line backgrounds, so an opaque
|
||||||
|
// active line hides selections on the current line; this pair of
|
||||||
|
// values makes both visible at once.
|
||||||
|
selection: '#38bdf855',
|
||||||
|
// Active line: very subtle sky tint at ~6% alpha. Strong enough to
|
||||||
|
// see at a glance which line the caret is on, weak enough to leave
|
||||||
|
// the selection visible underneath. The active-line gutter (the
|
||||||
|
// brighter line number on the left) is the primary indicator.
|
||||||
|
activeLine: '#38bdf810',
|
||||||
|
activeLineGutter: '#1e293b',
|
||||||
|
matchingBracket: '#38bdf850',
|
||||||
|
searchMatch: '#38bdf850',
|
||||||
|
searchMatchSelected: '#38bdf8',
|
||||||
|
|
||||||
|
// Syntax — chosen to be readable against #0b1220 without making
|
||||||
|
// any single token feel louder than the rest.
|
||||||
|
comment: '#64748b',
|
||||||
|
str: '#86efac',
|
||||||
|
num: '#fbbf24',
|
||||||
|
bool: '#fbbf24',
|
||||||
|
keyword: '#c4b5fd',
|
||||||
|
control: '#c4b5fd',
|
||||||
|
operator: '#cbd5e1',
|
||||||
|
punct: '#cbd5e1',
|
||||||
|
function: '#38bdf8',
|
||||||
|
property: '#e2e8f0',
|
||||||
|
variable: '#e2e8f0',
|
||||||
|
ctx: '#f472b6', // `ctx` is special — pinker so users notice it
|
||||||
|
namespace: '#fbbf24', // `log::`
|
||||||
|
invalid: '#ef4444'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dashboardTheme = EditorView.theme(
|
||||||
|
{
|
||||||
|
'&': {
|
||||||
|
color: palette.text,
|
||||||
|
backgroundColor: palette.bg,
|
||||||
|
borderRadius: '0.375rem'
|
||||||
|
},
|
||||||
|
'&.cm-focused': {
|
||||||
|
outline: `1px solid ${palette.cursor}`
|
||||||
|
},
|
||||||
|
'.cm-content': {
|
||||||
|
caretColor: palette.cursor,
|
||||||
|
fontFamily:
|
||||||
|
'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
padding: '0.5rem 0'
|
||||||
|
},
|
||||||
|
'.cm-cursor, .cm-dropCursor': {
|
||||||
|
borderLeftColor: palette.cursor,
|
||||||
|
borderLeftWidth: '2px'
|
||||||
|
},
|
||||||
|
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
|
||||||
|
{
|
||||||
|
backgroundColor: palette.selection
|
||||||
|
},
|
||||||
|
'.cm-activeLine': {
|
||||||
|
backgroundColor: palette.activeLine
|
||||||
|
},
|
||||||
|
'.cm-gutters': {
|
||||||
|
backgroundColor: palette.bgGutter,
|
||||||
|
color: palette.textMuted,
|
||||||
|
border: 'none',
|
||||||
|
borderRight: `1px solid ${palette.border}`
|
||||||
|
},
|
||||||
|
'.cm-activeLineGutter': {
|
||||||
|
backgroundColor: palette.activeLineGutter,
|
||||||
|
color: palette.text
|
||||||
|
},
|
||||||
|
'.cm-matchingBracket, .cm-nonmatchingBracket': {
|
||||||
|
backgroundColor: palette.matchingBracket,
|
||||||
|
outline: 'none'
|
||||||
|
},
|
||||||
|
// Search / replace panel (Ctrl+F)
|
||||||
|
'.cm-panels': {
|
||||||
|
backgroundColor: palette.bgGutter,
|
||||||
|
color: palette.text,
|
||||||
|
borderTop: `1px solid ${palette.border}`
|
||||||
|
},
|
||||||
|
'.cm-searchMatch': {
|
||||||
|
backgroundColor: palette.searchMatch
|
||||||
|
},
|
||||||
|
'.cm-searchMatch.cm-searchMatch-selected': {
|
||||||
|
backgroundColor: palette.searchMatchSelected
|
||||||
|
},
|
||||||
|
'.cm-panel input, .cm-panel button': {
|
||||||
|
backgroundColor: palette.bg,
|
||||||
|
color: palette.text,
|
||||||
|
border: `1px solid ${palette.border}`,
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
padding: '0.2rem 0.4rem'
|
||||||
|
},
|
||||||
|
'.cm-panel button:hover': {
|
||||||
|
backgroundColor: palette.activeLine
|
||||||
|
},
|
||||||
|
// Autocomplete popup
|
||||||
|
'.cm-tooltip': {
|
||||||
|
backgroundColor: palette.bgGutter,
|
||||||
|
border: `1px solid ${palette.border}`,
|
||||||
|
color: palette.text
|
||||||
|
},
|
||||||
|
'.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]': {
|
||||||
|
backgroundColor: palette.activeLine,
|
||||||
|
color: palette.text
|
||||||
|
},
|
||||||
|
'.cm-completionLabel': {
|
||||||
|
color: palette.text
|
||||||
|
},
|
||||||
|
'.cm-completionDetail': {
|
||||||
|
color: palette.textMuted,
|
||||||
|
fontStyle: 'normal'
|
||||||
|
},
|
||||||
|
'.cm-completionIcon': {
|
||||||
|
color: palette.textMuted
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ dark: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dashboardHighlight = HighlightStyle.define([
|
||||||
|
{ tag: t.comment, color: palette.comment, fontStyle: 'italic' },
|
||||||
|
{ tag: [t.string, t.special(t.string)], color: palette.str },
|
||||||
|
{ tag: [t.number, t.bool, t.null], color: palette.num },
|
||||||
|
{ tag: [t.keyword, t.modifier], color: palette.keyword, fontWeight: '600' },
|
||||||
|
{ tag: t.controlKeyword, color: palette.control, fontWeight: '600' },
|
||||||
|
{ tag: [t.operator, t.derefOperator, t.logicOperator], color: palette.operator },
|
||||||
|
{ tag: [t.punctuation, t.bracket, t.brace, t.paren, t.squareBracket], color: palette.punct },
|
||||||
|
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: palette.function },
|
||||||
|
{ tag: [t.propertyName, t.attributeName], color: palette.property },
|
||||||
|
{ tag: t.variableName, color: palette.variable },
|
||||||
|
{ tag: t.special(t.variableName), color: palette.ctx, fontWeight: '600' },
|
||||||
|
{ tag: t.namespace, color: palette.namespace, fontWeight: '600' },
|
||||||
|
{ tag: t.invalid, color: palette.invalid, textDecoration: 'underline wavy' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const dashboardSyntaxHighlighting = syntaxHighlighting(dashboardHighlight);
|
||||||
600
dashboard/src/lib/rhai-mode.ts
Normal file
600
dashboard/src/lib/rhai-mode.ts
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
// CodeMirror StreamLanguage for Rhai.
|
||||||
|
//
|
||||||
|
// Keyword and operator lists are sourced from the upstream TextMate
|
||||||
|
// grammar maintained by the Rhai authors:
|
||||||
|
// https://github.com/rhaiscript/vscode-rhai
|
||||||
|
// syntax/rhai.tmLanguage.json (MPL-2.0)
|
||||||
|
// This file does NOT copy the upstream grammar bytes — only the
|
||||||
|
// symbol lists. The matching logic is a simple regex tokenizer
|
||||||
|
// tailored to CodeMirror's StreamLanguage shape; if richer
|
||||||
|
// highlighting is wanted later, swap this out for a full tmLanguage
|
||||||
|
// loader (vscode-textmate + oniguruma) without touching callers.
|
||||||
|
//
|
||||||
|
// SDK completions (`ctx.*`, `log::*`) come from our own SDK contract
|
||||||
|
// in crates/executor-core/tests/sdk_contract.rs — that file is the
|
||||||
|
// authoritative list of what scripts can do.
|
||||||
|
|
||||||
|
import { StreamLanguage, LanguageSupport } from '@codemirror/language';
|
||||||
|
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
|
||||||
|
import { EditorSelection, StateEffect, StateField, type Extension } from '@codemirror/state';
|
||||||
|
import { EditorView, keymap, showPanel, type Panel } from '@codemirror/view';
|
||||||
|
import { parse, buildSymbolTable, type ParseResult, type Range, type SymbolTable } from './rhai';
|
||||||
|
|
||||||
|
// Keywords that drive control flow (`if`, `for`, ...) — these get the
|
||||||
|
// `controlKeyword` tag so the theme can color them distinctly from
|
||||||
|
// declaration-style keywords like `let` or `fn`.
|
||||||
|
const CONTROL_KEYWORDS = new Set([
|
||||||
|
'if',
|
||||||
|
'else',
|
||||||
|
'for',
|
||||||
|
'while',
|
||||||
|
'loop',
|
||||||
|
'do',
|
||||||
|
'switch',
|
||||||
|
'case',
|
||||||
|
'default',
|
||||||
|
'return',
|
||||||
|
'break',
|
||||||
|
'continue',
|
||||||
|
'try',
|
||||||
|
'catch',
|
||||||
|
'throw'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const DECLARATION_KEYWORDS = new Set([
|
||||||
|
'let',
|
||||||
|
'const',
|
||||||
|
'fn',
|
||||||
|
'private',
|
||||||
|
'in',
|
||||||
|
'as',
|
||||||
|
'is'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reserved-but-not-currently-valid keywords from the upstream grammar.
|
||||||
|
// We still highlight them so users notice them; the parser will reject
|
||||||
|
// at execute time.
|
||||||
|
const RESERVED_KEYWORDS = new Set([
|
||||||
|
'var',
|
||||||
|
'match',
|
||||||
|
'public',
|
||||||
|
'protected',
|
||||||
|
'new',
|
||||||
|
'use',
|
||||||
|
'with',
|
||||||
|
'module',
|
||||||
|
'package',
|
||||||
|
'super',
|
||||||
|
'spawn',
|
||||||
|
'thread',
|
||||||
|
'go',
|
||||||
|
'sync',
|
||||||
|
'async',
|
||||||
|
'await',
|
||||||
|
'yield',
|
||||||
|
'void',
|
||||||
|
'null',
|
||||||
|
'nil',
|
||||||
|
'debug',
|
||||||
|
'eval',
|
||||||
|
'print',
|
||||||
|
'import',
|
||||||
|
'export'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BOOLEAN_LITERALS = new Set(['true', 'false']);
|
||||||
|
|
||||||
|
const SPECIAL_VARIABLES = new Set(['ctx']);
|
||||||
|
const NAMESPACES = new Set(['log']);
|
||||||
|
|
||||||
|
interface RhaiState {
|
||||||
|
inBlockComment: boolean;
|
||||||
|
inString: false | '"' | '`';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rhaiLanguage = StreamLanguage.define<RhaiState>({
|
||||||
|
name: 'rhai',
|
||||||
|
startState: () => ({ inBlockComment: false, inString: false }),
|
||||||
|
token(stream, state) {
|
||||||
|
// --- inside a /* … */ block comment ---
|
||||||
|
if (state.inBlockComment) {
|
||||||
|
if (stream.match(/.*?\*\//)) {
|
||||||
|
state.inBlockComment = false;
|
||||||
|
} else {
|
||||||
|
stream.skipToEnd();
|
||||||
|
}
|
||||||
|
return 'comment';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- inside a multi-line string (rare but possible with ` ` strings) ---
|
||||||
|
if (state.inString) {
|
||||||
|
const quote = state.inString;
|
||||||
|
while (!stream.eol()) {
|
||||||
|
const ch = stream.next();
|
||||||
|
if (ch === '\\') {
|
||||||
|
stream.next();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === quote) {
|
||||||
|
state.inString = false;
|
||||||
|
return 'string';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip whitespace
|
||||||
|
if (stream.eatSpace()) return null;
|
||||||
|
|
||||||
|
// --- line comment ---
|
||||||
|
if (stream.match('//')) {
|
||||||
|
stream.skipToEnd();
|
||||||
|
return 'comment';
|
||||||
|
}
|
||||||
|
// --- block comment ---
|
||||||
|
if (stream.match('/*')) {
|
||||||
|
state.inBlockComment = true;
|
||||||
|
if (stream.match(/.*?\*\//)) {
|
||||||
|
state.inBlockComment = false;
|
||||||
|
} else {
|
||||||
|
stream.skipToEnd();
|
||||||
|
}
|
||||||
|
return 'comment';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- strings ---
|
||||||
|
const quote = stream.peek();
|
||||||
|
if (quote === '"' || quote === '`') {
|
||||||
|
stream.next();
|
||||||
|
while (!stream.eol()) {
|
||||||
|
const ch = stream.next();
|
||||||
|
if (ch === '\\') {
|
||||||
|
stream.next();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === quote) {
|
||||||
|
return 'string';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// String continues on next line (only really valid for ` `).
|
||||||
|
state.inString = quote;
|
||||||
|
return 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- numbers (hex, binary, decimal, float) ---
|
||||||
|
if (stream.match(/^0x[0-9a-fA-F_]+/)) return 'number';
|
||||||
|
if (stream.match(/^0b[01_]+/)) return 'number';
|
||||||
|
if (stream.match(/^\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?/)) return 'number';
|
||||||
|
|
||||||
|
// --- module path: name::name (used for log::info etc.) ---
|
||||||
|
// Recognized before plain identifiers so we can return 'namespace'.
|
||||||
|
if (stream.match(/^[a-zA-Z_]\w*(?=::)/)) {
|
||||||
|
const word = stream.current();
|
||||||
|
if (NAMESPACES.has(word)) return 'namespace';
|
||||||
|
return 'variableName';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- identifiers + keywords ---
|
||||||
|
if (stream.match(/^[a-zA-Z_]\w*/)) {
|
||||||
|
const word = stream.current();
|
||||||
|
if (CONTROL_KEYWORDS.has(word)) return 'controlKeyword';
|
||||||
|
if (DECLARATION_KEYWORDS.has(word)) return 'keyword';
|
||||||
|
if (RESERVED_KEYWORDS.has(word)) return 'keyword';
|
||||||
|
if (BOOLEAN_LITERALS.has(word)) return 'bool';
|
||||||
|
if (SPECIAL_VARIABLES.has(word)) return 'variableName.special';
|
||||||
|
if (NAMESPACES.has(word)) return 'namespace';
|
||||||
|
// Followed by `(` → function call. We highlight as a function name.
|
||||||
|
if (stream.peek() === '(') return 'function(variableName)';
|
||||||
|
// Property after `.`
|
||||||
|
const before = stream.string.slice(0, stream.start);
|
||||||
|
if (before.endsWith('.')) return 'propertyName';
|
||||||
|
return 'variableName';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- operators / punctuation ---
|
||||||
|
if (stream.match(/^(\?\?|\.\.=|\.\.|::|==|!=|<=|>=|&&|\|\||<<|>>|=>|->|[+\-*/%<>!&|^~=])/)) {
|
||||||
|
return 'operator';
|
||||||
|
}
|
||||||
|
if (stream.match(/^[(){}[\];,.]/)) return 'punctuation';
|
||||||
|
|
||||||
|
// Unrecognized — advance one char and bail.
|
||||||
|
stream.next();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
languageData: {
|
||||||
|
commentTokens: { line: '//', block: { open: '/*', close: '*/' } },
|
||||||
|
closeBrackets: { brackets: ['(', '[', '{', '"', '`'] },
|
||||||
|
indentOnInput: /^\s*[}\])]$/
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Autocomplete
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface CompletionItem {
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
type?: 'keyword' | 'variable' | 'function' | 'property' | 'namespace';
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEYWORD_COMPLETIONS: CompletionItem[] = [
|
||||||
|
...['let', 'const', 'fn'].map((k) => ({ label: k, type: 'keyword' as const, detail: 'declaration' })),
|
||||||
|
...['if', 'else', 'for', 'while', 'loop', 'switch', 'return', 'break', 'continue', 'try', 'catch', 'throw'].map(
|
||||||
|
(k) => ({ label: k, type: 'keyword' as const, detail: 'control flow' })
|
||||||
|
),
|
||||||
|
...['in', 'as', 'is'].map((k) => ({ label: k, type: 'keyword' as const })),
|
||||||
|
{ label: 'true', type: 'keyword', detail: 'boolean' },
|
||||||
|
{ label: 'false', type: 'keyword', detail: 'boolean' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ctx.* — keep aligned with `build_ctx_map` in
|
||||||
|
// crates/executor-core/src/engine.rs.
|
||||||
|
const CTX_TOP_COMPLETIONS: CompletionItem[] = [
|
||||||
|
{ label: 'execution_id', type: 'property', detail: 'string' },
|
||||||
|
{ label: 'script_id', type: 'property', detail: 'string' },
|
||||||
|
{ label: 'script_name', type: 'property', detail: 'string' },
|
||||||
|
{ label: 'request_id', type: 'property', detail: 'string' },
|
||||||
|
{ label: 'invocation_type', type: 'property', detail: '"http" | "function" | "scheduled"' },
|
||||||
|
{ label: 'sdk_version', type: 'property', detail: 'string ("major.minor")' },
|
||||||
|
{ label: 'request', type: 'property', detail: 'object' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const CTX_REQUEST_COMPLETIONS: CompletionItem[] = [
|
||||||
|
{ label: 'path', type: 'property', detail: 'string' },
|
||||||
|
{ label: 'headers', type: 'property', detail: 'map of string→string' },
|
||||||
|
{ label: 'body', type: 'property', detail: 'parsed JSON value' },
|
||||||
|
{ label: 'params', type: 'property', detail: 'map (param-route captures, SDK 1.1+)' },
|
||||||
|
{ label: 'query', type: 'property', detail: 'map (parsed query string, SDK 1.1+)' },
|
||||||
|
{ label: 'rest', type: 'property', detail: 'string (prefix-route tail, SDK 1.1+)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOG_COMPLETIONS: CompletionItem[] = [
|
||||||
|
{ label: 'info', type: 'function', detail: 'log::info(msg, data?)' },
|
||||||
|
{ label: 'warn', type: 'function', detail: 'log::warn(msg, data?)' },
|
||||||
|
{ label: 'error', type: 'function', detail: 'log::error(msg, data?)' },
|
||||||
|
{ label: 'trace', type: 'function', detail: 'log::trace(msg, data?) — use instead of "debug" (reserved keyword)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOP_LEVEL_GLOBALS: CompletionItem[] = [
|
||||||
|
{ label: 'ctx', type: 'variable', detail: 'invocation context' },
|
||||||
|
{ label: 'log', type: 'namespace', detail: 'log::info/warn/error/trace' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function toCMCompletions(items: CompletionItem[]) {
|
||||||
|
return items.map((c) => ({
|
||||||
|
label: c.label,
|
||||||
|
type: c.type,
|
||||||
|
detail: c.detail
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rhaiCompletions(context: CompletionContext): CompletionResult | null {
|
||||||
|
// `log::` namespace
|
||||||
|
const ns = context.matchBefore(/log::\w*/);
|
||||||
|
if (ns) {
|
||||||
|
return {
|
||||||
|
from: ns.from + 'log::'.length,
|
||||||
|
options: toCMCompletions(LOG_COMPLETIONS),
|
||||||
|
validFor: /^\w*$/
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// `ctx.request.` properties
|
||||||
|
const ctxReq = context.matchBefore(/ctx\.request\.\w*/);
|
||||||
|
if (ctxReq) {
|
||||||
|
return {
|
||||||
|
from: ctxReq.from + 'ctx.request.'.length,
|
||||||
|
options: toCMCompletions(CTX_REQUEST_COMPLETIONS),
|
||||||
|
validFor: /^\w*$/
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// `ctx.` properties
|
||||||
|
const ctx = context.matchBefore(/ctx\.\w*/);
|
||||||
|
if (ctx) {
|
||||||
|
return {
|
||||||
|
from: ctx.from + 'ctx.'.length,
|
||||||
|
options: toCMCompletions(CTX_TOP_COMPLETIONS),
|
||||||
|
validFor: /^\w*$/
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member access on something other than `ctx`/`log` — let the
|
||||||
|
// script-aware source decide whether it has fields to offer. We bow
|
||||||
|
// out so we don't flood the popup with keywords after a `.`.
|
||||||
|
if (context.matchBefore(/\.\w*$/)) return null;
|
||||||
|
|
||||||
|
// Plain word at the cursor → keywords + top-level names.
|
||||||
|
const word = context.matchBefore(/\w+/);
|
||||||
|
if (!word && !context.explicit) return null;
|
||||||
|
return {
|
||||||
|
from: word ? word.from : context.pos,
|
||||||
|
options: toCMCompletions([...KEYWORD_COMPLETIONS, ...TOP_LEVEL_GLOBALS]),
|
||||||
|
validFor: /^\w*$/
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Script-aware analysis
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// One AST + symbol table per editor state. Rebuilt on every doc change.
|
||||||
|
// Scripts in the dashboard are small (20–200 lines is the target); the
|
||||||
|
// parse + walk is sub-millisecond at that size, so we don't bother
|
||||||
|
// debouncing for now.
|
||||||
|
interface RhaiAnalysis {
|
||||||
|
parse: ParseResult;
|
||||||
|
table: SymbolTable;
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyze(source: string): RhaiAnalysis {
|
||||||
|
const parsed = parse(source);
|
||||||
|
return { parse: parsed, table: buildSymbolTable(parsed) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rhaiAnalysisField = StateField.define<RhaiAnalysis>({
|
||||||
|
create: (state) => analyze(state.doc.toString()),
|
||||||
|
update: (value, tr) => (tr.docChanged ? analyze(tr.newDoc.toString()) : value)
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script-aware completion source.
|
||||||
|
*
|
||||||
|
* Two things on top of the static `ctx.*` / `log::*` list:
|
||||||
|
* 1. After `name.`, if `name` was initialized to an object-map
|
||||||
|
* literal, suggest that literal's field names.
|
||||||
|
* 2. At a plain word position, suggest user-defined symbols in scope
|
||||||
|
* (locals, function parameters, top-level `fn` decls). `fn` decls
|
||||||
|
* get their signature in the `detail` field so the popup shows
|
||||||
|
* `process(order, user)` rather than just `process`.
|
||||||
|
*
|
||||||
|
* Composes with `rhaiCompletions` via `autocompletion({ override:
|
||||||
|
* [scopeCompletionSource, rhaiCompletions] })` — CodeMirror merges the
|
||||||
|
* results.
|
||||||
|
*/
|
||||||
|
export function scopeCompletionSource(context: CompletionContext): CompletionResult | null {
|
||||||
|
const analysis = context.state.field(rhaiAnalysisField, false);
|
||||||
|
if (!analysis) return null;
|
||||||
|
|
||||||
|
// Member access — `obj.fie|`. Only fire if `obj` resolves to a known
|
||||||
|
// object-literal in scope; otherwise leave the popup empty rather
|
||||||
|
// than guess wrong fields.
|
||||||
|
const member = context.matchBefore(/(\w+)\.(\w*)/);
|
||||||
|
if (member) {
|
||||||
|
const dotIdx = member.text.indexOf('.');
|
||||||
|
const objectName = member.text.slice(0, dotIdx);
|
||||||
|
// `ctx.*` is handled by the static source — don't double up.
|
||||||
|
if (objectName !== 'ctx') {
|
||||||
|
const fields = analysis.table.objectFieldsOf(objectName, member.from);
|
||||||
|
if (fields.length > 0) {
|
||||||
|
return {
|
||||||
|
from: member.from + dotIdx + 1,
|
||||||
|
options: fields.map((label) => ({ label, type: 'property' })),
|
||||||
|
validFor: /^\w*$/
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip when right after `::` (handled by the static source for log::).
|
||||||
|
if (context.matchBefore(/::\w*$/)) return null;
|
||||||
|
|
||||||
|
// Plain word — surface in-scope decls.
|
||||||
|
const word = context.matchBefore(/\w+/);
|
||||||
|
if (!word && !context.explicit) return null;
|
||||||
|
const decls = analysis.table.scopeCompletions(context.pos);
|
||||||
|
if (decls.length === 0) return null;
|
||||||
|
return {
|
||||||
|
from: word ? word.from : context.pos,
|
||||||
|
options: decls.map((d) => ({
|
||||||
|
label: d.name,
|
||||||
|
type: d.kind === 'fn' ? 'function' : 'variable',
|
||||||
|
detail: d.signature ?? d.kind
|
||||||
|
})),
|
||||||
|
validFor: /^\w*$/
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Go-to-definition, Ctrl/Cmd+Click, find-usages
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface UsagesPanelState {
|
||||||
|
name: string;
|
||||||
|
ranges: Range[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const setUsagesPanel = StateEffect.define<UsagesPanelState | null>();
|
||||||
|
|
||||||
|
const usagesPanelField = StateField.define<UsagesPanelState | null>({
|
||||||
|
create: () => null,
|
||||||
|
update(value, tr) {
|
||||||
|
for (const e of tr.effects) if (e.is(setUsagesPanel)) return e.value;
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
provide: (f) => showPanel.from(f, (value) => (value ? buildUsagesPanel : null))
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildUsagesPanel(view: EditorView): Panel {
|
||||||
|
const state = view.state.field(usagesPanelField);
|
||||||
|
const dom = document.createElement('div');
|
||||||
|
dom.className = 'cm-rhai-usages';
|
||||||
|
|
||||||
|
const head = document.createElement('div');
|
||||||
|
head.className = 'cm-rhai-usages-head';
|
||||||
|
const count = state ? state.ranges.length : 0;
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = `${count} occurrence${count === 1 ? '' : 's'} of "${state?.name ?? ''}"`;
|
||||||
|
head.appendChild(label);
|
||||||
|
const close = document.createElement('button');
|
||||||
|
close.type = 'button';
|
||||||
|
close.textContent = '×';
|
||||||
|
close.title = 'Close (Esc)';
|
||||||
|
close.onclick = () => {
|
||||||
|
view.dispatch({ effects: setUsagesPanel.of(null) });
|
||||||
|
view.focus();
|
||||||
|
};
|
||||||
|
head.appendChild(close);
|
||||||
|
dom.appendChild(head);
|
||||||
|
|
||||||
|
if (state) {
|
||||||
|
for (const r of state.ranges) {
|
||||||
|
const line = view.state.doc.lineAt(r.start);
|
||||||
|
const row = document.createElement('button');
|
||||||
|
row.type = 'button';
|
||||||
|
row.className = 'cm-rhai-usages-row';
|
||||||
|
const num = document.createElement('span');
|
||||||
|
num.className = 'cm-rhai-usages-line';
|
||||||
|
num.textContent = String(line.number);
|
||||||
|
const snip = document.createElement('span');
|
||||||
|
snip.className = 'cm-rhai-usages-snip';
|
||||||
|
snip.textContent = line.text.trim();
|
||||||
|
row.appendChild(num);
|
||||||
|
row.appendChild(snip);
|
||||||
|
row.onclick = () => {
|
||||||
|
view.dispatch({
|
||||||
|
selection: EditorSelection.cursor(r.start),
|
||||||
|
scrollIntoView: true
|
||||||
|
});
|
||||||
|
view.focus();
|
||||||
|
};
|
||||||
|
dom.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dom, top: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const usagesPanelTheme = EditorView.baseTheme({
|
||||||
|
'.cm-rhai-usages': {
|
||||||
|
background: '#0f172a',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
borderTop: '1px solid #334155',
|
||||||
|
padding: '0.4rem 0.5rem',
|
||||||
|
maxHeight: '180px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
fontSize: '0.85rem'
|
||||||
|
},
|
||||||
|
'.cm-rhai-usages-head': {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: '#94a3b8',
|
||||||
|
marginBottom: '0.25rem'
|
||||||
|
},
|
||||||
|
'.cm-rhai-usages-head button': {
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#94a3b8',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '1rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0 0.4rem'
|
||||||
|
},
|
||||||
|
'.cm-rhai-usages-row': {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
width: '100%',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
border: 'none',
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: '0.15rem 0.25rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 'inherit',
|
||||||
|
borderRadius: '0.25rem'
|
||||||
|
},
|
||||||
|
'.cm-rhai-usages-row:hover': {
|
||||||
|
background: '#1e293b'
|
||||||
|
},
|
||||||
|
'.cm-rhai-usages-line': {
|
||||||
|
color: '#64748b',
|
||||||
|
minWidth: '2.5rem',
|
||||||
|
flexShrink: 0
|
||||||
|
},
|
||||||
|
'.cm-rhai-usages-snip': {
|
||||||
|
fontFamily:
|
||||||
|
'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function declAtCursor(view: EditorView, pos: number) {
|
||||||
|
const analysis = view.state.field(rhaiAnalysisField, false);
|
||||||
|
if (!analysis) return null;
|
||||||
|
return analysis.table.declOfUsageAt(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gotoDefinition(view: EditorView): boolean {
|
||||||
|
const pos = view.state.selection.main.head;
|
||||||
|
const decl = declAtCursor(view, pos);
|
||||||
|
if (!decl) return false;
|
||||||
|
view.dispatch({
|
||||||
|
selection: EditorSelection.cursor(decl.nameRange.start),
|
||||||
|
scrollIntoView: true
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findUsages(view: EditorView): boolean {
|
||||||
|
const pos = view.state.selection.main.head;
|
||||||
|
const analysis = view.state.field(rhaiAnalysisField, false);
|
||||||
|
if (!analysis) return false;
|
||||||
|
const decl = analysis.table.declOfUsageAt(pos);
|
||||||
|
if (!decl) return false;
|
||||||
|
view.dispatch({
|
||||||
|
effects: setUsagesPanel.of({
|
||||||
|
name: decl.name,
|
||||||
|
ranges: analysis.table.usagesOf(decl)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUsagesPanel(view: EditorView): boolean {
|
||||||
|
if (!view.state.field(usagesPanelField, false)) return false;
|
||||||
|
view.dispatch({ effects: setUsagesPanel.of(null) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rhaiKeymap = keymap.of([
|
||||||
|
{ key: 'F12', run: gotoDefinition },
|
||||||
|
{ key: 'Shift-F12', run: findUsages },
|
||||||
|
{ key: 'Escape', run: closeUsagesPanel }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Ctrl+Click (Cmd+Click on macOS) on an identifier → jump to its decl.
|
||||||
|
// Returning `true` suppresses CodeMirror's default selection behavior so
|
||||||
|
// the click doesn't simultaneously place the caret at the click site.
|
||||||
|
const ctrlClickHandler = EditorView.domEventHandlers({
|
||||||
|
mousedown(event, view) {
|
||||||
|
if (!(event.metaKey || event.ctrlKey) || event.button !== 0) return false;
|
||||||
|
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
|
||||||
|
if (pos == null) return false;
|
||||||
|
const decl = declAtCursor(view, pos);
|
||||||
|
if (!decl) return false;
|
||||||
|
view.dispatch({
|
||||||
|
selection: EditorSelection.cursor(decl.nameRange.start),
|
||||||
|
scrollIntoView: true
|
||||||
|
});
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function rhai(): LanguageSupport {
|
||||||
|
const extensions: Extension[] = [
|
||||||
|
rhaiAnalysisField,
|
||||||
|
usagesPanelField,
|
||||||
|
usagesPanelTheme,
|
||||||
|
rhaiKeymap,
|
||||||
|
ctrlClickHandler,
|
||||||
|
autocompletion({ override: [scopeCompletionSource, rhaiCompletions] })
|
||||||
|
];
|
||||||
|
return new LanguageSupport(rhaiLanguage, extensions);
|
||||||
|
}
|
||||||
279
dashboard/src/lib/rhai/ast.ts
Normal file
279
dashboard/src/lib/rhai/ast.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
// AST node definitions for the dashboard's hand-rolled Rhai parser.
|
||||||
|
//
|
||||||
|
// Every node carries `start` / `end` byte offsets into the source so the
|
||||||
|
// editor features (autocomplete, goto-def, find-usages, format) can map
|
||||||
|
// between positions in the document and nodes in the tree.
|
||||||
|
//
|
||||||
|
// The shape mirrors the Rhai book grammar (https://rhai.rs/book/language/)
|
||||||
|
// but simplified: type annotations are absent (Rhai is dynamic), and
|
||||||
|
// statement-vs-expression duality is collapsed by letting `if` / `switch` /
|
||||||
|
// block expressions appear in both positions (an `ExprStmt` wrapper turns
|
||||||
|
// any expression into a statement).
|
||||||
|
|
||||||
|
export interface Range {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Comments — captured by the lexer with their positions and re-emitted by
|
||||||
|
// the formatter. Kept off the AST tree so they don't clutter walkers.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Comment extends Range {
|
||||||
|
kind: 'LineComment' | 'BlockComment';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Statements
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type Stmt =
|
||||||
|
| LetStmt
|
||||||
|
| ConstStmt
|
||||||
|
| FnDecl
|
||||||
|
| ExprStmt
|
||||||
|
| ReturnStmt
|
||||||
|
| WhileStmt
|
||||||
|
| LoopStmt
|
||||||
|
| ForStmt
|
||||||
|
| BreakStmt
|
||||||
|
| ContinueStmt
|
||||||
|
| TryStmt;
|
||||||
|
|
||||||
|
export interface LetStmt extends Range {
|
||||||
|
kind: 'Let';
|
||||||
|
name: string;
|
||||||
|
nameRange: Range;
|
||||||
|
init: Expr | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConstStmt extends Range {
|
||||||
|
kind: 'Const';
|
||||||
|
name: string;
|
||||||
|
nameRange: Range;
|
||||||
|
init: Expr | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Param extends Range {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FnDecl extends Range {
|
||||||
|
kind: 'FnDecl';
|
||||||
|
name: string;
|
||||||
|
nameRange: Range;
|
||||||
|
params: Param[];
|
||||||
|
body: BlockExpr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExprStmt extends Range {
|
||||||
|
kind: 'ExprStmt';
|
||||||
|
expr: Expr;
|
||||||
|
// Whether the statement is terminated with `;`. Block-form expressions
|
||||||
|
// (`if`/`switch`/`{...}`) don't require it; everything else does.
|
||||||
|
semi: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReturnStmt extends Range {
|
||||||
|
kind: 'Return';
|
||||||
|
value: Expr | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WhileStmt extends Range {
|
||||||
|
kind: 'While';
|
||||||
|
cond: Expr;
|
||||||
|
body: BlockExpr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoopStmt extends Range {
|
||||||
|
kind: 'Loop';
|
||||||
|
body: BlockExpr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForStmt extends Range {
|
||||||
|
kind: 'For';
|
||||||
|
varName: string;
|
||||||
|
varRange: Range;
|
||||||
|
iter: Expr;
|
||||||
|
body: BlockExpr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BreakStmt extends Range {
|
||||||
|
kind: 'Break';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContinueStmt extends Range {
|
||||||
|
kind: 'Continue';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TryStmt extends Range {
|
||||||
|
kind: 'Try';
|
||||||
|
body: BlockExpr;
|
||||||
|
catchVar: string | null;
|
||||||
|
catchVarRange: Range | null;
|
||||||
|
handler: BlockExpr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Expressions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type Expr =
|
||||||
|
| IdentExpr
|
||||||
|
| NumberExpr
|
||||||
|
| StringExpr
|
||||||
|
| BoolExpr
|
||||||
|
| NullExpr
|
||||||
|
| CallExpr
|
||||||
|
| MemberExpr
|
||||||
|
| IndexExpr
|
||||||
|
| UnaryExpr
|
||||||
|
| BinaryExpr
|
||||||
|
| AssignExpr
|
||||||
|
| ParenExpr
|
||||||
|
| ObjectMapExpr
|
||||||
|
| ArrayExpr
|
||||||
|
| FnExpr
|
||||||
|
| IfExpr
|
||||||
|
| SwitchExpr
|
||||||
|
| BlockExpr;
|
||||||
|
|
||||||
|
export interface IdentExpr extends Range {
|
||||||
|
kind: 'Ident';
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberExpr extends Range {
|
||||||
|
kind: 'Number';
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StringExpr extends Range {
|
||||||
|
kind: 'String';
|
||||||
|
// The surrounding quote — `"` is escape-processed, backtick is raw and
|
||||||
|
// may span multiple lines. We don't decode escapes; the formatter just
|
||||||
|
// preserves the raw text between the quotes.
|
||||||
|
quote: '"' | '`';
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoolExpr extends Range {
|
||||||
|
kind: 'Bool';
|
||||||
|
value: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NullExpr extends Range {
|
||||||
|
kind: 'Null';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallExpr extends Range {
|
||||||
|
kind: 'Call';
|
||||||
|
callee: Expr;
|
||||||
|
args: Expr[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberExpr extends Range {
|
||||||
|
kind: 'Member';
|
||||||
|
object: Expr;
|
||||||
|
property: string;
|
||||||
|
propertyRange: Range;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexExpr extends Range {
|
||||||
|
kind: 'Index';
|
||||||
|
object: Expr;
|
||||||
|
index: Expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnaryExpr extends Range {
|
||||||
|
kind: 'Unary';
|
||||||
|
op: string;
|
||||||
|
operand: Expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BinaryExpr extends Range {
|
||||||
|
kind: 'Binary';
|
||||||
|
op: string;
|
||||||
|
left: Expr;
|
||||||
|
right: Expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssignExpr extends Range {
|
||||||
|
kind: 'Assign';
|
||||||
|
op: string; // = += -= *= /= %= ??=
|
||||||
|
target: Expr;
|
||||||
|
value: Expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParenExpr extends Range {
|
||||||
|
kind: 'Paren';
|
||||||
|
expr: Expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectMapEntry extends Range {
|
||||||
|
key: string;
|
||||||
|
keyRange: Range;
|
||||||
|
value: Expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectMapExpr extends Range {
|
||||||
|
kind: 'ObjectMap';
|
||||||
|
entries: ObjectMapEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArrayExpr extends Range {
|
||||||
|
kind: 'Array';
|
||||||
|
elements: Expr[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FnExpr extends Range {
|
||||||
|
kind: 'FnExpr';
|
||||||
|
params: Param[];
|
||||||
|
body: BlockExpr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IfExpr extends Range {
|
||||||
|
kind: 'IfExpr';
|
||||||
|
cond: Expr;
|
||||||
|
then: BlockExpr;
|
||||||
|
// else branch: either a block or another `if` for `else if` chains.
|
||||||
|
else_: BlockExpr | IfExpr | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwitchArm extends Range {
|
||||||
|
pattern: Expr | null; // null = `_` default case
|
||||||
|
guard: Expr | null;
|
||||||
|
value: Expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwitchExpr extends Range {
|
||||||
|
kind: 'SwitchExpr';
|
||||||
|
subject: Expr;
|
||||||
|
arms: SwitchArm[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlockExpr extends Range {
|
||||||
|
kind: 'BlockExpr';
|
||||||
|
stmts: Stmt[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Top-level parse output
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ParseError extends Range {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParseResult {
|
||||||
|
source: string;
|
||||||
|
program: BlockExpr;
|
||||||
|
errors: ParseError[];
|
||||||
|
comments: Comment[];
|
||||||
|
// Offsets at which the source contained a blank line (a whitespace
|
||||||
|
// run with two or more newlines). One entry per blank run; the
|
||||||
|
// formatter consults these to preserve user-intent vertical grouping.
|
||||||
|
blankLines: number[];
|
||||||
|
}
|
||||||
153
dashboard/src/lib/rhai/format.test.ts
Normal file
153
dashboard/src/lib/rhai/format.test.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { format } from './format';
|
||||||
|
|
||||||
|
function formatted(src: string): string {
|
||||||
|
const r = format(src);
|
||||||
|
if (!r.ok) throw new Error(`expected format to succeed, got: ${r.error.message}`);
|
||||||
|
return r.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('format — basic shape', () => {
|
||||||
|
it('normalizes a simple let with operator spacing', () => {
|
||||||
|
const out = formatted('let x=1+2 * 3;');
|
||||||
|
expect(out).toBe('let x = 1 + 2 * 3;\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a fn declaration with body', () => {
|
||||||
|
const out = formatted('fn process(order,user){order.total}');
|
||||||
|
expect(out).toBe(
|
||||||
|
'fn process(order, user) {\n' +
|
||||||
|
'\torder.total\n' +
|
||||||
|
'}\n'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not insert a blank between fn decls the user did not separate', () => {
|
||||||
|
// Strict preserve-only policy: no source blank => no emitted blank.
|
||||||
|
const out = formatted('fn a(){1}fn b(){2}');
|
||||||
|
expect(out).toBe('fn a() {\n\t1\n}\nfn b() {\n\t2\n}\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders if / else if / else with blocks', () => {
|
||||||
|
const out = formatted('if a{1}else if b{2}else{3}');
|
||||||
|
expect(out).toBe(
|
||||||
|
'if a {\n\t1\n} else if b {\n\t2\n} else {\n\t3\n}\n'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an object-map literal inline when short', () => {
|
||||||
|
const out = formatted('let o=#{a:1,b:2};');
|
||||||
|
expect(out).toBe('let o = #{ a: 1, b: 2 };\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders log::info as a namespace call', () => {
|
||||||
|
const out = formatted('log::info( "hi" );');
|
||||||
|
expect(out).toBe('log::info("hi");\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves comments verbatim before statements', () => {
|
||||||
|
const out = formatted('// docstring\nfn process(){1}');
|
||||||
|
expect(out).toBe(
|
||||||
|
'// docstring\nfn process() {\n\t1\n}\n'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps block comments verbatim', () => {
|
||||||
|
const out = formatted('/* keep me */ let x = 1;');
|
||||||
|
expect(out).toContain('/* keep me */');
|
||||||
|
expect(out).toContain('let x = 1;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits an empty block as `{}` without padding', () => {
|
||||||
|
const out = formatted('fn noop(){}');
|
||||||
|
expect(out).toBe('fn noop() {}\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves string literals verbatim', () => {
|
||||||
|
const out = formatted('let s = "hello\\nworld";');
|
||||||
|
expect(out).toBe('let s = "hello\\nworld";\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('format — reflow', () => {
|
||||||
|
it('reflows a long argument list onto separate lines', () => {
|
||||||
|
const src =
|
||||||
|
'process(aaaaaaaaaa, bbbbbbbbbb, cccccccccc, dddddddddd, eeeeeeeeee, ffffffffff, gggggggggg, hhhhhhhhhh);';
|
||||||
|
const out = formatted(src);
|
||||||
|
// Should contain at least one newline inside the parens (multi-line).
|
||||||
|
const callBlock = out.slice(out.indexOf('('), out.lastIndexOf(')') + 1);
|
||||||
|
expect(callBlock).toContain('\n');
|
||||||
|
expect(callBlock.endsWith(',\n)')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps short argument lists inline', () => {
|
||||||
|
const out = formatted('process(1, 2, 3);');
|
||||||
|
expect(out).toBe('process(1, 2, 3);\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('format — blank-line preservation', () => {
|
||||||
|
it('preserves a single blank line between statements', () => {
|
||||||
|
const src = 'let a = 1;\n\nlet b = 2;';
|
||||||
|
expect(formatted(src)).toBe('let a = 1;\n\nlet b = 2;\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses multiple blank lines to a single one', () => {
|
||||||
|
const src = 'let a = 1;\n\n\n\nlet b = 2;';
|
||||||
|
expect(formatted(src)).toBe('let a = 1;\n\nlet b = 2;\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves blanks inside block bodies', () => {
|
||||||
|
const src = 'fn process() {\n\tlet a = 1;\n\n\tlet b = 2;\n}';
|
||||||
|
expect(formatted(src)).toBe('fn process() {\n\tlet a = 1;\n\n\tlet b = 2;\n}\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not invent blanks between adjacent statements', () => {
|
||||||
|
expect(formatted('let a=1;let b=2;')).toBe('let a = 1;\nlet b = 2;\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('format — parse failures', () => {
|
||||||
|
it('returns ok=false with a Rhai-flavored message and 1-based line/column', () => {
|
||||||
|
// Pattern from the user complaint: `let;` should surface as
|
||||||
|
// "Expecting name of a variable" at line/column.
|
||||||
|
const r = format('let msg = ctx.request.params.name;\nlet;\n');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.error.message).toBe('Expecting name of a variable');
|
||||||
|
expect(r.error.line).toBe(2);
|
||||||
|
expect(r.error.column).toBe(4);
|
||||||
|
expect(r.error.offset).toBeGreaterThanOrEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports script-incomplete on truncated input', () => {
|
||||||
|
// `fn` alone — the parser expects a function name and hits EOF.
|
||||||
|
const r = format('fn');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.error.message).toMatch(/script is incomplete/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not partially rewrite when parsing fails', () => {
|
||||||
|
const r = format('let x = 1; this is garbage');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('format — idempotent', () => {
|
||||||
|
it('formatting twice yields the same output', () => {
|
||||||
|
const src = `
|
||||||
|
fn process(order,user) {
|
||||||
|
if order.total > 100 {
|
||||||
|
log::info("big", #{id:order.id});
|
||||||
|
} else {
|
||||||
|
log::info("small");
|
||||||
|
}
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const a = formatted(src);
|
||||||
|
const b = formatted(a);
|
||||||
|
expect(b).toBe(a);
|
||||||
|
});
|
||||||
|
});
|
||||||
479
dashboard/src/lib/rhai/format.ts
Normal file
479
dashboard/src/lib/rhai/format.ts
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
// Rhai source formatter.
|
||||||
|
//
|
||||||
|
// Parses the source, walks the AST, emits canonical text. On a parse
|
||||||
|
// failure it returns the first error and leaves the caller responsible
|
||||||
|
// for showing it (the dashboard's UX mirrors the JSON "Format" button:
|
||||||
|
// the doc is untouched and the error is surfaced inline).
|
||||||
|
//
|
||||||
|
// Choices:
|
||||||
|
// * Indent = one tab. The dashboard CSS is tab-based and the editor
|
||||||
|
// keymaps `indentWithTab`, so matching the existing convention.
|
||||||
|
// * Print width = 100 cols. If an inline-printed call's argument list
|
||||||
|
// would push the current line past 100 cols, the args reflow one
|
||||||
|
// per line with a trailing comma.
|
||||||
|
// * Comments preserved verbatim. Each comment lands on its own line at
|
||||||
|
// the indent of the statement it precedes — same-line inline
|
||||||
|
// positioning is intentionally NOT recovered; the goal is "verbatim
|
||||||
|
// text", not "byte-exact placement".
|
||||||
|
// * Blank lines between statements are preserved when the user wrote
|
||||||
|
// them; multiples collapse to one. The formatter never *adds* blank
|
||||||
|
// lines the user didn't write (rustfmt's default policy applied
|
||||||
|
// strictly — no forced separation between top-level fn decls).
|
||||||
|
// * Block bodies always use multi-line braces. `{}` for empty.
|
||||||
|
// * If parse errors are reported by the parser, the formatter refuses
|
||||||
|
// to emit anything and returns the first error with line / column
|
||||||
|
// coordinates (1-based, matching Rhai's own diagnostic format).
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BlockExpr,
|
||||||
|
Comment,
|
||||||
|
Expr,
|
||||||
|
IfExpr,
|
||||||
|
ObjectMapExpr,
|
||||||
|
ParseError,
|
||||||
|
ParseResult,
|
||||||
|
Stmt,
|
||||||
|
SwitchExpr
|
||||||
|
} from './ast';
|
||||||
|
import { parse } from './parser';
|
||||||
|
|
||||||
|
const PRINT_WIDTH = 100;
|
||||||
|
|
||||||
|
export type FormatResult =
|
||||||
|
| { ok: true; text: string }
|
||||||
|
| { ok: false; error: FormatError };
|
||||||
|
|
||||||
|
export interface FormatError {
|
||||||
|
message: string;
|
||||||
|
// 1-based line and column, matching Rhai's own diagnostic format.
|
||||||
|
line: number;
|
||||||
|
column: number;
|
||||||
|
// Byte offset retained for callers that want to jump the editor
|
||||||
|
// cursor (CodeMirror works in offsets, not line/col).
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function format(source: string): FormatResult {
|
||||||
|
const result = parse(source);
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
return { ok: false, error: errorPayload(source, result.errors[0]) };
|
||||||
|
}
|
||||||
|
const p = new Printer(result);
|
||||||
|
p.printProgram();
|
||||||
|
return { ok: true, text: p.finish() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorPayload(source: string, e: ParseError): FormatError {
|
||||||
|
const { line, column } = lineColAt(source, e.start);
|
||||||
|
return { message: e.message, line, column, offset: e.start };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a byte offset into 1-based (line, column). Used for rendering
|
||||||
|
// parser errors in a way that matches Rhai's own diagnostic format
|
||||||
|
// (e.g. "Expecting name of a variable (line 2, position 4)").
|
||||||
|
function lineColAt(source: string, offset: number): { line: number; column: number } {
|
||||||
|
let line = 1;
|
||||||
|
let lineStart = 0;
|
||||||
|
const limit = Math.min(offset, source.length);
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
if (source.charCodeAt(i) === 10) {
|
||||||
|
line++;
|
||||||
|
lineStart = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { line, column: limit - lineStart + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
class Printer {
|
||||||
|
private buf = '';
|
||||||
|
private indent = 0;
|
||||||
|
private commentPtr = 0;
|
||||||
|
|
||||||
|
constructor(private result: ParseResult) {}
|
||||||
|
|
||||||
|
finish(): string {
|
||||||
|
this.drainCommentsBefore(this.result.source.length + 1);
|
||||||
|
// Strip trailing whitespace from every line; ensure a single
|
||||||
|
// terminating newline.
|
||||||
|
const text = this.buf.replace(/[ \t]+$/gm, '').replace(/\n*$/, '\n');
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- emit
|
||||||
|
|
||||||
|
private emit(s: string): void {
|
||||||
|
this.buf += s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private newline(): void {
|
||||||
|
this.buf += '\n' + '\t'.repeat(this.indent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private blankLine(): void {
|
||||||
|
// Two newlines, but never more than that.
|
||||||
|
if (this.buf.endsWith('\n\n' + '\t'.repeat(this.indent))) return;
|
||||||
|
// Remove any trailing indent we already wrote so the blank line is
|
||||||
|
// truly blank (no stray tabs).
|
||||||
|
this.buf = this.buf.replace(/[ \t]*$/, '');
|
||||||
|
if (!this.buf.endsWith('\n')) this.buf += '\n';
|
||||||
|
this.buf += '\n' + '\t'.repeat(this.indent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private column(): number {
|
||||||
|
const last = this.buf.lastIndexOf('\n');
|
||||||
|
return this.buf.length - (last + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run `body` against a scratch buffer, return the text it would have
|
||||||
|
// appended. Useful for measuring before deciding whether to reflow.
|
||||||
|
private measure(body: () => void): string {
|
||||||
|
const prev = this.buf;
|
||||||
|
body();
|
||||||
|
const text = this.buf.slice(prev.length);
|
||||||
|
this.buf = prev;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ comments
|
||||||
|
|
||||||
|
private drainCommentsBefore(pos: number): void {
|
||||||
|
const comments = this.result.comments;
|
||||||
|
while (this.commentPtr < comments.length && comments[this.commentPtr].start < pos) {
|
||||||
|
const c = comments[this.commentPtr++];
|
||||||
|
this.emitComment(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitComment(c: Comment): void {
|
||||||
|
// Emit each comment on its own line at the current indent. For
|
||||||
|
// block comments we still keep the original text (which may span
|
||||||
|
// multiple lines) verbatim.
|
||||||
|
this.emit(c.text);
|
||||||
|
this.newline();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- program
|
||||||
|
|
||||||
|
printProgram(): void {
|
||||||
|
const stmts = this.result.program.stmts;
|
||||||
|
for (let i = 0; i < stmts.length; i++) {
|
||||||
|
const stmt = stmts[i];
|
||||||
|
if (i > 0) {
|
||||||
|
if (this.hadBlankBetween(stmts[i - 1].end, stmt.start)) this.blankLine();
|
||||||
|
else this.newline();
|
||||||
|
}
|
||||||
|
this.drainCommentsBefore(stmt.start);
|
||||||
|
this.printStmt(stmt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Did the user leave a blank line in this gap?" Consulted between
|
||||||
|
// every pair of emitted statements to decide whether to keep the
|
||||||
|
// vertical separator the source originally had.
|
||||||
|
private hadBlankBetween(prevEnd: number, currStart: number): boolean {
|
||||||
|
for (const offset of this.result.blankLines) {
|
||||||
|
if (offset >= prevEnd && offset < currStart) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------- statements
|
||||||
|
|
||||||
|
private printStmt(stmt: Stmt): void {
|
||||||
|
switch (stmt.kind) {
|
||||||
|
case 'Let':
|
||||||
|
case 'Const': {
|
||||||
|
this.emit(stmt.kind === 'Let' ? 'let ' : 'const ');
|
||||||
|
this.emit(stmt.name);
|
||||||
|
if (stmt.init) {
|
||||||
|
this.emit(' = ');
|
||||||
|
this.printExpr(stmt.init);
|
||||||
|
}
|
||||||
|
this.emit(';');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'FnDecl': {
|
||||||
|
this.emit('fn ');
|
||||||
|
this.emit(stmt.name);
|
||||||
|
this.emit('(');
|
||||||
|
this.emit(stmt.params.map((p) => p.name).join(', '));
|
||||||
|
this.emit(') ');
|
||||||
|
this.printBlock(stmt.body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'ExprStmt': {
|
||||||
|
this.printExpr(stmt.expr);
|
||||||
|
// Preserve whether the user terminated with `;`. Block-form
|
||||||
|
// expressions never take one, so suppress regardless.
|
||||||
|
if (stmt.semi && !isBlockForm(stmt.expr)) this.emit(';');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'Return': {
|
||||||
|
this.emit('return');
|
||||||
|
if (stmt.value) {
|
||||||
|
this.emit(' ');
|
||||||
|
this.printExpr(stmt.value);
|
||||||
|
}
|
||||||
|
this.emit(';');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'While': {
|
||||||
|
this.emit('while ');
|
||||||
|
this.printExpr(stmt.cond);
|
||||||
|
this.emit(' ');
|
||||||
|
this.printBlock(stmt.body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'Loop': {
|
||||||
|
this.emit('loop ');
|
||||||
|
this.printBlock(stmt.body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'For': {
|
||||||
|
this.emit('for ');
|
||||||
|
this.emit(stmt.varName);
|
||||||
|
this.emit(' in ');
|
||||||
|
this.printExpr(stmt.iter);
|
||||||
|
this.emit(' ');
|
||||||
|
this.printBlock(stmt.body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'Break':
|
||||||
|
this.emit('break;');
|
||||||
|
return;
|
||||||
|
case 'Continue':
|
||||||
|
this.emit('continue;');
|
||||||
|
return;
|
||||||
|
case 'Try': {
|
||||||
|
this.emit('try ');
|
||||||
|
this.printBlock(stmt.body);
|
||||||
|
this.emit(' catch');
|
||||||
|
if (stmt.catchVar) {
|
||||||
|
this.emit(' (');
|
||||||
|
this.emit(stmt.catchVar);
|
||||||
|
this.emit(') ');
|
||||||
|
} else {
|
||||||
|
this.emit(' ');
|
||||||
|
}
|
||||||
|
this.printBlock(stmt.handler);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private printBlock(block: BlockExpr): void {
|
||||||
|
if (block.stmts.length === 0) {
|
||||||
|
this.drainCommentsBefore(block.end);
|
||||||
|
this.emit('{}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emit('{');
|
||||||
|
this.indent++;
|
||||||
|
for (let i = 0; i < block.stmts.length; i++) {
|
||||||
|
if (i > 0 && this.hadBlankBetween(block.stmts[i - 1].end, block.stmts[i].start)) {
|
||||||
|
this.blankLine();
|
||||||
|
} else {
|
||||||
|
this.newline();
|
||||||
|
}
|
||||||
|
this.drainCommentsBefore(block.stmts[i].start);
|
||||||
|
this.printStmt(block.stmts[i]);
|
||||||
|
}
|
||||||
|
this.drainCommentsBefore(block.end);
|
||||||
|
this.indent--;
|
||||||
|
this.newline();
|
||||||
|
this.emit('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------- expressions
|
||||||
|
|
||||||
|
private printExpr(expr: Expr): void {
|
||||||
|
switch (expr.kind) {
|
||||||
|
case 'Ident':
|
||||||
|
this.emit(expr.name);
|
||||||
|
return;
|
||||||
|
case 'Number':
|
||||||
|
case 'String':
|
||||||
|
this.emit(expr.raw);
|
||||||
|
return;
|
||||||
|
case 'Bool':
|
||||||
|
this.emit(expr.value ? 'true' : 'false');
|
||||||
|
return;
|
||||||
|
case 'Null':
|
||||||
|
this.emit('null');
|
||||||
|
return;
|
||||||
|
case 'Member': {
|
||||||
|
this.printExpr(expr.object);
|
||||||
|
// `log::info` was parsed as Member(Ident(log), 'info'); restore
|
||||||
|
// the namespace separator for known namespaces. `ctx.request`
|
||||||
|
// always uses `.` because we parsed it that way.
|
||||||
|
const sep = isNamespacePath(expr.object) ? '::' : '.';
|
||||||
|
this.emit(sep);
|
||||||
|
this.emit(expr.property);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'Index':
|
||||||
|
this.printExpr(expr.object);
|
||||||
|
this.emit('[');
|
||||||
|
this.printExpr(expr.index);
|
||||||
|
this.emit(']');
|
||||||
|
return;
|
||||||
|
case 'Call':
|
||||||
|
this.printExpr(expr.callee);
|
||||||
|
this.printArgList('(', ')', expr.args);
|
||||||
|
return;
|
||||||
|
case 'Unary':
|
||||||
|
this.emit(expr.op);
|
||||||
|
this.printExpr(expr.operand);
|
||||||
|
return;
|
||||||
|
case 'Binary':
|
||||||
|
this.printExpr(expr.left);
|
||||||
|
this.emit(` ${expr.op} `);
|
||||||
|
this.printExpr(expr.right);
|
||||||
|
return;
|
||||||
|
case 'Assign':
|
||||||
|
this.printExpr(expr.target);
|
||||||
|
this.emit(` ${expr.op} `);
|
||||||
|
this.printExpr(expr.value);
|
||||||
|
return;
|
||||||
|
case 'Paren':
|
||||||
|
this.emit('(');
|
||||||
|
this.printExpr(expr.expr);
|
||||||
|
this.emit(')');
|
||||||
|
return;
|
||||||
|
case 'Array':
|
||||||
|
this.printArgList('[', ']', expr.elements);
|
||||||
|
return;
|
||||||
|
case 'ObjectMap':
|
||||||
|
this.printObjectMap(expr);
|
||||||
|
return;
|
||||||
|
case 'FnExpr':
|
||||||
|
this.emit('fn (');
|
||||||
|
this.emit(expr.params.map((p) => p.name).join(', '));
|
||||||
|
this.emit(') ');
|
||||||
|
this.printBlock(expr.body);
|
||||||
|
return;
|
||||||
|
case 'IfExpr':
|
||||||
|
this.printIf(expr);
|
||||||
|
return;
|
||||||
|
case 'SwitchExpr':
|
||||||
|
this.printSwitch(expr);
|
||||||
|
return;
|
||||||
|
case 'BlockExpr':
|
||||||
|
this.printBlock(expr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private printArgList(open: string, close: string, items: Expr[]): void {
|
||||||
|
if (items.length === 0) {
|
||||||
|
this.emit(open);
|
||||||
|
this.emit(close);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inline = this.measure(() => {
|
||||||
|
this.emit(open);
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (i > 0) this.emit(', ');
|
||||||
|
this.printExpr(items[i]);
|
||||||
|
}
|
||||||
|
this.emit(close);
|
||||||
|
});
|
||||||
|
if (!inline.includes('\n') && this.column() + inline.length <= PRINT_WIDTH) {
|
||||||
|
this.emit(inline);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emit(open);
|
||||||
|
this.indent++;
|
||||||
|
for (const item of items) {
|
||||||
|
this.newline();
|
||||||
|
this.printExpr(item);
|
||||||
|
this.emit(',');
|
||||||
|
}
|
||||||
|
this.indent--;
|
||||||
|
this.newline();
|
||||||
|
this.emit(close);
|
||||||
|
}
|
||||||
|
|
||||||
|
private printObjectMap(expr: ObjectMapExpr): void {
|
||||||
|
if (expr.entries.length === 0) {
|
||||||
|
this.emit('#{}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inline = this.measure(() => {
|
||||||
|
this.emit('#{ ');
|
||||||
|
for (let i = 0; i < expr.entries.length; i++) {
|
||||||
|
const e = expr.entries[i];
|
||||||
|
if (i > 0) this.emit(', ');
|
||||||
|
this.emit(e.key);
|
||||||
|
this.emit(': ');
|
||||||
|
this.printExpr(e.value);
|
||||||
|
}
|
||||||
|
this.emit(' }');
|
||||||
|
});
|
||||||
|
if (!inline.includes('\n') && this.column() + inline.length <= PRINT_WIDTH) {
|
||||||
|
this.emit(inline);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emit('#{');
|
||||||
|
this.indent++;
|
||||||
|
for (const e of expr.entries) {
|
||||||
|
this.newline();
|
||||||
|
this.emit(e.key);
|
||||||
|
this.emit(': ');
|
||||||
|
this.printExpr(e.value);
|
||||||
|
this.emit(',');
|
||||||
|
}
|
||||||
|
this.indent--;
|
||||||
|
this.newline();
|
||||||
|
this.emit('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
private printIf(expr: IfExpr): void {
|
||||||
|
this.emit('if ');
|
||||||
|
this.printExpr(expr.cond);
|
||||||
|
this.emit(' ');
|
||||||
|
this.printBlock(expr.then);
|
||||||
|
if (expr.else_) {
|
||||||
|
this.emit(' else ');
|
||||||
|
if (expr.else_.kind === 'IfExpr') this.printIf(expr.else_);
|
||||||
|
else this.printBlock(expr.else_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private printSwitch(expr: SwitchExpr): void {
|
||||||
|
this.emit('switch ');
|
||||||
|
this.printExpr(expr.subject);
|
||||||
|
this.emit(' {');
|
||||||
|
this.indent++;
|
||||||
|
for (const arm of expr.arms) {
|
||||||
|
this.newline();
|
||||||
|
if (arm.pattern === null) this.emit('_');
|
||||||
|
else this.printExpr(arm.pattern);
|
||||||
|
if (arm.guard) {
|
||||||
|
this.emit(' if ');
|
||||||
|
this.printExpr(arm.guard);
|
||||||
|
}
|
||||||
|
this.emit(' => ');
|
||||||
|
this.printExpr(arm.value);
|
||||||
|
this.emit(',');
|
||||||
|
}
|
||||||
|
this.indent--;
|
||||||
|
this.newline();
|
||||||
|
this.emit('}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockForm(expr: Expr): boolean {
|
||||||
|
return expr.kind === 'IfExpr' || expr.kind === 'SwitchExpr' || expr.kind === 'BlockExpr' || expr.kind === 'FnExpr';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Namespace path detection — used by `Member` printing to decide between
|
||||||
|
// `.` and `::`. Currently the only well-known namespace in scripts is
|
||||||
|
// `log`, but we generalize to any bare identifier whose name happens to
|
||||||
|
// be the namespace token. False positives are harmless (we'd render
|
||||||
|
// `something::field` for a local named `log`); the parser-side fix would
|
||||||
|
// be a dedicated `Path` node — not worth it for one keyword.
|
||||||
|
function isNamespacePath(expr: Expr): boolean {
|
||||||
|
if (expr.kind === 'Ident') return expr.name === 'log';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
18
dashboard/src/lib/rhai/index.ts
Normal file
18
dashboard/src/lib/rhai/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Public entry points for the Rhai parser package. Editor features
|
||||||
|
// import from here.
|
||||||
|
|
||||||
|
export { parse } from './parser';
|
||||||
|
export { tokenize, KEYWORDS } from './lexer';
|
||||||
|
export { buildSymbolTable, renderFnSignature } from './symbols';
|
||||||
|
export { format } from './format';
|
||||||
|
export type { FormatError, FormatResult } from './format';
|
||||||
|
export type { Decl, DeclKind, Scope, SymbolTable, Usage } from './symbols';
|
||||||
|
export type {
|
||||||
|
BlockExpr,
|
||||||
|
Comment,
|
||||||
|
Expr,
|
||||||
|
ParseError,
|
||||||
|
ParseResult,
|
||||||
|
Range,
|
||||||
|
Stmt
|
||||||
|
} from './ast';
|
||||||
73
dashboard/src/lib/rhai/lexer.test.ts
Normal file
73
dashboard/src/lib/rhai/lexer.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { tokenize } from './lexer';
|
||||||
|
|
||||||
|
function kinds(src: string): string[] {
|
||||||
|
return tokenize(src).tokens.filter((t) => t.kind !== 'EOF').map((t) => t.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function texts(src: string): string[] {
|
||||||
|
return tokenize(src).tokens.filter((t) => t.kind !== 'EOF').map((t) => t.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('lexer', () => {
|
||||||
|
it('emits an EOF for empty input', () => {
|
||||||
|
const { tokens } = tokenize('');
|
||||||
|
expect(tokens).toHaveLength(1);
|
||||||
|
expect(tokens[0].kind).toBe('EOF');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distinguishes keywords from identifiers', () => {
|
||||||
|
const { tokens } = tokenize('let foo = bar;');
|
||||||
|
expect(tokens[0]).toMatchObject({ kind: 'Keyword', text: 'let' });
|
||||||
|
expect(tokens[1]).toMatchObject({ kind: 'Ident', text: 'foo' });
|
||||||
|
expect(tokens[2]).toMatchObject({ kind: 'Operator', text: '=' });
|
||||||
|
expect(tokens[3]).toMatchObject({ kind: 'Ident', text: 'bar' });
|
||||||
|
expect(tokens[4]).toMatchObject({ kind: 'Punct', text: ';' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lexes integer, float, hex, and binary numbers', () => {
|
||||||
|
expect(texts('1 1.5 0xff 0b1010 1e10 1_000')).toEqual(['1', '1.5', '0xff', '0b1010', '1e10', '1_000']);
|
||||||
|
expect(kinds('1 1.5 0xff')).toEqual(['Number', 'Number', 'Number']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lexes double-quote and backtick strings', () => {
|
||||||
|
const { tokens } = tokenize('"hi" `world`');
|
||||||
|
expect(tokens[0]).toMatchObject({ kind: 'String', text: '"hi"' });
|
||||||
|
expect(tokens[1]).toMatchObject({ kind: 'String', text: '`world`' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves backslash escapes inside double-quoted strings', () => {
|
||||||
|
const { tokens } = tokenize('"a\\"b"');
|
||||||
|
expect(tokens[0].text).toBe('"a\\"b"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures line and block comments as comments, not tokens', () => {
|
||||||
|
const { tokens, comments } = tokenize('let x = 1; // tail\n/* block */ y');
|
||||||
|
expect(comments.map((c) => c.kind)).toEqual(['LineComment', 'BlockComment']);
|
||||||
|
expect(tokens.find((t) => t.text === '//' || t.text === '/*')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles nested block comments', () => {
|
||||||
|
const { comments } = tokenize('/* outer /* inner */ still outer */');
|
||||||
|
expect(comments).toHaveLength(1);
|
||||||
|
expect(comments[0].text).toBe('/* outer /* inner */ still outer */');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lexes multi-character operators greedily', () => {
|
||||||
|
expect(texts('a == b && c != d')).toEqual(['a', '==', 'b', '&&', 'c', '!=', 'd']);
|
||||||
|
expect(texts('a ?? b ??= c')).toEqual(['a', '??', 'b', '??=', 'c']);
|
||||||
|
expect(texts('1..=10')).toEqual(['1', '..=', '10']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes #{ as separate punctuation tokens', () => {
|
||||||
|
const { tokens } = tokenize('#{}');
|
||||||
|
expect(tokens.slice(0, 3).map((t) => t.text)).toEqual(['#', '{', '}']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records accurate byte ranges', () => {
|
||||||
|
const src = 'let abc = 42;';
|
||||||
|
const { tokens } = tokenize(src);
|
||||||
|
const abc = tokens.find((t) => t.text === 'abc')!;
|
||||||
|
expect(src.slice(abc.start, abc.end)).toBe('abc');
|
||||||
|
});
|
||||||
|
});
|
||||||
266
dashboard/src/lib/rhai/lexer.ts
Normal file
266
dashboard/src/lib/rhai/lexer.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
// Tokenizer for the dashboard's Rhai parser.
|
||||||
|
//
|
||||||
|
// Produces a flat array of tokens (eager — Rhai scripts in the dashboard
|
||||||
|
// are small, 20–200 lines typical) plus a separate list of comments. The
|
||||||
|
// parser only sees tokens; comments are handed to the formatter so it
|
||||||
|
// can re-emit them at the right positions.
|
||||||
|
//
|
||||||
|
// Keyword and operator lists trace back to the upstream TextMate grammar
|
||||||
|
// (rhaiscript/vscode-rhai). We don't copy any grammar bytes.
|
||||||
|
|
||||||
|
import type { Comment, Range } from './ast';
|
||||||
|
|
||||||
|
export type TokenKind =
|
||||||
|
| 'Ident'
|
||||||
|
| 'Keyword'
|
||||||
|
| 'Number'
|
||||||
|
| 'String'
|
||||||
|
| 'Punct'
|
||||||
|
| 'Operator'
|
||||||
|
| 'EOF';
|
||||||
|
|
||||||
|
export interface Token extends Range {
|
||||||
|
kind: TokenKind;
|
||||||
|
// For Ident/Keyword/Punct/Operator: the literal source text. For
|
||||||
|
// Number/String: the full literal including quotes.
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KEYWORDS = new Set([
|
||||||
|
'let',
|
||||||
|
'const',
|
||||||
|
'fn',
|
||||||
|
'if',
|
||||||
|
'else',
|
||||||
|
'while',
|
||||||
|
'loop',
|
||||||
|
'do',
|
||||||
|
'for',
|
||||||
|
'in',
|
||||||
|
'return',
|
||||||
|
'break',
|
||||||
|
'continue',
|
||||||
|
'switch',
|
||||||
|
'case',
|
||||||
|
'default',
|
||||||
|
'true',
|
||||||
|
'false',
|
||||||
|
'null',
|
||||||
|
'try',
|
||||||
|
'catch',
|
||||||
|
'throw',
|
||||||
|
'as',
|
||||||
|
'is',
|
||||||
|
'private'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Multi-char operators, longest first so the lexer picks them up greedily.
|
||||||
|
const MULTI_CHAR_OPS = [
|
||||||
|
'??=',
|
||||||
|
'..=',
|
||||||
|
'??',
|
||||||
|
'..',
|
||||||
|
'::',
|
||||||
|
'==',
|
||||||
|
'!=',
|
||||||
|
'<=',
|
||||||
|
'>=',
|
||||||
|
'&&',
|
||||||
|
'||',
|
||||||
|
'<<',
|
||||||
|
'>>',
|
||||||
|
'+=',
|
||||||
|
'-=',
|
||||||
|
'*=',
|
||||||
|
'/=',
|
||||||
|
'%=',
|
||||||
|
'=>',
|
||||||
|
'->'
|
||||||
|
];
|
||||||
|
|
||||||
|
const SINGLE_CHAR_OPS = new Set(['+', '-', '*', '/', '%', '<', '>', '!', '&', '|', '^', '~', '=', '?']);
|
||||||
|
|
||||||
|
// `#` is included so we can recognize the start of `#{` object-map literals;
|
||||||
|
// the lexer emits it as a separate `Punct` and the parser combines it with
|
||||||
|
// the following `{`.
|
||||||
|
const PUNCTS = new Set(['(', ')', '{', '}', '[', ']', ';', ',', '.', ':', '#']);
|
||||||
|
|
||||||
|
export interface LexResult {
|
||||||
|
tokens: Token[];
|
||||||
|
comments: Comment[];
|
||||||
|
// Offsets at which the source contained at least one blank line (a
|
||||||
|
// run of whitespace with two or more newlines). One entry per blank
|
||||||
|
// run, pointing at the second-newline position. Used by the formatter
|
||||||
|
// to preserve user-intent vertical grouping.
|
||||||
|
blankLines: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tokenize(source: string): LexResult {
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
const comments: Comment[] = [];
|
||||||
|
const blankLines: number[] = [];
|
||||||
|
let i = 0;
|
||||||
|
const n = source.length;
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
const ch = source[i];
|
||||||
|
|
||||||
|
// Whitespace — coalesce runs and record blank-line offsets.
|
||||||
|
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
|
||||||
|
let newlines = 0;
|
||||||
|
let blankAt = -1;
|
||||||
|
while (i < n) {
|
||||||
|
const c = source[i];
|
||||||
|
if (c === '\n') {
|
||||||
|
newlines++;
|
||||||
|
if (newlines === 2) blankAt = i;
|
||||||
|
} else if (c !== ' ' && c !== '\t' && c !== '\r') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (blankAt >= 0) blankLines.push(blankAt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line comment
|
||||||
|
if (ch === '/' && source[i + 1] === '/') {
|
||||||
|
const start = i;
|
||||||
|
while (i < n && source[i] !== '\n') i++;
|
||||||
|
comments.push({ kind: 'LineComment', start, end: i, text: source.slice(start, i) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block comment (supports nesting per the Rhai book)
|
||||||
|
if (ch === '/' && source[i + 1] === '*') {
|
||||||
|
const start = i;
|
||||||
|
i += 2;
|
||||||
|
let depth = 1;
|
||||||
|
while (i < n && depth > 0) {
|
||||||
|
if (source[i] === '/' && source[i + 1] === '*') {
|
||||||
|
depth++;
|
||||||
|
i += 2;
|
||||||
|
} else if (source[i] === '*' && source[i + 1] === '/') {
|
||||||
|
depth--;
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
comments.push({ kind: 'BlockComment', start, end: i, text: source.slice(start, i) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strings: " ... " (escape-aware, single-line by convention) and
|
||||||
|
// ` ... ` (raw, multi-line). We tokenize the entire literal including
|
||||||
|
// quotes; the parser only cares about its position and text.
|
||||||
|
if (ch === '"' || ch === '`') {
|
||||||
|
const quote = ch;
|
||||||
|
const start = i;
|
||||||
|
i++;
|
||||||
|
while (i < n) {
|
||||||
|
const c = source[i];
|
||||||
|
if (c === '\\' && quote === '"') {
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === quote) {
|
||||||
|
i++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
tokens.push({ kind: 'String', start, end: i, text: source.slice(start, i) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numbers: hex, binary, decimal, optional `.frac`, optional exponent.
|
||||||
|
// Underscores are allowed as digit separators per Rhai.
|
||||||
|
if (isDigit(ch)) {
|
||||||
|
const start = i;
|
||||||
|
if (ch === '0' && (source[i + 1] === 'x' || source[i + 1] === 'X')) {
|
||||||
|
i += 2;
|
||||||
|
while (i < n && (isHexDigit(source[i]) || source[i] === '_')) i++;
|
||||||
|
} else if (ch === '0' && (source[i + 1] === 'b' || source[i + 1] === 'B')) {
|
||||||
|
i += 2;
|
||||||
|
while (i < n && (source[i] === '0' || source[i] === '1' || source[i] === '_')) i++;
|
||||||
|
} else {
|
||||||
|
while (i < n && (isDigit(source[i]) || source[i] === '_')) i++;
|
||||||
|
if (source[i] === '.' && isDigit(source[i + 1])) {
|
||||||
|
i++;
|
||||||
|
while (i < n && (isDigit(source[i]) || source[i] === '_')) i++;
|
||||||
|
}
|
||||||
|
if (source[i] === 'e' || source[i] === 'E') {
|
||||||
|
i++;
|
||||||
|
if (source[i] === '+' || source[i] === '-') i++;
|
||||||
|
while (i < n && isDigit(source[i])) i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokens.push({ kind: 'Number', start, end: i, text: source.slice(start, i) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identifier or keyword
|
||||||
|
if (isIdentStart(ch)) {
|
||||||
|
const start = i;
|
||||||
|
i++;
|
||||||
|
while (i < n && isIdentCont(source[i])) i++;
|
||||||
|
const text = source.slice(start, i);
|
||||||
|
tokens.push({
|
||||||
|
kind: KEYWORDS.has(text) ? 'Keyword' : 'Ident',
|
||||||
|
start,
|
||||||
|
end: i,
|
||||||
|
text
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-char operators
|
||||||
|
let matched = false;
|
||||||
|
for (const op of MULTI_CHAR_OPS) {
|
||||||
|
if (source.startsWith(op, i)) {
|
||||||
|
tokens.push({ kind: 'Operator', start: i, end: i + op.length, text: op });
|
||||||
|
i += op.length;
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matched) continue;
|
||||||
|
|
||||||
|
// Single-char operator
|
||||||
|
if (SINGLE_CHAR_OPS.has(ch)) {
|
||||||
|
tokens.push({ kind: 'Operator', start: i, end: i + 1, text: ch });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Punctuation
|
||||||
|
if (PUNCTS.has(ch)) {
|
||||||
|
tokens.push({ kind: 'Punct', start: i, end: i + 1, text: ch });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unrecognized: skip and let the parser report the gap if needed.
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.push({ kind: 'EOF', start: n, end: n, text: '' });
|
||||||
|
return { tokens, comments, blankLines };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDigit(c: string): boolean {
|
||||||
|
return c >= '0' && c <= '9';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHexDigit(c: string): boolean {
|
||||||
|
return isDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIdentStart(c: string): boolean {
|
||||||
|
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIdentCont(c: string): boolean {
|
||||||
|
return isIdentStart(c) || isDigit(c);
|
||||||
|
}
|
||||||
126
dashboard/src/lib/rhai/parser.test.ts
Normal file
126
dashboard/src/lib/rhai/parser.test.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parse } from './parser';
|
||||||
|
import type { BinaryExpr, ExprStmt, FnDecl, LetStmt } from './ast';
|
||||||
|
|
||||||
|
describe('parser — declarations', () => {
|
||||||
|
it('parses a let binding with initializer', () => {
|
||||||
|
const { program, errors } = parse('let x = 1 + 2;');
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
expect(program.stmts).toHaveLength(1);
|
||||||
|
const let_ = program.stmts[0] as LetStmt;
|
||||||
|
expect(let_.kind).toBe('Let');
|
||||||
|
expect(let_.name).toBe('x');
|
||||||
|
expect(let_.init?.kind).toBe('Binary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a const binding', () => {
|
||||||
|
const { program, errors } = parse('const PI = 3.14;');
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
expect(program.stmts[0]).toMatchObject({ kind: 'Const', name: 'PI' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses fn declarations with parameters', () => {
|
||||||
|
const { program, errors } = parse('fn process(order, user) { order.total }');
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
const fn = program.stmts[0] as FnDecl;
|
||||||
|
expect(fn.kind).toBe('FnDecl');
|
||||||
|
expect(fn.name).toBe('process');
|
||||||
|
expect(fn.params.map((p) => p.name)).toEqual(['order', 'user']);
|
||||||
|
expect(fn.body.stmts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parser — expressions', () => {
|
||||||
|
it('respects binary precedence (* before +)', () => {
|
||||||
|
const { program } = parse('let a = 1 + 2 * 3;');
|
||||||
|
const e = (program.stmts[0] as LetStmt).init as BinaryExpr;
|
||||||
|
expect(e.kind).toBe('Binary');
|
||||||
|
expect(e.op).toBe('+');
|
||||||
|
const right = e.right as BinaryExpr;
|
||||||
|
expect(right.op).toBe('*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses method chains (member + call + index)', () => {
|
||||||
|
const { program, errors } = parse('let x = ctx.request.body["k"];');
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
const init = (program.stmts[0] as LetStmt).init!;
|
||||||
|
expect(init.kind).toBe('Index');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses log::info("hi") as Call(Member(Ident(log), "info"), ["hi"])', () => {
|
||||||
|
const { program, errors } = parse('log::info("hi");');
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
const stmt = program.stmts[0] as ExprStmt;
|
||||||
|
expect(stmt.expr.kind).toBe('Call');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses object-map literal #{} with keys', () => {
|
||||||
|
const { program, errors } = parse('let o = #{ a: 1, b: 2 };');
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
const init = (program.stmts[0] as LetStmt).init!;
|
||||||
|
expect(init.kind).toBe('ObjectMap');
|
||||||
|
if (init.kind === 'ObjectMap') {
|
||||||
|
expect(init.entries.map((e) => e.key)).toEqual(['a', 'b']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses array literals', () => {
|
||||||
|
const { program, errors } = parse('let xs = [1, 2, 3];');
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
expect((program.stmts[0] as LetStmt).init!.kind).toBe('Array');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses if-as-expression for let RHS', () => {
|
||||||
|
const { program, errors } = parse('let x = if true { 1 } else { 2 };');
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
expect((program.stmts[0] as LetStmt).init!.kind).toBe('IfExpr');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parser — control flow', () => {
|
||||||
|
it('parses while, for, loop', () => {
|
||||||
|
const { errors: e1 } = parse('while true { break; }');
|
||||||
|
const { errors: e2 } = parse('for x in [1, 2] { x }');
|
||||||
|
const { errors: e3 } = parse('loop { break; }');
|
||||||
|
expect(e1).toEqual([]);
|
||||||
|
expect(e2).toEqual([]);
|
||||||
|
expect(e3).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses if / else if / else chains', () => {
|
||||||
|
const { program, errors } = parse(`
|
||||||
|
if a { 1 } else if b { 2 } else { 3 }
|
||||||
|
`);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
const stmt = program.stmts[0] as ExprStmt;
|
||||||
|
expect(stmt.expr.kind).toBe('IfExpr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses try / catch with binding', () => {
|
||||||
|
const { errors } = parse('try { foo(); } catch (e) { log::error(e); }');
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parser — error tolerance', () => {
|
||||||
|
it('is lenient about missing semicolons between statements', () => {
|
||||||
|
// The parser accepts implicit statement separation so completions
|
||||||
|
// remain useful while the user is still typing. Both bindings
|
||||||
|
// should land in the program regardless of the missing `;`.
|
||||||
|
const { program } = parse('let x = 1 let y = 2;');
|
||||||
|
const names = program.stmts.flatMap((s) => (s.kind === 'Let' ? [s.name] : []));
|
||||||
|
expect(names).toContain('x');
|
||||||
|
expect(names).toContain('y');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not loop forever on garbage', () => {
|
||||||
|
const { errors } = parse('@@@ ### }}}');
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers after a bad statement and parses the next one', () => {
|
||||||
|
const { program } = parse('let = ; let y = 2;');
|
||||||
|
const y = program.stmts.find((s) => s.kind === 'Let' && s.name === 'y');
|
||||||
|
expect(y).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
605
dashboard/src/lib/rhai/parser.ts
Normal file
605
dashboard/src/lib/rhai/parser.ts
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
// Parser for the dashboard's Rhai mode.
|
||||||
|
//
|
||||||
|
// Recursive descent for statements, Pratt precedence climbing for
|
||||||
|
// expressions. Error-tolerant: on unexpected input the parser records an
|
||||||
|
// error, resyncs to the next `;` or matching `}`, and keeps going. The
|
||||||
|
// AST it returns is best-effort — partial trees are fine; callers
|
||||||
|
// (autocomplete, goto-def) tolerate gaps.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BlockExpr,
|
||||||
|
Expr,
|
||||||
|
FnDecl,
|
||||||
|
IfExpr,
|
||||||
|
ObjectMapEntry,
|
||||||
|
Param,
|
||||||
|
ParseError,
|
||||||
|
ParseResult,
|
||||||
|
Stmt,
|
||||||
|
SwitchArm
|
||||||
|
} from './ast';
|
||||||
|
import { tokenize, type Token, type TokenKind } from './lexer';
|
||||||
|
|
||||||
|
export function parse(source: string): ParseResult {
|
||||||
|
const { tokens, comments, blankLines } = tokenize(source);
|
||||||
|
const p = new Parser(source, tokens);
|
||||||
|
const program = p.parseProgram();
|
||||||
|
return { source, program, errors: p.errors, comments, blankLines };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precedence levels for binary operators. Higher binds tighter. Assignment
|
||||||
|
// is special-cased outside the binary chain because it's right-associative
|
||||||
|
// and only legal at the top of an expression.
|
||||||
|
const BINARY_PRECEDENCE: Record<string, number> = {
|
||||||
|
'??': 1,
|
||||||
|
'||': 2,
|
||||||
|
'&&': 3,
|
||||||
|
'==': 4,
|
||||||
|
'!=': 4,
|
||||||
|
'<': 5,
|
||||||
|
'<=': 5,
|
||||||
|
'>': 5,
|
||||||
|
'>=': 5,
|
||||||
|
'|': 6,
|
||||||
|
'^': 7,
|
||||||
|
'&': 8,
|
||||||
|
'<<': 9,
|
||||||
|
'>>': 9,
|
||||||
|
'+': 10,
|
||||||
|
'-': 10,
|
||||||
|
'*': 11,
|
||||||
|
'/': 11,
|
||||||
|
'%': 11,
|
||||||
|
'..': 12,
|
||||||
|
'..=': 12
|
||||||
|
};
|
||||||
|
|
||||||
|
const ASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '??=']);
|
||||||
|
const UNARY_OPS = new Set(['!', '-', '+', '~']);
|
||||||
|
|
||||||
|
class Parser {
|
||||||
|
pos = 0;
|
||||||
|
errors: ParseError[] = [];
|
||||||
|
constructor(
|
||||||
|
private source: string,
|
||||||
|
private tokens: Token[]
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- nav
|
||||||
|
|
||||||
|
private peek(offset = 0): Token {
|
||||||
|
return this.tokens[Math.min(this.pos + offset, this.tokens.length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private advance(): Token {
|
||||||
|
const t = this.tokens[this.pos];
|
||||||
|
if (this.pos < this.tokens.length - 1) this.pos++;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private match(kind: TokenKind, text?: string): boolean {
|
||||||
|
const t = this.peek();
|
||||||
|
if (t.kind !== kind) return false;
|
||||||
|
if (text !== undefined && t.text !== text) return false;
|
||||||
|
this.advance();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private check(kind: TokenKind, text?: string): boolean {
|
||||||
|
const t = this.peek();
|
||||||
|
if (t.kind !== kind) return false;
|
||||||
|
if (text !== undefined && t.text !== text) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `role` is a human-readable description of what was expected, used
|
||||||
|
// in place of the bare token kind so the message reads like Rhai's
|
||||||
|
// own diagnostics (`Expecting name of a variable` rather than
|
||||||
|
// `expected ident`). Falls back to the literal/kind when omitted.
|
||||||
|
private expect(kind: TokenKind, text?: string, role?: string): Token {
|
||||||
|
const t = this.peek();
|
||||||
|
if (t.kind === kind && (text === undefined || t.text === text)) {
|
||||||
|
return this.advance();
|
||||||
|
}
|
||||||
|
if (t.kind === 'EOF') {
|
||||||
|
this.error(t, role ? `Expecting ${role} — script is incomplete` : 'Script is incomplete');
|
||||||
|
} else {
|
||||||
|
const desc = role ?? (text !== undefined ? `'${text}'` : kind.toLowerCase());
|
||||||
|
this.error(t, `Expecting ${desc}`);
|
||||||
|
}
|
||||||
|
// Return the token without consuming so the caller's parent can
|
||||||
|
// still resync at its own boundary.
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private error(at: Token, message: string): void {
|
||||||
|
this.errors.push({ start: at.start, end: at.end, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resync to the next statement boundary inside the current block. Used
|
||||||
|
// when a statement fails to parse — we drop tokens until we either land
|
||||||
|
// on `;` (consumed) or `}` / EOF (left for the caller).
|
||||||
|
private resyncStmt(): void {
|
||||||
|
let depth = 0;
|
||||||
|
while (true) {
|
||||||
|
const t = this.peek();
|
||||||
|
if (t.kind === 'EOF') return;
|
||||||
|
if (t.kind === 'Punct') {
|
||||||
|
if (t.text === '{' || t.text === '(' || t.text === '[') depth++;
|
||||||
|
else if (t.text === '}' || t.text === ')' || t.text === ']') {
|
||||||
|
if (depth === 0) return;
|
||||||
|
depth--;
|
||||||
|
} else if (depth === 0 && t.text === ';') {
|
||||||
|
this.advance();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.advance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ top level
|
||||||
|
|
||||||
|
parseProgram(): BlockExpr {
|
||||||
|
const start = this.peek().start;
|
||||||
|
const stmts: Stmt[] = [];
|
||||||
|
while (this.peek().kind !== 'EOF') {
|
||||||
|
const before = this.pos;
|
||||||
|
const stmt = this.parseStmt();
|
||||||
|
if (stmt) stmts.push(stmt);
|
||||||
|
else if (this.pos === before) {
|
||||||
|
// No forward progress — drop a token to avoid an infinite loop.
|
||||||
|
this.advance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const last = this.tokens[this.tokens.length - 1];
|
||||||
|
return { kind: 'BlockExpr', start, end: last.end, stmts };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- statements
|
||||||
|
|
||||||
|
private parseStmt(): Stmt | null {
|
||||||
|
const t = this.peek();
|
||||||
|
|
||||||
|
if (t.kind === 'Keyword') {
|
||||||
|
switch (t.text) {
|
||||||
|
case 'let':
|
||||||
|
return this.parseLetOrConst('Let');
|
||||||
|
case 'const':
|
||||||
|
return this.parseLetOrConst('Const');
|
||||||
|
case 'fn':
|
||||||
|
return this.parseFnDecl();
|
||||||
|
case 'return':
|
||||||
|
return this.parseReturn();
|
||||||
|
case 'while':
|
||||||
|
return this.parseWhile();
|
||||||
|
case 'loop':
|
||||||
|
return this.parseLoop();
|
||||||
|
case 'for':
|
||||||
|
return this.parseFor();
|
||||||
|
case 'break': {
|
||||||
|
this.advance();
|
||||||
|
const semi = this.match('Punct', ';');
|
||||||
|
return { kind: 'Break', start: t.start, end: semi ? t.end + 1 : t.end };
|
||||||
|
}
|
||||||
|
case 'continue': {
|
||||||
|
this.advance();
|
||||||
|
const semi = this.match('Punct', ';');
|
||||||
|
return { kind: 'Continue', start: t.start, end: semi ? t.end + 1 : t.end };
|
||||||
|
}
|
||||||
|
case 'try':
|
||||||
|
return this.parseTry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stray semicolons are no-ops; consume and try again.
|
||||||
|
if (this.match('Punct', ';')) return null;
|
||||||
|
|
||||||
|
// Expression statement (also covers if/switch/block-as-stmt because
|
||||||
|
// those parse as expressions).
|
||||||
|
const expr = this.tryParseExpr();
|
||||||
|
if (!expr) {
|
||||||
|
const bad = this.peek();
|
||||||
|
this.error(bad, bad.kind === 'EOF' ? 'Script is incomplete' : `Unexpected token '${bad.text}'`);
|
||||||
|
this.resyncStmt();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const semi = this.match('Punct', ';');
|
||||||
|
return {
|
||||||
|
kind: 'ExprStmt',
|
||||||
|
start: expr.start,
|
||||||
|
end: semi ? this.tokens[this.pos - 1].end : expr.end,
|
||||||
|
expr,
|
||||||
|
semi
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseLetOrConst(kind: 'Let' | 'Const'): Stmt {
|
||||||
|
const start = this.advance().start; // let|const
|
||||||
|
const nameTok = this.expect('Ident', undefined, 'name of a variable');
|
||||||
|
const name = nameTok.text;
|
||||||
|
const nameRange = { start: nameTok.start, end: nameTok.end };
|
||||||
|
let init: Expr | null = null;
|
||||||
|
if (this.match('Operator', '=')) {
|
||||||
|
init = this.tryParseExpr() ?? null;
|
||||||
|
}
|
||||||
|
const semi = this.match('Punct', ';');
|
||||||
|
const end = semi ? this.tokens[this.pos - 1].end : init ? init.end : nameTok.end;
|
||||||
|
return { kind, start, end, name, nameRange, init } as Stmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseFnDecl(): FnDecl {
|
||||||
|
const start = this.advance().start; // fn
|
||||||
|
const nameTok = this.expect('Ident', undefined, 'function name in function declaration');
|
||||||
|
this.expect('Punct', '(');
|
||||||
|
const params: Param[] = [];
|
||||||
|
while (!this.check('Punct', ')') && this.peek().kind !== 'EOF') {
|
||||||
|
const pTok = this.expect('Ident', undefined, 'parameter name');
|
||||||
|
params.push({ name: pTok.text, start: pTok.start, end: pTok.end });
|
||||||
|
if (!this.match('Punct', ',')) break;
|
||||||
|
}
|
||||||
|
this.expect('Punct', ')');
|
||||||
|
const body = this.parseBlockExpr();
|
||||||
|
return {
|
||||||
|
kind: 'FnDecl',
|
||||||
|
start,
|
||||||
|
end: body.end,
|
||||||
|
name: nameTok.text,
|
||||||
|
nameRange: { start: nameTok.start, end: nameTok.end },
|
||||||
|
params,
|
||||||
|
body
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseReturn(): Stmt {
|
||||||
|
const start = this.advance().start; // return
|
||||||
|
let value: Expr | null = null;
|
||||||
|
if (!this.check('Punct', ';') && !this.check('Punct', '}') && this.peek().kind !== 'EOF') {
|
||||||
|
value = this.tryParseExpr() ?? null;
|
||||||
|
}
|
||||||
|
const semi = this.match('Punct', ';');
|
||||||
|
const end = semi ? this.tokens[this.pos - 1].end : value ? value.end : start + 'return'.length;
|
||||||
|
return { kind: 'Return', start, end, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseWhile(): Stmt {
|
||||||
|
const start = this.advance().start; // while
|
||||||
|
const cond = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
const body = this.parseBlockExpr();
|
||||||
|
return { kind: 'While', start, end: body.end, cond, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseLoop(): Stmt {
|
||||||
|
const start = this.advance().start; // loop
|
||||||
|
const body = this.parseBlockExpr();
|
||||||
|
return { kind: 'Loop', start, end: body.end, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseFor(): Stmt {
|
||||||
|
const start = this.advance().start; // for
|
||||||
|
const nameTok = this.expect('Ident', undefined, 'loop variable name');
|
||||||
|
this.expect('Keyword', 'in');
|
||||||
|
const iter = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
const body = this.parseBlockExpr();
|
||||||
|
return {
|
||||||
|
kind: 'For',
|
||||||
|
start,
|
||||||
|
end: body.end,
|
||||||
|
varName: nameTok.text,
|
||||||
|
varRange: { start: nameTok.start, end: nameTok.end },
|
||||||
|
iter,
|
||||||
|
body
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseTry(): Stmt {
|
||||||
|
const start = this.advance().start; // try
|
||||||
|
const body = this.parseBlockExpr();
|
||||||
|
this.expect('Keyword', 'catch');
|
||||||
|
let catchVar: string | null = null;
|
||||||
|
let catchVarRange: { start: number; end: number } | null = null;
|
||||||
|
if (this.match('Punct', '(')) {
|
||||||
|
if (this.check('Ident')) {
|
||||||
|
const id = this.advance();
|
||||||
|
catchVar = id.text;
|
||||||
|
catchVarRange = { start: id.start, end: id.end };
|
||||||
|
}
|
||||||
|
this.expect('Punct', ')');
|
||||||
|
}
|
||||||
|
const handler = this.parseBlockExpr();
|
||||||
|
return { kind: 'Try', start, end: handler.end, body, catchVar, catchVarRange, handler };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseBlockExpr(): BlockExpr {
|
||||||
|
const openTok = this.peek();
|
||||||
|
if (!this.match('Punct', '{')) {
|
||||||
|
this.error(openTok, "Expecting '{' to begin a block");
|
||||||
|
return { kind: 'BlockExpr', start: openTok.start, end: openTok.start, stmts: [] };
|
||||||
|
}
|
||||||
|
const start = openTok.start;
|
||||||
|
const stmts: Stmt[] = [];
|
||||||
|
while (!this.check('Punct', '}') && this.peek().kind !== 'EOF') {
|
||||||
|
const before = this.pos;
|
||||||
|
const s = this.parseStmt();
|
||||||
|
if (s) stmts.push(s);
|
||||||
|
else if (this.pos === before) this.advance();
|
||||||
|
}
|
||||||
|
const closeTok = this.peek();
|
||||||
|
this.match('Punct', '}');
|
||||||
|
return { kind: 'BlockExpr', start, end: closeTok.end, stmts };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------- expressions
|
||||||
|
|
||||||
|
private tryParseExpr(): Expr | null {
|
||||||
|
const t = this.peek();
|
||||||
|
if (t.kind === 'EOF' || (t.kind === 'Punct' && (t.text === ';' || t.text === '}' || t.text === ')' || t.text === ']' || t.text === ','))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.parseAssign();
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseAssign(): Expr {
|
||||||
|
const left = this.parseBinary(0);
|
||||||
|
const t = this.peek();
|
||||||
|
if (t.kind === 'Operator' && ASSIGN_OPS.has(t.text)) {
|
||||||
|
this.advance();
|
||||||
|
const right = this.parseAssign();
|
||||||
|
return { kind: 'Assign', start: left.start, end: right.end, op: t.text, target: left, value: right };
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseBinary(minPrec: number): Expr {
|
||||||
|
let left = this.parseUnary();
|
||||||
|
while (true) {
|
||||||
|
const t = this.peek();
|
||||||
|
if (t.kind !== 'Operator') break;
|
||||||
|
const prec = BINARY_PRECEDENCE[t.text];
|
||||||
|
if (prec === undefined || prec < minPrec) break;
|
||||||
|
this.advance();
|
||||||
|
const right = this.parseBinary(prec + 1);
|
||||||
|
left = { kind: 'Binary', start: left.start, end: right.end, op: t.text, left, right };
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseUnary(): Expr {
|
||||||
|
const t = this.peek();
|
||||||
|
if (t.kind === 'Operator' && UNARY_OPS.has(t.text)) {
|
||||||
|
this.advance();
|
||||||
|
const operand = this.parseUnary();
|
||||||
|
return { kind: 'Unary', start: t.start, end: operand.end, op: t.text, operand };
|
||||||
|
}
|
||||||
|
return this.parsePostfix(this.parsePrimary());
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePostfix(initial: Expr): Expr {
|
||||||
|
let expr = initial;
|
||||||
|
while (true) {
|
||||||
|
const t = this.peek();
|
||||||
|
if (t.kind === 'Punct' && t.text === '.') {
|
||||||
|
this.advance();
|
||||||
|
const prop = this.expect('Ident', undefined, 'name of a property');
|
||||||
|
expr = {
|
||||||
|
kind: 'Member',
|
||||||
|
start: expr.start,
|
||||||
|
end: prop.end,
|
||||||
|
object: expr,
|
||||||
|
property: prop.text,
|
||||||
|
propertyRange: { start: prop.start, end: prop.end }
|
||||||
|
};
|
||||||
|
} else if (t.kind === 'Punct' && t.text === '(') {
|
||||||
|
this.advance();
|
||||||
|
const args: Expr[] = [];
|
||||||
|
while (!this.check('Punct', ')') && this.peek().kind !== 'EOF') {
|
||||||
|
const a = this.tryParseExpr();
|
||||||
|
if (!a) break;
|
||||||
|
args.push(a);
|
||||||
|
if (!this.match('Punct', ',')) break;
|
||||||
|
}
|
||||||
|
const close = this.peek();
|
||||||
|
this.expect('Punct', ')');
|
||||||
|
expr = { kind: 'Call', start: expr.start, end: close.end, callee: expr, args };
|
||||||
|
} else if (t.kind === 'Punct' && t.text === '[') {
|
||||||
|
this.advance();
|
||||||
|
const idx = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
const close = this.peek();
|
||||||
|
this.expect('Punct', ']');
|
||||||
|
expr = { kind: 'Index', start: expr.start, end: close.end, object: expr, index: idx };
|
||||||
|
} else if (t.kind === 'Operator' && t.text === '::') {
|
||||||
|
// Namespace path: treat `log::info` as a Member chain on an
|
||||||
|
// Ident so completion and lookup can walk the same shape.
|
||||||
|
this.advance();
|
||||||
|
const next = this.expect('Ident', undefined, "name after '::'");
|
||||||
|
expr = {
|
||||||
|
kind: 'Member',
|
||||||
|
start: expr.start,
|
||||||
|
end: next.end,
|
||||||
|
object: expr,
|
||||||
|
property: next.text,
|
||||||
|
propertyRange: { start: next.start, end: next.end }
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePrimary(): Expr {
|
||||||
|
const t = this.peek();
|
||||||
|
|
||||||
|
// Literals
|
||||||
|
if (t.kind === 'Number') {
|
||||||
|
this.advance();
|
||||||
|
return { kind: 'Number', start: t.start, end: t.end, raw: t.text };
|
||||||
|
}
|
||||||
|
if (t.kind === 'String') {
|
||||||
|
this.advance();
|
||||||
|
const quote = t.text.charAt(0) === '`' ? '`' : '"';
|
||||||
|
return { kind: 'String', start: t.start, end: t.end, quote, raw: t.text };
|
||||||
|
}
|
||||||
|
if (t.kind === 'Keyword') {
|
||||||
|
if (t.text === 'true' || t.text === 'false') {
|
||||||
|
this.advance();
|
||||||
|
return { kind: 'Bool', start: t.start, end: t.end, value: t.text === 'true' };
|
||||||
|
}
|
||||||
|
if (t.text === 'null') {
|
||||||
|
this.advance();
|
||||||
|
return { kind: 'Null', start: t.start, end: t.end };
|
||||||
|
}
|
||||||
|
if (t.text === 'if') return this.parseIfExpr();
|
||||||
|
if (t.text === 'switch') return this.parseSwitchExpr();
|
||||||
|
if (t.text === 'fn') return this.parseFnExpr();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identifier
|
||||||
|
if (t.kind === 'Ident') {
|
||||||
|
this.advance();
|
||||||
|
return { kind: 'Ident', start: t.start, end: t.end, name: t.text };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paren expression
|
||||||
|
if (t.kind === 'Punct' && t.text === '(') {
|
||||||
|
this.advance();
|
||||||
|
const inner = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
const close = this.peek();
|
||||||
|
this.expect('Punct', ')');
|
||||||
|
return { kind: 'Paren', start: t.start, end: close.end, expr: inner };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array literal
|
||||||
|
if (t.kind === 'Punct' && t.text === '[') {
|
||||||
|
return this.parseArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object-map literal: `#{`
|
||||||
|
if (t.kind === 'Punct' && t.text === '#' && this.peek(1).kind === 'Punct' && this.peek(1).text === '{') {
|
||||||
|
return this.parseObjectMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block expression `{ ... }`
|
||||||
|
if (t.kind === 'Punct' && t.text === '{') {
|
||||||
|
return this.parseBlockExpr();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.error(t, t.kind === 'EOF' ? 'Script is incomplete' : `Unexpected token '${t.text}'`);
|
||||||
|
// Consume one token so we make forward progress, then return a
|
||||||
|
// placeholder so the surrounding parser keeps its shape.
|
||||||
|
this.advance();
|
||||||
|
return this.placeholderExpr(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseIfExpr(): IfExpr {
|
||||||
|
const start = this.advance().start; // if
|
||||||
|
const cond = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
const thenB = this.parseBlockExpr();
|
||||||
|
let else_: BlockExpr | IfExpr | null = null;
|
||||||
|
if (this.match('Keyword', 'else')) {
|
||||||
|
if (this.check('Keyword', 'if')) {
|
||||||
|
else_ = this.parseIfExpr();
|
||||||
|
} else {
|
||||||
|
else_ = this.parseBlockExpr();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const end = else_ ? else_.end : thenB.end;
|
||||||
|
return { kind: 'IfExpr', start, end, cond, then: thenB, else_ };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSwitchExpr(): Expr {
|
||||||
|
const start = this.advance().start; // switch
|
||||||
|
const subject = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
this.expect('Punct', '{');
|
||||||
|
const arms: SwitchArm[] = [];
|
||||||
|
while (!this.check('Punct', '}') && this.peek().kind !== 'EOF') {
|
||||||
|
const armStart = this.peek().start;
|
||||||
|
let pattern: Expr | null;
|
||||||
|
if (this.check('Operator', '_') || (this.peek().kind === 'Ident' && this.peek().text === '_')) {
|
||||||
|
this.advance();
|
||||||
|
pattern = null;
|
||||||
|
} else {
|
||||||
|
pattern = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
}
|
||||||
|
let guard: Expr | null = null;
|
||||||
|
if (this.match('Keyword', 'if')) {
|
||||||
|
guard = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
}
|
||||||
|
this.expect('Operator', '=>');
|
||||||
|
const value = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
arms.push({ start: armStart, end: value.end, pattern, guard, value });
|
||||||
|
if (!this.match('Punct', ',')) break;
|
||||||
|
}
|
||||||
|
const close = this.peek();
|
||||||
|
this.expect('Punct', '}');
|
||||||
|
return { kind: 'SwitchExpr', start, end: close.end, subject, arms };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseFnExpr(): Expr {
|
||||||
|
// `fn (params) { ... }` — anonymous function expression. Rare in
|
||||||
|
// Rhai but legal; some scripts use it for callbacks.
|
||||||
|
const start = this.advance().start; // fn
|
||||||
|
this.expect('Punct', '(');
|
||||||
|
const params: Param[] = [];
|
||||||
|
while (!this.check('Punct', ')') && this.peek().kind !== 'EOF') {
|
||||||
|
const pTok = this.expect('Ident', undefined, 'parameter name');
|
||||||
|
params.push({ name: pTok.text, start: pTok.start, end: pTok.end });
|
||||||
|
if (!this.match('Punct', ',')) break;
|
||||||
|
}
|
||||||
|
this.expect('Punct', ')');
|
||||||
|
const body = this.parseBlockExpr();
|
||||||
|
return { kind: 'FnExpr', start, end: body.end, params, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseArray(): Expr {
|
||||||
|
const start = this.advance().start; // [
|
||||||
|
const elements: Expr[] = [];
|
||||||
|
while (!this.check('Punct', ']') && this.peek().kind !== 'EOF') {
|
||||||
|
const e = this.tryParseExpr();
|
||||||
|
if (!e) break;
|
||||||
|
elements.push(e);
|
||||||
|
if (!this.match('Punct', ',')) break;
|
||||||
|
}
|
||||||
|
const close = this.peek();
|
||||||
|
this.expect('Punct', ']');
|
||||||
|
return { kind: 'Array', start, end: close.end, elements };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseObjectMap(): Expr {
|
||||||
|
const start = this.advance().start; // #
|
||||||
|
this.advance(); // {
|
||||||
|
const entries: ObjectMapEntry[] = [];
|
||||||
|
while (!this.check('Punct', '}') && this.peek().kind !== 'EOF') {
|
||||||
|
const k = this.peek();
|
||||||
|
let key: string;
|
||||||
|
let keyRange: { start: number; end: number };
|
||||||
|
if (k.kind === 'Ident' || k.kind === 'Keyword') {
|
||||||
|
this.advance();
|
||||||
|
key = k.text;
|
||||||
|
keyRange = { start: k.start, end: k.end };
|
||||||
|
} else if (k.kind === 'String') {
|
||||||
|
this.advance();
|
||||||
|
// Strip surrounding quotes for the key name (best-effort —
|
||||||
|
// we don't decode escape sequences; this is only used for
|
||||||
|
// completion labels).
|
||||||
|
key = k.text.length >= 2 ? k.text.slice(1, -1) : k.text;
|
||||||
|
keyRange = { start: k.start, end: k.end };
|
||||||
|
} else {
|
||||||
|
this.error(k, 'Expecting name of a map key');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.expect('Punct', ':');
|
||||||
|
const value = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
entries.push({ start: keyRange.start, end: value.end, key, keyRange, value });
|
||||||
|
if (!this.match('Punct', ',')) break;
|
||||||
|
}
|
||||||
|
const close = this.peek();
|
||||||
|
this.expect('Punct', '}');
|
||||||
|
return { kind: 'ObjectMap', start, end: close.end, entries };
|
||||||
|
}
|
||||||
|
|
||||||
|
private placeholderExpr(at?: Token): Expr {
|
||||||
|
const t = at ?? this.peek();
|
||||||
|
return { kind: 'Ident', start: t.start, end: t.start, name: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
119
dashboard/src/lib/rhai/symbols.test.ts
Normal file
119
dashboard/src/lib/rhai/symbols.test.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parse } from './parser';
|
||||||
|
import { buildSymbolTable } from './symbols';
|
||||||
|
|
||||||
|
function build(src: string) {
|
||||||
|
const r = parse(src);
|
||||||
|
return { ...r, table: buildSymbolTable(r) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('symbols — declarations and usages', () => {
|
||||||
|
it('captures let declarations', () => {
|
||||||
|
const { table } = build('let x = 1; x + 1;');
|
||||||
|
const x = table.allDecls.find((d) => d.name === 'x')!;
|
||||||
|
expect(x.kind).toBe('let');
|
||||||
|
expect(table.usages.find((u) => u.name === 'x')!.resolved).toBe(x);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records fn signatures for completion detail', () => {
|
||||||
|
const { table } = build('fn process(order, user) { order }');
|
||||||
|
const fn = table.allDecls.find((d) => d.name === 'process')!;
|
||||||
|
expect(fn.kind).toBe('fn');
|
||||||
|
expect(fn.signature).toBe('process(order, user)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hoists fn declarations: calls above the decl resolve', () => {
|
||||||
|
const { table } = build('greet("world"); fn greet(s) { s }');
|
||||||
|
const u = table.usages.find((u) => u.name === 'greet')!;
|
||||||
|
expect(u.resolved?.kind).toBe('fn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('function bodies do not see outer locals', () => {
|
||||||
|
const { table } = build(`
|
||||||
|
let outer = 1;
|
||||||
|
fn f() { outer }
|
||||||
|
`);
|
||||||
|
const outerUse = table.usages.find((u) => u.name === 'outer')!;
|
||||||
|
expect(outerUse.resolved).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('function bodies do see outer fn declarations', () => {
|
||||||
|
const { table } = build(`
|
||||||
|
fn helper() { 1 }
|
||||||
|
fn caller() { helper() }
|
||||||
|
`);
|
||||||
|
const helperUse = table.usages.find((u) => u.name === 'helper' && u.range.start > 30)!;
|
||||||
|
expect(helperUse.resolved?.kind).toBe('fn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures function parameters in their body scope', () => {
|
||||||
|
const { table } = build('fn f(a, b) { a + b }');
|
||||||
|
const a = table.allDecls.find((d) => d.name === 'a')!;
|
||||||
|
expect(a.kind).toBe('param');
|
||||||
|
const useOfA = table.usages.find((u) => u.name === 'a')!;
|
||||||
|
expect(useOfA.resolved).toBe(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures for-loop binders', () => {
|
||||||
|
const { table } = build('for item in [1, 2, 3] { item }');
|
||||||
|
const item = table.allDecls.find((d) => d.name === 'item')!;
|
||||||
|
expect(item.kind).toBe('for');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects forward-declaration: cannot use a let before its decl', () => {
|
||||||
|
const { table } = build('x; let x = 1;');
|
||||||
|
const earlyUse = table.usages.find((u) => u.name === 'x' && u.range.start < 5)!;
|
||||||
|
expect(earlyUse.resolved).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('symbols — object-literal field maps', () => {
|
||||||
|
it('records fields of an object-map literal initializer', () => {
|
||||||
|
const { table } = build('let order = #{ id: 1, total: 5 };');
|
||||||
|
const order = table.allDecls.find((d) => d.name === 'order')!;
|
||||||
|
expect(order.objectFields).toEqual(['id', 'total']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('objectFieldsOf returns the set after the declaration', () => {
|
||||||
|
const src = 'let order = #{ id: 1 }; order.id';
|
||||||
|
const { table } = build(src);
|
||||||
|
const afterDecl = src.indexOf('order.id') + 'order.'.length;
|
||||||
|
expect(table.objectFieldsOf('order', afterDecl)).toEqual(['id']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('symbols — completion + navigation helpers', () => {
|
||||||
|
it('scopeCompletions surfaces in-scope locals and hoisted fns', () => {
|
||||||
|
const src = `
|
||||||
|
let outer = 1;
|
||||||
|
fn process(order) {
|
||||||
|
order
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const { table } = build(src);
|
||||||
|
const insideFn = src.indexOf('order\n');
|
||||||
|
const names = table.scopeCompletions(insideFn).map((d) => d.name);
|
||||||
|
expect(names).toContain('order');
|
||||||
|
expect(names).toContain('process');
|
||||||
|
// outer is not visible from inside `fn process`.
|
||||||
|
expect(names).not.toContain('outer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declOfUsageAt resolves a usage to its declaration', () => {
|
||||||
|
const src = 'fn process(o) { o } process(1)';
|
||||||
|
const { table } = build(src);
|
||||||
|
const callPos = src.lastIndexOf('process');
|
||||||
|
const d = table.declOfUsageAt(callPos)!;
|
||||||
|
expect(d.name).toBe('process');
|
||||||
|
expect(d.kind).toBe('fn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('usagesOf collects declaration + every reference', () => {
|
||||||
|
const src = 'fn process(o) { o } process(1); process(2);';
|
||||||
|
const { table } = build(src);
|
||||||
|
const fn = table.allDecls.find((d) => d.name === 'process')!;
|
||||||
|
const all = table.usagesOf(fn);
|
||||||
|
// 1 decl name + 2 call sites = 3 ranges
|
||||||
|
expect(all).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
447
dashboard/src/lib/rhai/symbols.ts
Normal file
447
dashboard/src/lib/rhai/symbols.ts
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
// Symbol table built from the parsed AST.
|
||||||
|
//
|
||||||
|
// One walk produces everything the editor features need:
|
||||||
|
// * declarations (let, const, fn, params, for-loop binders, catch binder)
|
||||||
|
// * usages (every Ident reference, resolved by walking the scope chain)
|
||||||
|
// * object-literal field maps (so `obj.` can suggest known keys)
|
||||||
|
//
|
||||||
|
// Resolution rules (matching Rhai):
|
||||||
|
// * `fn` declarations live in the script-root scope regardless of where
|
||||||
|
// they appear textually. They form a flat namespace; nested functions
|
||||||
|
// are not allowed in standard Rhai.
|
||||||
|
// * A function body is a fresh scope that does NOT inherit the enclosing
|
||||||
|
// locals — Rhai's `fn` is a pure function, not a closure. It can
|
||||||
|
// still see top-level `fn`s (call them) but not top-level `let`s.
|
||||||
|
// * Blocks (if/while/loop/for/try) nest within their containing scope.
|
||||||
|
// * `let`/`const` are visible only after their declaration site within
|
||||||
|
// their scope.
|
||||||
|
//
|
||||||
|
// Known limit: object-literal field tracking is best-effort. We only
|
||||||
|
// record fields at the literal-initialization site — `let o = #{ a: 1 };`.
|
||||||
|
// Reassignments and member writes (`o.b = 2;`) don't update the field set.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BlockExpr,
|
||||||
|
Comment,
|
||||||
|
Expr,
|
||||||
|
ForStmt,
|
||||||
|
FnDecl,
|
||||||
|
IfExpr,
|
||||||
|
ObjectMapExpr,
|
||||||
|
ParseResult,
|
||||||
|
Range,
|
||||||
|
Stmt,
|
||||||
|
SwitchExpr,
|
||||||
|
TryStmt
|
||||||
|
} from './ast';
|
||||||
|
|
||||||
|
export type DeclKind = 'let' | 'const' | 'fn' | 'param' | 'for' | 'catch';
|
||||||
|
|
||||||
|
export interface Decl {
|
||||||
|
kind: DeclKind;
|
||||||
|
name: string;
|
||||||
|
nameRange: Range;
|
||||||
|
// For `fn`: rendered signature like `process(order, user)`.
|
||||||
|
signature?: string;
|
||||||
|
// For `let`/`const` initialized to an object-map literal — the field
|
||||||
|
// names at the literal site. Empty otherwise.
|
||||||
|
objectFields?: string[];
|
||||||
|
// Lexical visibility: the offset at which references can resolve to
|
||||||
|
// this declaration. For `let`/`const` it's just past the declaration;
|
||||||
|
// for `fn`, parameters, `for`, and `catch` binders it's the start of
|
||||||
|
// the scope they belong to.
|
||||||
|
visibleFrom: number;
|
||||||
|
scope: Scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Usage {
|
||||||
|
name: string;
|
||||||
|
range: Range;
|
||||||
|
scope: Scope;
|
||||||
|
resolved: Decl | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Scope {
|
||||||
|
id: number;
|
||||||
|
kind: 'root' | 'fn' | 'block';
|
||||||
|
// A scope's range covers the source span where its locals are
|
||||||
|
// reachable. For the root scope this is the whole document.
|
||||||
|
range: Range;
|
||||||
|
parent: Scope | null;
|
||||||
|
children: Scope[];
|
||||||
|
decls: Decl[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SymbolTable {
|
||||||
|
root: Scope;
|
||||||
|
allDecls: Decl[];
|
||||||
|
usages: Usage[];
|
||||||
|
// Public API used by the editor features.
|
||||||
|
declAt(pos: number): Decl | null;
|
||||||
|
declOfUsageAt(pos: number): Decl | null;
|
||||||
|
usagesOf(decl: Decl): Range[];
|
||||||
|
objectFieldsOf(name: string, atPos: number): string[];
|
||||||
|
scopeCompletions(atPos: number): Decl[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSymbolTable(result: ParseResult): SymbolTable {
|
||||||
|
const builder = new Builder(result);
|
||||||
|
builder.walkProgram();
|
||||||
|
builder.resolveUsages();
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cosmetic helper: format a function's signature for the completion
|
||||||
|
// `detail` field. Kept here so the symbol table is the single source of
|
||||||
|
// truth for "how a `fn` shows up in the UI".
|
||||||
|
export function renderFnSignature(fn: FnDecl): string {
|
||||||
|
return `${fn.name}(${fn.params.map((p) => p.name).join(', ')})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Builder {
|
||||||
|
allDecls: Decl[] = [];
|
||||||
|
usages: Usage[] = [];
|
||||||
|
root: Scope;
|
||||||
|
private currentScope: Scope;
|
||||||
|
private nextScopeId = 0;
|
||||||
|
|
||||||
|
constructor(private result: ParseResult) {
|
||||||
|
const span = { start: 0, end: result.source.length };
|
||||||
|
this.root = this.makeScope('root', span, null);
|
||||||
|
this.currentScope = this.root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeScope(kind: Scope['kind'], range: Range, parent: Scope | null): Scope {
|
||||||
|
const s: Scope = { id: this.nextScopeId++, kind, range, parent, children: [], decls: [] };
|
||||||
|
if (parent) parent.children.push(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private declare(d: Decl): void {
|
||||||
|
this.currentScope.decls.push(d);
|
||||||
|
this.allDecls.push(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- walk
|
||||||
|
|
||||||
|
walkProgram(): void {
|
||||||
|
// First pass: hoist `fn` decls into the root scope so calls anywhere
|
||||||
|
// in the file can resolve to them regardless of source order.
|
||||||
|
for (const stmt of this.result.program.stmts) {
|
||||||
|
if (stmt.kind === 'FnDecl') {
|
||||||
|
this.declare({
|
||||||
|
kind: 'fn',
|
||||||
|
name: stmt.name,
|
||||||
|
nameRange: stmt.nameRange,
|
||||||
|
signature: renderFnSignature(stmt),
|
||||||
|
visibleFrom: 0,
|
||||||
|
scope: this.root
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Second pass: walk statements normally. Skip re-declaring the
|
||||||
|
// already-hoisted fn names; just descend into their bodies.
|
||||||
|
for (const stmt of this.result.program.stmts) this.walkStmt(stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private walkStmt(stmt: Stmt): void {
|
||||||
|
switch (stmt.kind) {
|
||||||
|
case 'Let':
|
||||||
|
case 'Const': {
|
||||||
|
if (stmt.init) this.walkExpr(stmt.init);
|
||||||
|
const objectFields =
|
||||||
|
stmt.init && stmt.init.kind === 'ObjectMap'
|
||||||
|
? (stmt.init as ObjectMapExpr).entries.map((e) => e.key)
|
||||||
|
: undefined;
|
||||||
|
this.declare({
|
||||||
|
kind: stmt.kind === 'Let' ? 'let' : 'const',
|
||||||
|
name: stmt.name,
|
||||||
|
nameRange: stmt.nameRange,
|
||||||
|
objectFields,
|
||||||
|
visibleFrom: stmt.nameRange.end,
|
||||||
|
scope: this.currentScope
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'FnDecl': {
|
||||||
|
const prev = this.currentScope;
|
||||||
|
const fnScope = this.makeScope('fn', stmt.body, prev);
|
||||||
|
this.currentScope = fnScope;
|
||||||
|
for (const p of stmt.params) {
|
||||||
|
this.declare({
|
||||||
|
kind: 'param',
|
||||||
|
name: p.name,
|
||||||
|
nameRange: { start: p.start, end: p.end },
|
||||||
|
visibleFrom: stmt.body.start,
|
||||||
|
scope: fnScope
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const s of stmt.body.stmts) this.walkStmt(s);
|
||||||
|
this.currentScope = prev;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'ExprStmt':
|
||||||
|
this.walkExpr(stmt.expr);
|
||||||
|
return;
|
||||||
|
case 'Return':
|
||||||
|
if (stmt.value) this.walkExpr(stmt.value);
|
||||||
|
return;
|
||||||
|
case 'While':
|
||||||
|
this.walkExpr(stmt.cond);
|
||||||
|
this.walkBlock(stmt.body);
|
||||||
|
return;
|
||||||
|
case 'Loop':
|
||||||
|
this.walkBlock(stmt.body);
|
||||||
|
return;
|
||||||
|
case 'For':
|
||||||
|
this.walkFor(stmt);
|
||||||
|
return;
|
||||||
|
case 'Try':
|
||||||
|
this.walkTry(stmt);
|
||||||
|
return;
|
||||||
|
case 'Break':
|
||||||
|
case 'Continue':
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private walkFor(stmt: ForStmt): void {
|
||||||
|
this.walkExpr(stmt.iter);
|
||||||
|
const prev = this.currentScope;
|
||||||
|
const blockScope = this.makeScope('block', stmt.body, prev);
|
||||||
|
this.currentScope = blockScope;
|
||||||
|
this.declare({
|
||||||
|
kind: 'for',
|
||||||
|
name: stmt.varName,
|
||||||
|
nameRange: stmt.varRange,
|
||||||
|
visibleFrom: stmt.body.start,
|
||||||
|
scope: blockScope
|
||||||
|
});
|
||||||
|
for (const s of stmt.body.stmts) this.walkStmt(s);
|
||||||
|
this.currentScope = prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
private walkTry(stmt: TryStmt): void {
|
||||||
|
this.walkBlock(stmt.body);
|
||||||
|
const prev = this.currentScope;
|
||||||
|
const handlerScope = this.makeScope('block', stmt.handler, prev);
|
||||||
|
this.currentScope = handlerScope;
|
||||||
|
if (stmt.catchVar && stmt.catchVarRange) {
|
||||||
|
this.declare({
|
||||||
|
kind: 'catch',
|
||||||
|
name: stmt.catchVar,
|
||||||
|
nameRange: stmt.catchVarRange,
|
||||||
|
visibleFrom: stmt.handler.start,
|
||||||
|
scope: handlerScope
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const s of stmt.handler.stmts) this.walkStmt(s);
|
||||||
|
this.currentScope = prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
private walkBlock(block: BlockExpr): void {
|
||||||
|
const prev = this.currentScope;
|
||||||
|
const blockScope = this.makeScope('block', block, prev);
|
||||||
|
this.currentScope = blockScope;
|
||||||
|
for (const s of block.stmts) this.walkStmt(s);
|
||||||
|
this.currentScope = prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
private walkExpr(expr: Expr): void {
|
||||||
|
switch (expr.kind) {
|
||||||
|
case 'Ident':
|
||||||
|
if (expr.name) {
|
||||||
|
this.usages.push({
|
||||||
|
name: expr.name,
|
||||||
|
range: { start: expr.start, end: expr.end },
|
||||||
|
scope: this.currentScope,
|
||||||
|
resolved: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'Number':
|
||||||
|
case 'String':
|
||||||
|
case 'Bool':
|
||||||
|
case 'Null':
|
||||||
|
return;
|
||||||
|
case 'Call':
|
||||||
|
this.walkExpr(expr.callee);
|
||||||
|
for (const a of expr.args) this.walkExpr(a);
|
||||||
|
return;
|
||||||
|
case 'Member':
|
||||||
|
this.walkExpr(expr.object);
|
||||||
|
// We don't record the property as a usage — it's resolved
|
||||||
|
// against the object's shape, not the lexical scope.
|
||||||
|
return;
|
||||||
|
case 'Index':
|
||||||
|
this.walkExpr(expr.object);
|
||||||
|
this.walkExpr(expr.index);
|
||||||
|
return;
|
||||||
|
case 'Unary':
|
||||||
|
this.walkExpr(expr.operand);
|
||||||
|
return;
|
||||||
|
case 'Binary':
|
||||||
|
this.walkExpr(expr.left);
|
||||||
|
this.walkExpr(expr.right);
|
||||||
|
return;
|
||||||
|
case 'Assign':
|
||||||
|
this.walkExpr(expr.target);
|
||||||
|
this.walkExpr(expr.value);
|
||||||
|
return;
|
||||||
|
case 'Paren':
|
||||||
|
this.walkExpr(expr.expr);
|
||||||
|
return;
|
||||||
|
case 'ObjectMap':
|
||||||
|
for (const e of expr.entries) this.walkExpr(e.value);
|
||||||
|
return;
|
||||||
|
case 'Array':
|
||||||
|
for (const e of expr.elements) this.walkExpr(e);
|
||||||
|
return;
|
||||||
|
case 'FnExpr': {
|
||||||
|
const prev = this.currentScope;
|
||||||
|
const fnScope = this.makeScope('fn', expr.body, prev);
|
||||||
|
this.currentScope = fnScope;
|
||||||
|
for (const p of expr.params) {
|
||||||
|
this.declare({
|
||||||
|
kind: 'param',
|
||||||
|
name: p.name,
|
||||||
|
nameRange: { start: p.start, end: p.end },
|
||||||
|
visibleFrom: expr.body.start,
|
||||||
|
scope: fnScope
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const s of expr.body.stmts) this.walkStmt(s);
|
||||||
|
this.currentScope = prev;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'IfExpr':
|
||||||
|
this.walkIf(expr);
|
||||||
|
return;
|
||||||
|
case 'SwitchExpr':
|
||||||
|
this.walkSwitch(expr);
|
||||||
|
return;
|
||||||
|
case 'BlockExpr':
|
||||||
|
this.walkBlock(expr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private walkIf(expr: IfExpr): void {
|
||||||
|
this.walkExpr(expr.cond);
|
||||||
|
this.walkBlock(expr.then);
|
||||||
|
if (expr.else_) {
|
||||||
|
if (expr.else_.kind === 'IfExpr') this.walkIf(expr.else_);
|
||||||
|
else this.walkBlock(expr.else_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private walkSwitch(expr: SwitchExpr): void {
|
||||||
|
this.walkExpr(expr.subject);
|
||||||
|
for (const arm of expr.arms) {
|
||||||
|
if (arm.pattern) this.walkExpr(arm.pattern);
|
||||||
|
if (arm.guard) this.walkExpr(arm.guard);
|
||||||
|
this.walkExpr(arm.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- resolution
|
||||||
|
|
||||||
|
resolveUsages(): void {
|
||||||
|
for (const u of this.usages) {
|
||||||
|
u.resolved = this.resolveName(u.name, u.range.start, u.scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveName(name: string, atPos: number, fromScope: Scope): Decl | null {
|
||||||
|
let scope: Scope | null = fromScope;
|
||||||
|
let crossedFn = false;
|
||||||
|
while (scope) {
|
||||||
|
for (const d of scope.decls) {
|
||||||
|
if (d.name !== name) continue;
|
||||||
|
// Function scopes don't see outer locals — only the root's
|
||||||
|
// `fn` decls. So once we've crossed a fn boundary, accept
|
||||||
|
// only `fn` declarations.
|
||||||
|
if (crossedFn && d.kind !== 'fn') continue;
|
||||||
|
if (d.visibleFrom <= atPos) return d;
|
||||||
|
}
|
||||||
|
if (scope.kind === 'fn') crossedFn = true;
|
||||||
|
scope = scope.parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- finish
|
||||||
|
|
||||||
|
finish(): SymbolTable {
|
||||||
|
const { allDecls, usages, root } = this;
|
||||||
|
|
||||||
|
const declAt = (pos: number): Decl | null => {
|
||||||
|
let best: Decl | null = null;
|
||||||
|
for (const d of allDecls) {
|
||||||
|
if (pos >= d.nameRange.start && pos <= d.nameRange.end) {
|
||||||
|
best = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findScopeAt = (pos: number, scope: Scope = root): Scope => {
|
||||||
|
for (const c of scope.children) {
|
||||||
|
if (pos >= c.range.start && pos <= c.range.end) {
|
||||||
|
return findScopeAt(pos, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scope;
|
||||||
|
};
|
||||||
|
|
||||||
|
const declOfUsageAt = (pos: number): Decl | null => {
|
||||||
|
const direct = declAt(pos);
|
||||||
|
if (direct) return direct;
|
||||||
|
for (const u of usages) {
|
||||||
|
if (pos >= u.range.start && pos <= u.range.end) return u.resolved;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const usagesOf = (decl: Decl): Range[] => {
|
||||||
|
const out: Range[] = [{ start: decl.nameRange.start, end: decl.nameRange.end }];
|
||||||
|
for (const u of usages) {
|
||||||
|
if (u.resolved === decl) out.push({ start: u.range.start, end: u.range.end });
|
||||||
|
}
|
||||||
|
out.sort((a, b) => a.start - b.start);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const objectFieldsOf = (name: string, atPos: number): string[] => {
|
||||||
|
const fromScope = findScopeAt(atPos);
|
||||||
|
const d = (this as Builder).resolveName(name, atPos, fromScope);
|
||||||
|
return d?.objectFields ?? [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const scopeCompletions = (atPos: number): Decl[] => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: Decl[] = [];
|
||||||
|
let scope: Scope | null = findScopeAt(atPos);
|
||||||
|
let crossedFn = false;
|
||||||
|
while (scope) {
|
||||||
|
for (const d of scope.decls) {
|
||||||
|
if (seen.has(d.name)) continue;
|
||||||
|
if (crossedFn && d.kind !== 'fn') continue;
|
||||||
|
if (d.visibleFrom > atPos) continue;
|
||||||
|
seen.add(d.name);
|
||||||
|
out.push(d);
|
||||||
|
}
|
||||||
|
if (scope.kind === 'fn') crossedFn = true;
|
||||||
|
scope = scope.parent;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { root, allDecls, usages, declAt, declOfUsageAt, usagesOf, objectFieldsOf, scopeCompletions };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used by symbols.test.ts; harmless to export.
|
||||||
|
export function commentsAt(comments: Comment[], pos: number): Comment | undefined {
|
||||||
|
return comments.find((c) => pos >= c.start && pos < c.end);
|
||||||
|
}
|
||||||
@@ -1,17 +1,67 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { currentUser, getToken } from '$lib/auth';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
let booting = $state(true);
|
||||||
|
const user = $derived($currentUser);
|
||||||
|
|
||||||
|
const isLoginRoute = $derived(page.url.pathname.endsWith('/login'));
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// Hydrate the session: if there's a token, ask the server who we
|
||||||
|
// are. On 401 the fetch wrapper already redirects to /login and
|
||||||
|
// clears state; on success we land in the SPA fully signed in.
|
||||||
|
const tok = getToken();
|
||||||
|
if (!tok) {
|
||||||
|
if (!isLoginRoute) {
|
||||||
|
await goto(`${base}/login`);
|
||||||
|
}
|
||||||
|
booting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const me = await api.auth.me();
|
||||||
|
currentUser.set(me);
|
||||||
|
} catch {
|
||||||
|
// adminRequest handles 401 redirects. For other errors fall
|
||||||
|
// through — the page will surface its own error state.
|
||||||
|
}
|
||||||
|
booting = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await api.auth.logout();
|
||||||
|
await goto(`${base}/login`);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<header>
|
<header>
|
||||||
<a href={base + '/'} class="brand">PiCloud</a>
|
<a href={base + '/'} class="brand">PiCloud</a>
|
||||||
<nav>
|
<nav>
|
||||||
<a href={base + '/'}>Scripts</a>
|
<a href={base + '/apps'}>Apps</a>
|
||||||
|
<a href={base + '/admins'}>Admins</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
{#if user}
|
||||||
|
<div class="usermenu">
|
||||||
|
<span class="username">{user.username}</span>
|
||||||
|
<button type="button" class="logout" onclick={handleLogout}>Logout</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
{#if booting}
|
||||||
|
<p class="boot">Loading…</p>
|
||||||
|
{:else}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -45,6 +95,11 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
nav a {
|
nav a {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -55,6 +110,36 @@
|
|||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usermenu {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
@@ -63,4 +148,8 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boot {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,249 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { api, ApiError, type Script } from '$lib/api';
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
const SAMPLE_SOURCE = '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
// Dashboard entry: always lands on the apps list now (multi-app
|
||||||
|
// scoping makes "scripts at root" no longer meaningful — every
|
||||||
let scripts = $state<Script[] | null>(null);
|
// script lives inside an app).
|
||||||
let listError = $state<string | null>(null);
|
onMount(() => {
|
||||||
let loading = $state(true);
|
void goto(`${base}/apps`, { replaceState: true });
|
||||||
|
|
||||||
let showCreate = $state(false);
|
|
||||||
let createName = $state('');
|
|
||||||
let createDescription = $state('');
|
|
||||||
let createSource = $state(SAMPLE_SOURCE);
|
|
||||||
let creating = $state(false);
|
|
||||||
let createError = $state<string | null>(null);
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading = true;
|
|
||||||
listError = null;
|
|
||||||
try {
|
|
||||||
scripts = await api.scripts.list();
|
|
||||||
} catch (e) {
|
|
||||||
listError = e instanceof Error ? e.message : String(e);
|
|
||||||
scripts = null;
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitCreate(event: Event) {
|
|
||||||
event.preventDefault();
|
|
||||||
creating = true;
|
|
||||||
createError = null;
|
|
||||||
try {
|
|
||||||
await api.scripts.create({
|
|
||||||
name: createName.trim(),
|
|
||||||
description: createDescription.trim() || null,
|
|
||||||
source: createSource
|
|
||||||
});
|
|
||||||
showCreate = false;
|
|
||||||
createName = '';
|
|
||||||
createDescription = '';
|
|
||||||
createSource = SAMPLE_SOURCE;
|
|
||||||
await load();
|
|
||||||
} catch (e) {
|
|
||||||
createError = e instanceof Error ? e.message : String(e);
|
|
||||||
if (e instanceof ApiError && e.status === 422) {
|
|
||||||
createError = `Syntax error: ${createError}`;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
creating = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void load();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section>
|
<p class="muted">Redirecting…</p>
|
||||||
<header class="page-header">
|
|
||||||
<h1>Scripts</h1>
|
|
||||||
<button type="button" onclick={() => (showCreate = !showCreate)}>
|
|
||||||
{showCreate ? 'Cancel' : 'New script'}
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{#if showCreate}
|
|
||||||
<form class="create-form" onsubmit={submitCreate}>
|
|
||||||
<div class="row">
|
|
||||||
<label>
|
|
||||||
<span>Name</span>
|
|
||||||
<input bind:value={createName} required minlength="1" placeholder="echo" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Description</span>
|
|
||||||
<input bind:value={createDescription} placeholder="optional" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label class="full">
|
|
||||||
<span>Source (Rhai)</span>
|
|
||||||
<textarea bind:value={createSource} rows="10" spellcheck="false"></textarea>
|
|
||||||
</label>
|
|
||||||
{#if createError}
|
|
||||||
<div class="error">{createError}</div>
|
|
||||||
{/if}
|
|
||||||
<div class="actions">
|
|
||||||
<button type="submit" disabled={creating}>
|
|
||||||
{creating ? 'Creating…' : 'Create script'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<p class="muted">Loading…</p>
|
|
||||||
{:else if listError}
|
|
||||||
<div class="error">
|
|
||||||
<strong>Could not load scripts.</strong>
|
|
||||||
<p>{listError}</p>
|
|
||||||
<button type="button" onclick={() => void load()}>Retry</button>
|
|
||||||
</div>
|
|
||||||
{:else if scripts && scripts.length === 0}
|
|
||||||
<p class="muted">No scripts yet. Create one above to get started.</p>
|
|
||||||
{:else if scripts}
|
|
||||||
<ul class="list">
|
|
||||||
{#each scripts as script (script.id)}
|
|
||||||
<li>
|
|
||||||
<a href="{base}/scripts/{script.id}">
|
|
||||||
<div class="primary">
|
|
||||||
<strong>{script.name}</strong>
|
|
||||||
<span class="muted">v{script.version}</span>
|
|
||||||
</div>
|
|
||||||
<div class="secondary muted">
|
|
||||||
{script.description ?? '—'}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
background: #38bdf8;
|
|
||||||
color: #0b1220;
|
|
||||||
border: none;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
|
||||||
border: 1px solid #b91c1c;
|
|
||||||
background: #450a0a;
|
|
||||||
color: #fecaca;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form {
|
|
||||||
background: #1e293b;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 1.25rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form .row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 2fr;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #cbd5e1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form label.full {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form input,
|
|
||||||
.create-form textarea {
|
|
||||||
background: #0b1220;
|
|
||||||
color: #e2e8f0;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form textarea {
|
|
||||||
font-family:
|
|
||||||
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
|
|
||||||
min-height: 8rem;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list a {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
background: #1e293b;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list a:hover {
|
|
||||||
background: #283549;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
687
dashboard/src/routes/admins/+page.svelte
Normal file
687
dashboard/src/routes/admins/+page.svelte
Normal file
@@ -0,0 +1,687 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api, ApiError, type AdminUserRecord } from '$lib/api';
|
||||||
|
import { currentUser } from '$lib/auth';
|
||||||
|
|
||||||
|
let admins = $state<AdminUserRecord[]>([]);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
|
||||||
|
|
||||||
|
const me = $derived($currentUser);
|
||||||
|
|
||||||
|
let createOpen = $state(false);
|
||||||
|
let createForm = $state({ username: '', password: '', confirm: '' });
|
||||||
|
let createPending = $state(false);
|
||||||
|
let createError = $state<string | null>(null);
|
||||||
|
|
||||||
|
let passwordTarget = $state<AdminUserRecord | null>(null);
|
||||||
|
let passwordForm = $state({ password: '', confirm: '' });
|
||||||
|
let passwordPending = $state(false);
|
||||||
|
let passwordError = $state<string | null>(null);
|
||||||
|
|
||||||
|
let deleteTarget = $state<AdminUserRecord | null>(null);
|
||||||
|
let deletePending = $state(false);
|
||||||
|
|
||||||
|
let actionsOpenFor = $state<string | null>(null);
|
||||||
|
|
||||||
|
onMount(refresh);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loadError = null;
|
||||||
|
try {
|
||||||
|
admins = await api.admins.list();
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e instanceof ApiError ? e.message : 'failed to load admins';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(kind: 'error' | 'info', message: string) {
|
||||||
|
banner = { kind, message };
|
||||||
|
setTimeout(() => {
|
||||||
|
if (banner?.message === message) banner = null;
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
createForm = { username: '', password: '', confirm: '' };
|
||||||
|
createError = null;
|
||||||
|
createOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
createError = null;
|
||||||
|
if (createForm.password !== createForm.confirm) {
|
||||||
|
createError = 'Passwords do not match';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createPending = true;
|
||||||
|
try {
|
||||||
|
await api.admins.create({
|
||||||
|
username: createForm.username.trim(),
|
||||||
|
password: createForm.password
|
||||||
|
});
|
||||||
|
createOpen = false;
|
||||||
|
await refresh();
|
||||||
|
flash('info', `Created admin "${createForm.username.trim()}".`);
|
||||||
|
} catch (e) {
|
||||||
|
createError = e instanceof ApiError ? e.message : 'failed to create admin';
|
||||||
|
} finally {
|
||||||
|
createPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPassword(row: AdminUserRecord) {
|
||||||
|
passwordTarget = row;
|
||||||
|
passwordForm = { password: '', confirm: '' };
|
||||||
|
passwordError = null;
|
||||||
|
actionsOpenFor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPassword(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!passwordTarget) return;
|
||||||
|
passwordError = null;
|
||||||
|
if (passwordForm.password !== passwordForm.confirm) {
|
||||||
|
passwordError = 'Passwords do not match';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
passwordPending = true;
|
||||||
|
try {
|
||||||
|
await api.admins.update(passwordTarget.id, { password: passwordForm.password });
|
||||||
|
const name = passwordTarget.username;
|
||||||
|
passwordTarget = null;
|
||||||
|
flash('info', `Password updated for "${name}".`);
|
||||||
|
} catch (e) {
|
||||||
|
passwordError = e instanceof ApiError ? e.message : 'failed to update password';
|
||||||
|
} finally {
|
||||||
|
passwordPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActive(row: AdminUserRecord) {
|
||||||
|
actionsOpenFor = null;
|
||||||
|
try {
|
||||||
|
const updated = await api.admins.update(row.id, { is_active: !row.is_active });
|
||||||
|
admins = admins.map((a) => (a.id === updated.id ? updated : a));
|
||||||
|
flash('info', `${updated.username} ${updated.is_active ? 'reactivated' : 'deactivated'}.`);
|
||||||
|
} catch (e) {
|
||||||
|
flash('error', e instanceof ApiError ? e.message : 'failed to update admin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDelete(row: AdminUserRecord) {
|
||||||
|
deleteTarget = row;
|
||||||
|
actionsOpenFor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deletePending = true;
|
||||||
|
const target = deleteTarget;
|
||||||
|
try {
|
||||||
|
await api.admins.remove(target.id);
|
||||||
|
deleteTarget = null;
|
||||||
|
if (me && me.id === target.id) {
|
||||||
|
// Just deleted ourselves — sign out and bounce.
|
||||||
|
await api.auth.logout();
|
||||||
|
await goto(`${base}/login`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
flash('info', `Deleted "${target.username}".`);
|
||||||
|
} catch (e) {
|
||||||
|
flash('error', e instanceof ApiError ? e.message : 'failed to delete admin');
|
||||||
|
} finally {
|
||||||
|
deletePending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleActions(id: string) {
|
||||||
|
actionsOpenFor = actionsOpenFor === id ? null : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relative(iso: string | null): string {
|
||||||
|
if (!iso) return 'Never';
|
||||||
|
const then = new Date(iso).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
const sec = Math.round((now - then) / 1000);
|
||||||
|
if (sec < 60) return `${sec} second${sec === 1 ? '' : 's'} ago`;
|
||||||
|
const min = Math.round(sec / 60);
|
||||||
|
if (min < 60) return `${min} minute${min === 1 ? '' : 's'} ago`;
|
||||||
|
const hr = Math.round(min / 60);
|
||||||
|
if (hr < 24) return `${hr} hour${hr === 1 ? '' : 's'} ago`;
|
||||||
|
const day = Math.round(hr / 24);
|
||||||
|
if (day === 1) return 'Yesterday';
|
||||||
|
if (day < 7) return `${day} days ago`;
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function absolute(iso: string | null): string {
|
||||||
|
return iso ? new Date(iso).toISOString() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(iso: string): string {
|
||||||
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="head">
|
||||||
|
<h1>Admin Users</h1>
|
||||||
|
<button type="button" class="primary" onclick={openCreate}>+ New admin user</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if banner}
|
||||||
|
<div class="banner banner-{banner.kind}">{banner.message}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<div class="error">
|
||||||
|
{loadError}
|
||||||
|
<button type="button" class="retry" onclick={refresh}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if admins.length === 0}
|
||||||
|
<p class="empty">No admin users yet. Add one to get started.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="table">
|
||||||
|
<div class="row head-row">
|
||||||
|
<div>Username</div>
|
||||||
|
<div>Status</div>
|
||||||
|
<div>Created</div>
|
||||||
|
<div>Last login</div>
|
||||||
|
<div class="actions-col"></div>
|
||||||
|
</div>
|
||||||
|
{#each admins as row (row.id)}
|
||||||
|
<div class="row">
|
||||||
|
<div class="username-cell">
|
||||||
|
<span class="name">{row.username}</span>
|
||||||
|
{#if me && me.id === row.id}
|
||||||
|
<span class="you-tag">(you)</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{#if row.is_active}
|
||||||
|
<span class="status status-active">● Active</span>
|
||||||
|
{:else}
|
||||||
|
<span class="status status-inactive">○ Inactive</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div>{shortDate(row.created_at)}</div>
|
||||||
|
<div title={absolute(row.last_login_at)}>{relative(row.last_login_at)}</div>
|
||||||
|
<div class="actions-col">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="kebab"
|
||||||
|
aria-label="Actions for {row.username}"
|
||||||
|
onclick={() => toggleActions(row.id)}
|
||||||
|
>
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
{#if actionsOpenFor === row.id}
|
||||||
|
<div class="menu">
|
||||||
|
<button type="button" onclick={() => openPassword(row)}>Change password</button>
|
||||||
|
<button type="button" onclick={() => toggleActive(row)}>
|
||||||
|
{row.is_active ? 'Deactivate' : 'Reactivate'}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="danger" onclick={() => openDelete(row)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- New admin modal -->
|
||||||
|
{#if createOpen}
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) createOpen = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form class="modal" onsubmit={submitCreate}>
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>New admin user</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="x"
|
||||||
|
aria-label="Close"
|
||||||
|
onclick={() => (createOpen = false)}>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Username</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={createForm.username}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small>Lowercase letters, digits, . _ -</small>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
bind:value={createForm.password}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small>Minimum 8 characters</small>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confirm password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
bind:value={createForm.confirm}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{#if createError}
|
||||||
|
<div class="error">{createError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="ghost" onclick={() => (createOpen = false)}>Cancel</button>
|
||||||
|
<button type="submit" class="primary" disabled={createPending}>
|
||||||
|
{createPending ? 'Creating…' : 'Create user'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Change password modal -->
|
||||||
|
{#if passwordTarget}
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) passwordTarget = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form class="modal" onsubmit={submitPassword}>
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>Change password — {passwordTarget.username}</h2>
|
||||||
|
<button type="button" class="x" aria-label="Close" onclick={() => (passwordTarget = null)}
|
||||||
|
>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>New password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
bind:value={passwordForm.password}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confirm password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
bind:value={passwordForm.confirm}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{#if passwordError}
|
||||||
|
<div class="error">{passwordError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="ghost" onclick={() => (passwordTarget = null)}>Cancel</button>
|
||||||
|
<button type="submit" class="primary" disabled={passwordPending}>
|
||||||
|
{passwordPending ? 'Updating…' : 'Update'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete confirmation modal -->
|
||||||
|
{#if deleteTarget}
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) deleteTarget = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>Delete {deleteTarget.username}?</h2>
|
||||||
|
<button type="button" class="x" aria-label="Close" onclick={() => (deleteTarget = null)}
|
||||||
|
>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if me && me.id === deleteTarget.id}
|
||||||
|
<p>
|
||||||
|
You are about to delete <strong>your own</strong> account. You will be signed out immediately
|
||||||
|
and will not be able to sign back in with these credentials.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p>
|
||||||
|
This permanently removes <strong>{deleteTarget.username}</strong> and all their sessions.
|
||||||
|
This cannot be undone.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="ghost" onclick={() => (deleteTarget = null)}>Cancel</button>
|
||||||
|
<button type="button" class="danger" disabled={deletePending} onclick={confirmDelete}>
|
||||||
|
{deletePending ? 'Deleting…' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.banner-error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
.banner-info {
|
||||||
|
background: #0c2a36;
|
||||||
|
border: 1px solid #155e75;
|
||||||
|
color: #a5f3fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 0;
|
||||||
|
border: 1px dashed #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: visible;
|
||||||
|
background: #0b1220;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.5fr 0.9fr 1fr 1.2fr 3rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-row {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.you-tag {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.status-active {
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
.status-inactive {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-col {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.kebab:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 12rem;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #cbd5e1;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.menu button:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.menu button.danger {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
.menu button.danger:hover {
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
button.primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
button.ghost:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: #b91c1c;
|
||||||
|
color: #fef2f2;
|
||||||
|
border: none;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
button.danger:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.7);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
min-width: 24rem;
|
||||||
|
max-width: 28rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.x {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.x:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal label small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal input {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.modal input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
305
dashboard/src/routes/apps/+page.svelte
Normal file
305
dashboard/src/routes/apps/+page.svelte
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { api, ApiError, type App } from '$lib/api';
|
||||||
|
|
||||||
|
let apps = $state<App[] | null>(null);
|
||||||
|
let listError = $state<string | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
let showCreate = $state(false);
|
||||||
|
let createSlug = $state('');
|
||||||
|
let createName = $state('');
|
||||||
|
let createDescription = $state('');
|
||||||
|
let creating = $state(false);
|
||||||
|
let createError = $state<string | null>(null);
|
||||||
|
let createHistoricalConflict = $state<App | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
listError = null;
|
||||||
|
try {
|
||||||
|
apps = await api.apps.list();
|
||||||
|
} catch (e) {
|
||||||
|
listError = e instanceof Error ? e.message : String(e);
|
||||||
|
apps = null;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCreate() {
|
||||||
|
createSlug = '';
|
||||||
|
createName = '';
|
||||||
|
createDescription = '';
|
||||||
|
createError = null;
|
||||||
|
createHistoricalConflict = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate(event: Event, forceTakeover = false) {
|
||||||
|
event.preventDefault();
|
||||||
|
creating = true;
|
||||||
|
createError = null;
|
||||||
|
if (!forceTakeover) createHistoricalConflict = null;
|
||||||
|
try {
|
||||||
|
await api.apps.create({
|
||||||
|
slug: createSlug.trim(),
|
||||||
|
name: createName.trim(),
|
||||||
|
description: createDescription.trim() || null,
|
||||||
|
force_takeover: forceTakeover || undefined
|
||||||
|
});
|
||||||
|
showCreate = false;
|
||||||
|
resetCreate();
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 409 && e.body) {
|
||||||
|
const body = e.body as { conflict_kind?: string; current_app?: App };
|
||||||
|
if (body.conflict_kind === 'historical' && body.current_app) {
|
||||||
|
createHistoricalConflict = body.current_app;
|
||||||
|
createError = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>Apps</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
showCreate = !showCreate;
|
||||||
|
if (!showCreate) resetCreate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showCreate ? 'Cancel' : 'New app'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if showCreate}
|
||||||
|
<form class="create-form" onsubmit={(e) => submitCreate(e)}>
|
||||||
|
<div class="row">
|
||||||
|
<label>
|
||||||
|
<span>Slug</span>
|
||||||
|
<input
|
||||||
|
bind:value={createSlug}
|
||||||
|
required
|
||||||
|
pattern="[a-z0-9][a-z0-9-]*"
|
||||||
|
placeholder="my-app"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Name</span>
|
||||||
|
<input bind:value={createName} required placeholder="My App" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Description</span>
|
||||||
|
<input bind:value={createDescription} placeholder="optional" />
|
||||||
|
</label>
|
||||||
|
{#if createHistoricalConflict}
|
||||||
|
<div class="warning">
|
||||||
|
<strong>Slug previously redirected.</strong>
|
||||||
|
<p>
|
||||||
|
<code>{createSlug}</code> currently redirects to
|
||||||
|
<code>{createHistoricalConflict.slug}</code>. Using it here will break any
|
||||||
|
external links that still target the old slug.
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="secondary" onclick={() => (createHistoricalConflict = null)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={(e) => submitCreate(e, true)}
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
{creating ? 'Claiming…' : 'Claim slug anyway'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if createError}
|
||||||
|
<div class="error">{createError}</div>
|
||||||
|
{/if}
|
||||||
|
{#if !createHistoricalConflict}
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" disabled={creating}>
|
||||||
|
{creating ? 'Creating…' : 'Create app'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="muted">Loading…</p>
|
||||||
|
{:else if listError}
|
||||||
|
<div class="error">
|
||||||
|
<strong>Could not load apps.</strong>
|
||||||
|
<p>{listError}</p>
|
||||||
|
<button type="button" onclick={() => void load()}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if apps && apps.length === 0}
|
||||||
|
<p class="muted">No apps yet. Create one above to get started.</p>
|
||||||
|
{:else if apps}
|
||||||
|
<ul class="list">
|
||||||
|
{#each apps as app (app.id)}
|
||||||
|
<li>
|
||||||
|
<a href="{base}/apps/{app.slug}">
|
||||||
|
<div class="primary">
|
||||||
|
<strong>{app.name}</strong>
|
||||||
|
<span class="muted">/{app.slug}</span>
|
||||||
|
</div>
|
||||||
|
<div class="secondary muted">
|
||||||
|
{app.description ?? '—'}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
border: 1px solid #ca8a04;
|
||||||
|
background: #3f2e07;
|
||||||
|
color: #fde68a;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning code {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form {
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form .row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form input {
|
||||||
|
background: #0b1220;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list a {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list a:hover {
|
||||||
|
background: #283549;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
653
dashboard/src/routes/apps/[slug]/+page.svelte
Normal file
653
dashboard/src/routes/apps/[slug]/+page.svelte
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
ApiError,
|
||||||
|
type App,
|
||||||
|
type AppDomain,
|
||||||
|
type Script
|
||||||
|
} from '$lib/api';
|
||||||
|
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||||
|
|
||||||
|
const SAMPLE_SOURCE =
|
||||||
|
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
||||||
|
|
||||||
|
type Tab = 'scripts' | 'domains' | 'settings';
|
||||||
|
|
||||||
|
let slug = $derived(page.params.slug ?? '');
|
||||||
|
let app = $state<App | null>(null);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let activeTab = $state<Tab>('scripts');
|
||||||
|
|
||||||
|
let scripts = $state<Script[]>([]);
|
||||||
|
let domains = $state<AppDomain[]>([]);
|
||||||
|
|
||||||
|
// Script create
|
||||||
|
let showCreateScript = $state(false);
|
||||||
|
let createScriptName = $state('');
|
||||||
|
let createScriptDescription = $state('');
|
||||||
|
let createScriptSource = $state(SAMPLE_SOURCE);
|
||||||
|
let creatingScript = $state(false);
|
||||||
|
let createScriptError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Domain create
|
||||||
|
let createDomainPattern = $state('');
|
||||||
|
let creatingDomain = $state(false);
|
||||||
|
let createDomainError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
let editName = $state('');
|
||||||
|
let editDescription = $state('');
|
||||||
|
let editSlug = $state('');
|
||||||
|
let savingSettings = $state(false);
|
||||||
|
let settingsError = $state<string | null>(null);
|
||||||
|
let slugTakeoverNeeded = $state<App | null>(null);
|
||||||
|
|
||||||
|
async function loadApp() {
|
||||||
|
loading = true;
|
||||||
|
loadError = null;
|
||||||
|
try {
|
||||||
|
const fetched = await api.apps.get(slug);
|
||||||
|
if (fetched.redirect_to && fetched.redirect_to !== slug) {
|
||||||
|
await goto(`${base}/apps/${fetched.redirect_to}`, { replaceState: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app = {
|
||||||
|
id: fetched.id,
|
||||||
|
slug: fetched.slug,
|
||||||
|
name: fetched.name,
|
||||||
|
description: fetched.description,
|
||||||
|
created_at: fetched.created_at,
|
||||||
|
updated_at: fetched.updated_at
|
||||||
|
};
|
||||||
|
editName = app.name;
|
||||||
|
editDescription = app.description ?? '';
|
||||||
|
editSlug = app.slug;
|
||||||
|
await Promise.all([loadScripts(app.id), loadDomains(app.id)]);
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadScripts(appId: string) {
|
||||||
|
try {
|
||||||
|
scripts = await api.scripts.list({ app: appId });
|
||||||
|
} catch (e) {
|
||||||
|
scripts = [];
|
||||||
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDomains(appId: string) {
|
||||||
|
try {
|
||||||
|
domains = await api.domains.listForApp(appId);
|
||||||
|
} catch (e) {
|
||||||
|
domains = [];
|
||||||
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreateScript(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!app) return;
|
||||||
|
creatingScript = true;
|
||||||
|
createScriptError = null;
|
||||||
|
try {
|
||||||
|
await api.scripts.create({
|
||||||
|
app_id: app.id,
|
||||||
|
name: createScriptName.trim(),
|
||||||
|
description: createScriptDescription.trim() || null,
|
||||||
|
source: createScriptSource
|
||||||
|
});
|
||||||
|
showCreateScript = false;
|
||||||
|
createScriptName = '';
|
||||||
|
createScriptDescription = '';
|
||||||
|
createScriptSource = SAMPLE_SOURCE;
|
||||||
|
await loadScripts(app.id);
|
||||||
|
} catch (e) {
|
||||||
|
createScriptError = e instanceof Error ? e.message : String(e);
|
||||||
|
if (e instanceof ApiError && e.status === 422) {
|
||||||
|
createScriptError = `Validation: ${createScriptError}`;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
creatingScript = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreateDomain(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!app) return;
|
||||||
|
creatingDomain = true;
|
||||||
|
createDomainError = null;
|
||||||
|
try {
|
||||||
|
await api.domains.create(app.id, createDomainPattern.trim());
|
||||||
|
createDomainPattern = '';
|
||||||
|
await loadDomains(app.id);
|
||||||
|
} catch (e) {
|
||||||
|
createDomainError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
creatingDomain = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDomain(d: AppDomain) {
|
||||||
|
if (!app) return;
|
||||||
|
if (!window.confirm(`Delete domain claim ${d.pattern}?`)) return;
|
||||||
|
try {
|
||||||
|
await api.domains.remove(app.id, d.id);
|
||||||
|
await loadDomains(app.id);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings(event: Event, forceTakeover = false) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!app) return;
|
||||||
|
savingSettings = true;
|
||||||
|
settingsError = null;
|
||||||
|
if (!forceTakeover) slugTakeoverNeeded = null;
|
||||||
|
try {
|
||||||
|
const slugChanged = editSlug.trim() !== app.slug;
|
||||||
|
const updated = await api.apps.update(app.id, {
|
||||||
|
name: editName.trim() !== app.name ? editName.trim() : undefined,
|
||||||
|
description:
|
||||||
|
editDescription !== (app.description ?? '')
|
||||||
|
? editDescription || null
|
||||||
|
: undefined,
|
||||||
|
slug: slugChanged ? editSlug.trim() : undefined,
|
||||||
|
force_takeover: forceTakeover || undefined
|
||||||
|
});
|
||||||
|
if (slugChanged) {
|
||||||
|
await goto(`${base}/apps/${updated.slug}`, { replaceState: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app = updated;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 409 && e.body) {
|
||||||
|
const body = e.body as { conflict_kind?: string; current_app?: App };
|
||||||
|
if (body.conflict_kind === 'historical' && body.current_app) {
|
||||||
|
slugTakeoverNeeded = body.current_app;
|
||||||
|
settingsError = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settingsError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
savingSettings = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteApp() {
|
||||||
|
if (!app) return;
|
||||||
|
const yes = window.confirm(
|
||||||
|
`Delete app "${app.name}"? This requires zero scripts and zero domain claims.`
|
||||||
|
);
|
||||||
|
if (!yes) return;
|
||||||
|
try {
|
||||||
|
await api.apps.remove(app.id);
|
||||||
|
await goto(`${base}/apps`);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void loadApp();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading && !app}
|
||||||
|
<p class="muted">Loading…</p>
|
||||||
|
{:else if loadError && !app}
|
||||||
|
<div class="error">
|
||||||
|
<strong>Could not load app.</strong>
|
||||||
|
<p>{loadError}</p>
|
||||||
|
<a href="{base}/apps">Back to apps</a>
|
||||||
|
</div>
|
||||||
|
{:else if app}
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="{base}/apps">Apps</a> / <code>{app.slug}</code>
|
||||||
|
</div>
|
||||||
|
<h1>{app.name}</h1>
|
||||||
|
{#if app.description}<p class="muted">{app.description}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={activeTab === 'scripts'}
|
||||||
|
onclick={() => (activeTab = 'scripts')}>Scripts ({scripts.length})</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={activeTab === 'domains'}
|
||||||
|
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={activeTab === 'settings'}
|
||||||
|
onclick={() => (activeTab = 'settings')}>Settings</button
|
||||||
|
>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{#if activeTab === 'scripts'}
|
||||||
|
<section>
|
||||||
|
<div class="row">
|
||||||
|
<h2>Scripts</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showCreateScript = !showCreateScript)}
|
||||||
|
>
|
||||||
|
{showCreateScript ? 'Cancel' : 'New script'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showCreateScript}
|
||||||
|
<form class="create-form" onsubmit={submitCreateScript}>
|
||||||
|
<div class="row">
|
||||||
|
<label>
|
||||||
|
<span>Name</span>
|
||||||
|
<input bind:value={createScriptName} required placeholder="echo" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Description</span>
|
||||||
|
<input bind:value={createScriptDescription} placeholder="optional" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="full">
|
||||||
|
<span>Source (Rhai)</span>
|
||||||
|
<CodeEditor bind:value={createScriptSource} language="rhai" minHeight="14rem" />
|
||||||
|
</label>
|
||||||
|
{#if createScriptError}
|
||||||
|
<div class="error">{createScriptError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" disabled={creatingScript}>
|
||||||
|
{creatingScript ? 'Creating…' : 'Create script'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if scripts.length === 0}
|
||||||
|
<p class="muted">No scripts in this app yet.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="list">
|
||||||
|
{#each scripts as script (script.id)}
|
||||||
|
<li>
|
||||||
|
<a href="{base}/scripts/{script.id}">
|
||||||
|
<div class="primary">
|
||||||
|
<strong>{script.name}</strong>
|
||||||
|
<span class="muted">v{script.version}</span>
|
||||||
|
</div>
|
||||||
|
<div class="secondary muted">{script.description ?? '—'}</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{:else if activeTab === 'domains'}
|
||||||
|
<section>
|
||||||
|
<h2>Domain claims</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Hosts this app answers on. Routes inside this app can only bind to
|
||||||
|
these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for
|
||||||
|
wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture.
|
||||||
|
</p>
|
||||||
|
<form class="create-form inline" onsubmit={submitCreateDomain}>
|
||||||
|
<input
|
||||||
|
bind:value={createDomainPattern}
|
||||||
|
required
|
||||||
|
placeholder="app.example.com"
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={creatingDomain}>
|
||||||
|
{creatingDomain ? 'Adding…' : 'Add domain'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{#if createDomainError}
|
||||||
|
<div class="error">{createDomainError}</div>
|
||||||
|
{/if}
|
||||||
|
{#if domains.length === 0}
|
||||||
|
<p class="muted">No domain claims yet.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="list">
|
||||||
|
{#each domains as d (d.id)}
|
||||||
|
<li class="domain-row">
|
||||||
|
<div>
|
||||||
|
<code>{d.pattern}</code>
|
||||||
|
<span class="muted">— {d.shape}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary danger"
|
||||||
|
onclick={() => void removeDomain(d)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{:else if activeTab === 'settings'}
|
||||||
|
<section>
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<form class="create-form" onsubmit={(e) => saveSettings(e)}>
|
||||||
|
<label>
|
||||||
|
<span>Name</span>
|
||||||
|
<input bind:value={editName} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Description</span>
|
||||||
|
<input bind:value={editDescription} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Slug</span>
|
||||||
|
<input
|
||||||
|
bind:value={editSlug}
|
||||||
|
required
|
||||||
|
pattern="[a-z0-9][a-z0-9-]*"
|
||||||
|
/>
|
||||||
|
<small class="muted">
|
||||||
|
Renaming records the old slug as a permanent 301 redirect.
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
{#if slugTakeoverNeeded}
|
||||||
|
<div class="warning">
|
||||||
|
<strong>Slug previously redirected.</strong>
|
||||||
|
<p>
|
||||||
|
<code>{editSlug}</code> currently redirects to
|
||||||
|
<code>{slugTakeoverNeeded.slug}</code>. Renaming to it will break old
|
||||||
|
links.
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
onclick={() => (slugTakeoverNeeded = null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={(e) => saveSettings(e, true)}
|
||||||
|
disabled={savingSettings}
|
||||||
|
>
|
||||||
|
{savingSettings ? 'Renaming…' : 'Rename anyway'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if settingsError}
|
||||||
|
<div class="error">{settingsError}</div>
|
||||||
|
{/if}
|
||||||
|
{#if !slugTakeoverNeeded}
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" disabled={savingSettings}>
|
||||||
|
{savingSettings ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="danger-zone">
|
||||||
|
<h3>Delete app</h3>
|
||||||
|
<p class="muted">
|
||||||
|
Requires the app to have zero scripts and zero domain claims.
|
||||||
|
</p>
|
||||||
|
<button type="button" class="danger" onclick={deleteApp}>Delete app</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb a {
|
||||||
|
color: #94a3b8;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb a:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb code {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: none;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button.active {
|
||||||
|
color: #38bdf8;
|
||||||
|
border-bottom-color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: #7f1d1d;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary.danger {
|
||||||
|
background: transparent;
|
||||||
|
color: #fca5a5;
|
||||||
|
border-color: #7f1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
border: 1px solid #ca8a04;
|
||||||
|
background: #3f2e07;
|
||||||
|
color: #fde68a;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning code {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form {
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form.inline {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form .row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form label.full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form input {
|
||||||
|
background: #0b1220;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font: inherit;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list a {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list a:hover {
|
||||||
|
background: #283549;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-row code {
|
||||||
|
background: #0b1220;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #7f1d1d;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #1e0a0a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
172
dashboard/src/routes/login/+page.svelte
Normal file
172
dashboard/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api, ApiError } from '$lib/api';
|
||||||
|
import { getToken } from '$lib/auth';
|
||||||
|
|
||||||
|
let username = $state('');
|
||||||
|
let password = $state('');
|
||||||
|
let pending = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// Already signed in? Skip the form.
|
||||||
|
if (!getToken()) return;
|
||||||
|
try {
|
||||||
|
await api.auth.me();
|
||||||
|
await goto(`${base}/`);
|
||||||
|
} catch {
|
||||||
|
// stale token; let the form render
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
error = null;
|
||||||
|
pending = true;
|
||||||
|
try {
|
||||||
|
await api.auth.login(username, password);
|
||||||
|
await goto(`${base}/`);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'Login failed';
|
||||||
|
} finally {
|
||||||
|
pending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="login-shell">
|
||||||
|
<form class="card" onsubmit={submit}>
|
||||||
|
<h1>PiCloud</h1>
|
||||||
|
<p class="sub">Admin sign-in</p>
|
||||||
|
<label>
|
||||||
|
<span>Username</span>
|
||||||
|
<input
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
autocomplete="username"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={username}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Password</span>
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
bind:value={password}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={pending}>
|
||||||
|
{pending ? 'Signing in…' : 'Sign in →'}
|
||||||
|
</button>
|
||||||
|
{#if error}
|
||||||
|
<div class="error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
<p class="hint">
|
||||||
|
Lost access? Run <code>picloud admin reset-password <username></code> on the host.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.login-shell {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
min-width: 22rem;
|
||||||
|
max-width: 26rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #38bdf8;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -13,6 +13,19 @@
|
|||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
import { logLevelColor, statusColor } from '$lib/styles';
|
import { logLevelColor, statusColor } from '$lib/styles';
|
||||||
import { guessHostKind, guessPathKind, pathKindMismatchWarning } from '$lib/route-utils';
|
import { guessHostKind, guessPathKind, pathKindMismatchWarning } from '$lib/route-utils';
|
||||||
|
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||||
|
import { format as formatRhai } from '$lib/rhai';
|
||||||
|
|
||||||
|
/// Pretty-print a JSON string in place, leaving it untouched if the
|
||||||
|
/// input doesn't parse. The error state is shown next to the button
|
||||||
|
/// so users see why it didn't reformat.
|
||||||
|
function formatJson(s: string): { ok: true; text: string } | { ok: false; error: string } {
|
||||||
|
try {
|
||||||
|
return { ok: true, text: JSON.stringify(JSON.parse(s), null, 2) };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Route is `/scripts/[id]` so `page.params.id` is always present.
|
// Route is `/scripts/[id]` so `page.params.id` is always present.
|
||||||
let id = $derived(page.params.id ?? '');
|
let id = $derived(page.params.id ?? '');
|
||||||
@@ -25,6 +38,8 @@
|
|||||||
let scriptLoading = $state(true);
|
let scriptLoading = $state(true);
|
||||||
let info = $state<VersionInfo | null>(null);
|
let info = $state<VersionInfo | null>(null);
|
||||||
|
|
||||||
|
let appSlug = $state<string | null>(null);
|
||||||
|
|
||||||
async function loadScript() {
|
async function loadScript() {
|
||||||
scriptLoading = true;
|
scriptLoading = true;
|
||||||
scriptError = null;
|
scriptError = null;
|
||||||
@@ -35,6 +50,14 @@
|
|||||||
editableDescription = script.description ?? '';
|
editableDescription = script.description ?? '';
|
||||||
editableTimeout = script.timeout_seconds;
|
editableTimeout = script.timeout_seconds;
|
||||||
editableSandbox = { ...(script.sandbox ?? {}) };
|
editableSandbox = { ...(script.sandbox ?? {}) };
|
||||||
|
// Resolve the owning app's slug for the breadcrumb. Failure
|
||||||
|
// is non-fatal — the page works without it.
|
||||||
|
void api.apps
|
||||||
|
.get(script.app_id)
|
||||||
|
.then((a) => {
|
||||||
|
appSlug = a.slug;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
scriptError = e instanceof Error ? e.message : String(e);
|
scriptError = e instanceof Error ? e.message : String(e);
|
||||||
script = null;
|
script = null;
|
||||||
@@ -55,6 +78,17 @@
|
|||||||
let editableSource = $state('');
|
let editableSource = $state('');
|
||||||
let savingSource = $state(false);
|
let savingSource = $state(false);
|
||||||
let saveSourceError = $state<string | null>(null);
|
let saveSourceError = $state<string | null>(null);
|
||||||
|
let rhaiFormatError = $state<string | null>(null);
|
||||||
|
|
||||||
|
function formatRhaiSource() {
|
||||||
|
const r = formatRhai(editableSource);
|
||||||
|
if (r.ok) {
|
||||||
|
editableSource = r.text;
|
||||||
|
rhaiFormatError = null;
|
||||||
|
} else {
|
||||||
|
rhaiFormatError = `Parse error: ${r.error.message} (line ${r.error.line}, position ${r.error.column})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveSource() {
|
async function saveSource() {
|
||||||
if (!script) return;
|
if (!script) return;
|
||||||
@@ -72,7 +106,28 @@
|
|||||||
|
|
||||||
let testBody = $state('{}');
|
let testBody = $state('{}');
|
||||||
let testHeaders = $state('{}');
|
let testHeaders = $state('{}');
|
||||||
|
let testBodyFormatError = $state<string | null>(null);
|
||||||
|
let testHeadersFormatError = $state<string | null>(null);
|
||||||
let testInProgress = $state(false);
|
let testInProgress = $state(false);
|
||||||
|
|
||||||
|
function formatTestBody() {
|
||||||
|
const r = formatJson(testBody);
|
||||||
|
if (r.ok) {
|
||||||
|
testBody = r.text;
|
||||||
|
testBodyFormatError = null;
|
||||||
|
} else {
|
||||||
|
testBodyFormatError = r.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function formatTestHeaders() {
|
||||||
|
const r = formatJson(testHeaders);
|
||||||
|
if (r.ok) {
|
||||||
|
testHeaders = r.text;
|
||||||
|
testHeadersFormatError = null;
|
||||||
|
} else {
|
||||||
|
testHeadersFormatError = r.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
let testResult = $state<{
|
let testResult = $state<{
|
||||||
status: number;
|
status: number;
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
@@ -206,8 +261,9 @@
|
|||||||
|
|
||||||
async function runPreview() {
|
async function runPreview() {
|
||||||
previewResult = null;
|
previewResult = null;
|
||||||
|
if (!script) return;
|
||||||
try {
|
try {
|
||||||
const r = await api.routes.match(previewUrl, previewMethod);
|
const r = await api.routes.match(script.app_id, previewUrl, previewMethod);
|
||||||
if (r.matched) {
|
if (r.matched) {
|
||||||
const ours = r.matched.script_id === id;
|
const ours = r.matched.script_id === id;
|
||||||
const tag = ours ? '✓ matches THIS script' : '⚠ matches a DIFFERENT script';
|
const tag = ours ? '✓ matches THIS script' : '⚠ matches a DIFFERENT script';
|
||||||
@@ -323,6 +379,13 @@
|
|||||||
{:else if script}
|
{:else if script}
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
|
{#if appSlug}
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="{base}/apps">Apps</a> /
|
||||||
|
<a href="{base}/apps/{appSlug}">{appSlug}</a> / Scripts /
|
||||||
|
<code>{script.name}</code>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<h1>{script.name}</h1>
|
<h1>{script.name}</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
|
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
|
||||||
@@ -351,8 +414,16 @@
|
|||||||
{#if tab === 'edit'}
|
{#if tab === 'edit'}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
<header class="editor-header">
|
||||||
<h2>Source</h2>
|
<h2>Source</h2>
|
||||||
<textarea bind:value={editableSource} rows="14" spellcheck="false"></textarea>
|
<button type="button" class="ghost small" onclick={formatRhaiSource}>
|
||||||
|
Format
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<CodeEditor bind:value={editableSource} language="rhai" minHeight="22rem" />
|
||||||
|
{#if rhaiFormatError}
|
||||||
|
<div class="error inline">{rhaiFormatError}</div>
|
||||||
|
{/if}
|
||||||
{#if saveSourceError}
|
{#if saveSourceError}
|
||||||
<div class="error inline">{saveSourceError}</div>
|
<div class="error inline">{saveSourceError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -369,14 +440,30 @@
|
|||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Test invoke</h2>
|
<h2>Test invoke</h2>
|
||||||
<label>
|
<div class="json-block">
|
||||||
|
<header class="json-header">
|
||||||
<span>Request body (JSON)</span>
|
<span>Request body (JSON)</span>
|
||||||
<textarea bind:value={testBody} rows="5" spellcheck="false"></textarea>
|
<button type="button" class="ghost small" onclick={formatTestBody}>
|
||||||
</label>
|
Format
|
||||||
<label>
|
</button>
|
||||||
|
</header>
|
||||||
|
<CodeEditor bind:value={testBody} language="json" minHeight="9rem" />
|
||||||
|
{#if testBodyFormatError}
|
||||||
|
<div class="error inline">{testBodyFormatError}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="json-block">
|
||||||
|
<header class="json-header">
|
||||||
<span>Headers (JSON object)</span>
|
<span>Headers (JSON object)</span>
|
||||||
<textarea bind:value={testHeaders} rows="3" spellcheck="false"></textarea>
|
<button type="button" class="ghost small" onclick={formatTestHeaders}>
|
||||||
</label>
|
Format
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<CodeEditor bind:value={testHeaders} language="json" minHeight="6rem" />
|
||||||
|
{#if testHeadersFormatError}
|
||||||
|
<div class="error inline">{testHeadersFormatError}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" onclick={invoke} disabled={testInProgress}>
|
<button type="button" onclick={invoke} disabled={testInProgress}>
|
||||||
{testInProgress ? 'Running…' : 'Send'}
|
{testInProgress ? 'Running…' : 'Send'}
|
||||||
@@ -687,6 +774,23 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
margin: 1rem 0 1.5rem;
|
margin: 1rem 0 1.5rem;
|
||||||
}
|
}
|
||||||
|
.breadcrumb {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.breadcrumb a {
|
||||||
|
color: #94a3b8;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.breadcrumb a:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.breadcrumb code {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
@@ -727,6 +831,32 @@
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
border: 1px solid #334155;
|
border: 1px solid #334155;
|
||||||
}
|
}
|
||||||
|
button.small {
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.json-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.editor-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
button.link {
|
button.link {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
@@ -798,7 +928,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea,
|
|
||||||
input,
|
input,
|
||||||
select {
|
select {
|
||||||
background: #0b1220;
|
background: #0b1220;
|
||||||
@@ -811,12 +940,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
textarea {
|
|
||||||
font-family:
|
|
||||||
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
input:disabled,
|
input:disabled,
|
||||||
select:disabled {
|
select:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
15
dashboard/vitest.config.ts
Normal file
15
dashboard/vitest.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Vitest config for unit-testing the Rhai parser / symbol table /
|
||||||
|
// formatter. Kept separate from vite.config.ts because the dev/build
|
||||||
|
// pipeline doesn't depend on a test runner.
|
||||||
|
//
|
||||||
|
// Tests use explicit `import { describe, it, expect } from 'vitest'`
|
||||||
|
// to keep globals out of the type environment.
|
||||||
|
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['src/lib/rhai/**/*.test.ts'],
|
||||||
|
environment: 'node'
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -93,12 +93,14 @@ A versioning scheme without enforcement decays in months. Five cheap mechanical
|
|||||||
2. **Runtime self-report.** `GET /version` returns every surface version. Dashboards, monitoring, inter-service handshakes, and humans all read from one source. `/healthz` stays a plain `"ok"` string for k8s probes — version negotiation is a separate concern.
|
2. **Runtime self-report.** `GET /version` returns every surface version. Dashboards, monitoring, inter-service handshakes, and humans all read from one source. `/healthz` stays a plain `"ok"` string for k8s probes — version negotiation is a separate concern.
|
||||||
3. **Golden SDK contract tests.** `tests/sdk_contract/` Rhai scripts exercise every SDK surface and must pass on every commit. The contract is the test.
|
3. **Golden SDK contract tests.** `tests/sdk_contract/` Rhai scripts exercise every SDK surface and must pass on every commit. The contract is the test.
|
||||||
4. **Migration replay test.** An integration test that boots a fresh Postgres, applies every migration in order, and asserts the resulting schema. Catches the most common mistake (edited-not-added migration).
|
4. **Migration replay test.** An integration test that boots a fresh Postgres, applies every migration in order, and asserts the resulting schema. Catches the most common mistake (edited-not-added migration).
|
||||||
5. **CI guardrail script.** A small diff-aware check that:
|
5. **CI guardrail script.** [`scripts/check-versioning.sh`](../scripts/check-versioning.sh) — runs the structural checks that don't need git history:
|
||||||
- Fails if `SDK_VERSION`'s major changed without a `CHANGELOG.md` breaking-change entry
|
- Migration files are numbered sequentially from `0001_*.sql` with no gaps.
|
||||||
- Fails if a new file appeared in `migrations/` that isn't the next sequential number
|
- `SDK_VERSION` parses as `MAJOR.MINOR` (numeric, no extra components).
|
||||||
- Fails if a route handler removed or retyped a public field without a `BREAKING:` line in the commit message
|
- `[workspace.package].version` parses as `MAJOR.MINOR.PATCH`.
|
||||||
|
|
||||||
(3) through (5) are wired in over the next few PRs; (1) and (2) land in the same commit as this document.
|
Run manually as `bash scripts/check-versioning.sh`. Wires into CI when CI exists. Deferred to the same future PR that introduces CI: SDK-major-bump-needs-CHANGELOG and `BREAKING:` commit-message annotation (both need git history + a CHANGELOG file that doesn't exist yet).
|
||||||
|
|
||||||
|
(3) and (4) are now in place: [`crates/executor-core/tests/sdk_contract.rs`](../crates/executor-core/tests/sdk_contract.rs) holds the SDK contract suite; [`crates/manager-core/tests/schema_snapshot.rs`](../crates/manager-core/tests/schema_snapshot.rs) holds the schema snapshot guard.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -124,10 +126,10 @@ A surface can hit its own `1.0` independently of the product. The SDK in particu
|
|||||||
|
|
||||||
| | Version |
|
| | Version |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Product | `0.5.0` |
|
| Product | `0.5.1` |
|
||||||
| SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) |
|
| SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) |
|
||||||
| API | `1` |
|
| API | `1` (additive: `Script.app_id`, `Route.app_id`, `ExecutionLog.app_id`, new `/api/v1/admin/apps/*` endpoints, `?app=` filter on script list) |
|
||||||
| Schema | `3` (matches `migrations/0003_routes.sql`) |
|
| Schema | `5` (matches `migrations/0005_apps.sql`) |
|
||||||
| Wire | `1` (reserved; cluster mode not implemented) |
|
| Wire | `1` (reserved; cluster mode not implemented) |
|
||||||
|
|
||||||
Read live from `GET /version` on any running instance.
|
Read live from `GET /version` on any running instance.
|
||||||
|
|||||||
126
scripts/check-versioning.sh
Executable file
126
scripts/check-versioning.sh
Executable file
@@ -0,0 +1,126 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Versioning guardrail — runs the structural checks from
|
||||||
|
# docs/versioning.md that don't need git history. Designed to be
|
||||||
|
# called from a CI job (once we have one) and/or as a pre-commit
|
||||||
|
# step. Exits 0 if everything is in shape, non-zero on the first
|
||||||
|
# failure with a precise message.
|
||||||
|
#
|
||||||
|
# What this DOES check:
|
||||||
|
# * Migration filenames are sequential `0001_*.sql`, `0002_*.sql`,
|
||||||
|
# ... starting from 0001 with no gaps and no duplicates.
|
||||||
|
# * SDK_VERSION in shared::version parses as MAJOR.MINOR (numeric).
|
||||||
|
# * Workspace product version in Cargo.toml parses as
|
||||||
|
# MAJOR.MINOR.PATCH (numeric).
|
||||||
|
#
|
||||||
|
# What this does NOT check (deferred until we have CI + a CHANGELOG
|
||||||
|
# file):
|
||||||
|
# * Whether an SDK major bump was paired with a CHANGELOG entry.
|
||||||
|
# * Whether commits that retype public fields carry a `BREAKING:`
|
||||||
|
# annotation in the commit message.
|
||||||
|
#
|
||||||
|
# Usage: bash scripts/check-versioning.sh
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Resolve repo root from this script's location so the checks run no
|
||||||
|
# matter what working directory the caller is in.
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'check-versioning: FAIL — %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 1. Migration filenames sequential
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
MIGRATIONS_DIR="$REPO_ROOT/crates/manager-core/migrations"
|
||||||
|
[ -d "$MIGRATIONS_DIR" ] || fail "migrations dir not found at $MIGRATIONS_DIR"
|
||||||
|
|
||||||
|
i=1
|
||||||
|
for file in "$MIGRATIONS_DIR"/*.sql; do
|
||||||
|
[ -e "$file" ] || fail "no migration files found in $MIGRATIONS_DIR"
|
||||||
|
base="$(basename "$file")"
|
||||||
|
expected_prefix="$(printf '%04d_' "$i")"
|
||||||
|
case "$base" in
|
||||||
|
"$expected_prefix"*)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "migration $base is not next-in-sequence (expected ${expected_prefix}<name>.sql); migrations must be added with strictly increasing 4-digit numbers"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
printf 'check-versioning: OK — %d migration(s) numbered sequentially\n' "$((i - 1))"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 2. SDK_VERSION format
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
SDK_FILE="$REPO_ROOT/crates/shared/src/version.rs"
|
||||||
|
[ -f "$SDK_FILE" ] || fail "shared::version not found at $SDK_FILE"
|
||||||
|
|
||||||
|
SDK_VERSION="$(
|
||||||
|
awk '/^pub const SDK_VERSION/ { match($0, /"[^"]+"/); print substr($0, RSTART+1, RLENGTH-2); exit }' "$SDK_FILE"
|
||||||
|
)"
|
||||||
|
[ -n "$SDK_VERSION" ] || fail "could not parse SDK_VERSION from $SDK_FILE"
|
||||||
|
|
||||||
|
case "$SDK_VERSION" in
|
||||||
|
[0-9]*"."[0-9]*)
|
||||||
|
# Reject things like "1.2.3" or "v1.2" or empty parts.
|
||||||
|
major="${SDK_VERSION%%.*}"
|
||||||
|
minor="${SDK_VERSION#*.}"
|
||||||
|
case "$major" in
|
||||||
|
''|*[!0-9]*) fail "SDK_VERSION '$SDK_VERSION' major is not numeric" ;;
|
||||||
|
esac
|
||||||
|
case "$minor" in
|
||||||
|
''|*[!0-9]*) fail "SDK_VERSION '$SDK_VERSION' minor is not numeric (extra components?)" ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "SDK_VERSION '$SDK_VERSION' is not MAJOR.MINOR (expected e.g. '1.1')"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf 'check-versioning: OK — SDK_VERSION = %s\n' "$SDK_VERSION"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 3. Workspace product version (semver MAJOR.MINOR.PATCH)
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
ROOT_CARGO="$REPO_ROOT/Cargo.toml"
|
||||||
|
[ -f "$ROOT_CARGO" ] || fail "workspace Cargo.toml not found"
|
||||||
|
|
||||||
|
PRODUCT_VERSION="$(
|
||||||
|
awk '
|
||||||
|
/^\[workspace\.package\]/ { in_section = 1; next }
|
||||||
|
/^\[/ { in_section = 0 }
|
||||||
|
in_section && /^version *= */ {
|
||||||
|
match($0, /"[^"]+"/)
|
||||||
|
print substr($0, RSTART+1, RLENGTH-2)
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
' "$ROOT_CARGO"
|
||||||
|
)"
|
||||||
|
[ -n "$PRODUCT_VERSION" ] || fail "could not parse [workspace.package].version from $ROOT_CARGO"
|
||||||
|
|
||||||
|
case "$PRODUCT_VERSION" in
|
||||||
|
[0-9]*"."[0-9]*"."[0-9]*)
|
||||||
|
major="${PRODUCT_VERSION%%.*}"
|
||||||
|
rest="${PRODUCT_VERSION#*.}"
|
||||||
|
minor="${rest%%.*}"
|
||||||
|
patch="${rest#*.}"
|
||||||
|
for part_name in major minor patch; do
|
||||||
|
eval "part=\$$part_name"
|
||||||
|
case "$part" in
|
||||||
|
''|*[!0-9]*)
|
||||||
|
fail "product version '$PRODUCT_VERSION' has non-numeric $part_name component"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "product version '$PRODUCT_VERSION' is not MAJOR.MINOR.PATCH"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf 'check-versioning: OK — product version = %s\n' "$PRODUCT_VERSION"
|
||||||
|
|
||||||
|
printf '\ncheck-versioning: all checks passed.\n'
|
||||||
@@ -732,68 +732,363 @@ volumes:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 11.4 Admin Auth (Phase 3a) — Shipped
|
||||||
|
|
||||||
|
**Status**: shipped. Implementation lives in `crates/manager-core/src/{auth,auth_*,admin_user_repo,admin_session_repo,admin_users_api}.rs`; migration `0004_admin_auth.sql`.
|
||||||
|
|
||||||
|
**Purpose**: gate the admin API (`/api/v1/admin/*`) and dashboard (`/admin/*`) behind per-user authentication. Before this phase the surface was open — anyone reaching the bound port could create, edit, and delete scripts.
|
||||||
|
|
||||||
|
**Why per-user, not a shared secret**: shared admin passwords get shared between humans, leave no audit trail, and can't be revoked per-person. Per-user accounts solve all three. The initial cut deliberately stops there — no roles, no per-app permissions — because that scope is small enough to ship in a single phase without blocking Phase 3b. Roles + per-app permissions are queued for v1.3+.
|
||||||
|
|
||||||
|
### Naming: `admin_users` vs `users`
|
||||||
|
|
||||||
|
We reserve the unqualified **`users`** table for the v1.1+ Rhai SDK feature (script-level end users — see §8.4). Platform-operator accounts live in **`admin_users`**. They are different concepts and never share rows, even when a PiCloud install hosts apps that themselves run user management.
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE admin_users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL, -- Argon2id (PHC string)
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_login_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE admin_sessions (
|
||||||
|
token_hash TEXT PRIMARY KEY, -- SHA-256(hex) of the bearer token; raw token only exists in the login response + cookie
|
||||||
|
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX admin_sessions_user_idx ON admin_sessions (user_id);
|
||||||
|
CREATE INDEX admin_sessions_expiry_idx ON admin_sessions (expires_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
`is_active` was added to the shipped cut so admins can be deactivated (login rejected, sessions wiped) without losing audit history; deletion still cascades sessions through the FK.
|
||||||
|
|
||||||
|
**Password hashing**: Argon2id with default OWASP parameters. This also resolves the v1.1+ open question about user-password hashing (§10) — the platform settles on Argon2id once, here.
|
||||||
|
|
||||||
|
### Bootstrap
|
||||||
|
|
||||||
|
On startup, if `admin_users` is empty, the manager reads `PICLOUD_ADMIN_USERNAME` plus a password from env (or a config file) and inserts the row. Two password env vars are accepted, in this precedence:
|
||||||
|
|
||||||
|
1. **`PICLOUD_ADMIN_PASSWORD_HASH`** (recommended) — pre-computed Argon2id PHC-format hash. The platform validates the string parses, then inserts it as-is. This avoids the raw password ever being written into env/compose files or process listings.
|
||||||
|
2. **`PICLOUD_ADMIN_PASSWORD`** (fallback) — raw password. The platform hashes it with Argon2id defaults and discards the raw value. Simpler for first-time setup; less ideal for committed configs.
|
||||||
|
|
||||||
|
If both are set, the hash wins and the raw value is ignored (with a warning logged). If neither is set on a fresh install, startup fails with a clear error pointing at the env vars.
|
||||||
|
|
||||||
|
**Once that bootstrap row exists, the env vars become inert** — restarting with different values does not change the password. This is deliberate: the env var is a one-time setup hatch, not a recovery backdoor (a backdoor would let anyone with systemd-unit or compose-file access override any admin's password).
|
||||||
|
|
||||||
|
Recovery is a separate manual flow:
|
||||||
|
```sh
|
||||||
|
picloud admin reset-password <username>
|
||||||
|
```
|
||||||
|
This requires shell access on the host (and therefore implies the operator already controls the box).
|
||||||
|
|
||||||
|
### Login & Session
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/admin/auth/login
|
||||||
|
{ "username": "...", "password": "..." }
|
||||||
|
|
||||||
|
→ 200 OK
|
||||||
|
Set-Cookie: picloud_session=<token>; HttpOnly; Secure; SameSite=Lax; Path=/
|
||||||
|
{ "user": { "id": "...", "username": "..." }, "token": "<token>", "expires_at": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
Token format: opaque random string (32 bytes base64). Stored hashed; the raw value lives only in the login response and the session cookie. The same token works as a bearer credential for non-browser clients:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
One token system serves both dashboard and CLI/CI clients — no separate "API token" concept. Personal long-lived API tokens can be added later as a distinct `admin_api_tokens` table if demand appears.
|
||||||
|
|
||||||
|
**Session TTL** is a **24-hour sliding window**: each authenticated request bumps `expires_at` to `now + ttl` and `last_used_at` to `now`. The TTL itself is configurable per deploy via `PICLOUD_SESSION_TTL_HOURS` (default `24`). A separate background sweep deletes rows where `expires_at < now()`; until that sweep runs, expired rows are also rejected at auth-check time (so a stuck sweep can't extend session lifetime past expiry).
|
||||||
|
|
||||||
|
Companion endpoints:
|
||||||
|
- `POST /api/v1/admin/auth/logout` — deletes the session row.
|
||||||
|
- `GET /api/v1/admin/auth/me` — returns the current authenticated user.
|
||||||
|
|
||||||
|
### Admin User Management
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/admin/admins — list
|
||||||
|
POST /api/v1/admin/admins — create ({ username, password })
|
||||||
|
GET /api/v1/admin/admins/{id} — get
|
||||||
|
PATCH /api/v1/admin/admins/{id} — update ({ username?, password?, is_active? })
|
||||||
|
DELETE /api/v1/admin/admins/{id} — delete
|
||||||
|
```
|
||||||
|
|
||||||
|
Initial cut: every authenticated admin can call all of these. No self-elevation concerns because there are no privilege levels yet. The PATCH and DELETE handlers both refuse to leave the system with zero active admins (`422 Unprocessable Entity` with a clear message); PATCH that transitions `is_active` from true to false also wipes that user's sessions immediately.
|
||||||
|
|
||||||
|
Validation: username `^[a-z0-9._-]{2,32}$`, password minimum 8 characters (no complexity rules — follows NIST 800-63B guidance).
|
||||||
|
|
||||||
|
Dashboard surface: `/admin/login` (unauthed), `/admin/admins` (user list with add / change-password / deactivate / reactivate / delete actions per row). The top-bar shows the logged-in admin and a logout button. Token is held in a Svelte store with a localStorage echo so a page refresh doesn't sign you out; cookie-based auth works in parallel for non-SPA browser hits.
|
||||||
|
|
||||||
|
### Forward Compatibility
|
||||||
|
|
||||||
|
Schema is intentionally simple so role/permission tables can be added without touching `admin_users`. Illustrative future shape:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE admin_roles (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE -- e.g., 'super_admin', 'app_editor', 'app_viewer'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE admin_user_roles (
|
||||||
|
admin_user_id UUID REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
role_id UUID REFERENCES admin_roles(id) ON DELETE RESTRICT,
|
||||||
|
app_id UUID REFERENCES apps(id) ON DELETE CASCADE, -- nullable for global roles
|
||||||
|
PRIMARY KEY (admin_user_id, role_id, app_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Permission checks land in middleware that initially only enforces "authenticated"; the same middleware is the seam where role checks slot in later. Don't pre-build the role tables — but keep the middleware shape such that adding them is a localized change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
**Why this slot**: pulled forward from the original v1.3+ "multi-user / project namespacing" bullet. Adding the `app_id` scoping dimension to schemas while the surface is small is cheap; retrofitting it after KV, docs, users, etc. ship is a multi-table migration on populated data.
|
||||||
|
|
||||||
|
### Apps Own Scripts
|
||||||
|
|
||||||
|
Every script belongs to exactly one app (`scripts.app_id`, non-null). Script IDs remain globally unique UUIDs — the API operates on script IDs directly without needing `app_id` in the URL. The dashboard nests scripts under their app in URLs (see "Dashboard URL Layout" below) but the script ID alone is still enough to resolve them server-side.
|
||||||
|
|
||||||
|
Cross-app script reuse is not done by linking. A future **duplicate-to-app** feature may copy a script's content and config into another app under a new ID, with **snapshot semantics**: the copy is independent, and changes to the original do not propagate. Genuine cross-app integration goes through HTTP calls (and, much later, an explicit export/import model for shared data).
|
||||||
|
|
||||||
|
### Apps Own Domains
|
||||||
|
|
||||||
|
Routes can no longer claim arbitrary hostnames freely. Each app declares a set of **domain claims**:
|
||||||
|
|
||||||
|
| Form | Example | Matches |
|
||||||
|
|---|---|---|
|
||||||
|
| Exact host | `app.example.com` | only that exact host |
|
||||||
|
| Single-label wildcard | `*.example.com` | one label deep: `foo.example.com`, not `a.b.example.com` |
|
||||||
|
| Parameterized | `{tenant}.example.com` | same shape as wildcard; binds `tenant` into request context |
|
||||||
|
|
||||||
|
**Syntax convention**: domain parameters use `{name}` (curly braces); route-path parameters use `:name` (colon). These are deliberately distinct so docs and conflict messages never confuse the two.
|
||||||
|
|
||||||
|
Every app also implicitly carries the reserved claim `__internal__`, granting access to `/api/v1/execute/{id}/*` for that app's scripts. An app with no public domain still works for execute-by-id (and, later, cron triggers, queue triggers, etc.).
|
||||||
|
|
||||||
|
When a route is created, its host must match one of the parent app's domain claims. The dashboard's route-creation UI offers a selector populated from the app's claims rather than a free-text host field.
|
||||||
|
|
||||||
|
### Conflict Rules — Checked at Claim Time
|
||||||
|
|
||||||
|
Domain-claim collisions are detected when a domain is added to an app, not when requests arrive:
|
||||||
|
|
||||||
|
- **Exact vs identical exact** → reject ("domain already claimed").
|
||||||
|
- **Exact vs wildcard** → allowed. `foo.example.com` (App A) coexists with `*.example.com` (App B); at request time the more-specific match wins, so A handles `foo.example.com`, B handles every other subdomain.
|
||||||
|
- **Wildcard vs wildcard at the same shape** → reject. Two apps cannot both claim `*.example.com`. `{tenant}.example.com` has the same shape as `*.example.com` for this check — the parameter name is a binding, not a discriminator.
|
||||||
|
|
||||||
|
Route-conflict errors are strictly **intra-app**. A user creating a route inside App A never sees an error that references App B. The only cross-app surface is "this domain is already claimed" at domain-claim time, which is honest and unavoidable.
|
||||||
|
|
||||||
|
### Runtime Dispatch
|
||||||
|
|
||||||
|
Request handling becomes a two-phase lookup:
|
||||||
|
|
||||||
|
1. **Host → app**: pick the app whose claim most-specifically matches the request's `Host` header (exact beats wildcard; ties are impossible by the claim rules above).
|
||||||
|
2. **Path → route**: run that app's route trie unchanged using the existing matcher.
|
||||||
|
|
||||||
|
The orchestrator's route matcher does not learn about apps — it just operates on whichever app's table was selected in step 1. This keeps the existing conflict-detection logic intact.
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
On `localhost`, `localhost` is treated as a regular domain claimed by exactly one app, defaulting to a bootstrap "default" app installed at first run. Dev and prod use the same dispatch model — no second mental model.
|
||||||
|
|
||||||
|
### Cross-App Data Sharing — Deferred
|
||||||
|
|
||||||
|
Per-app isolation is the **default and only mode** in the initial cut. KV collection `users` in App A is distinct from KV collection `users` in App B; App B cannot read App A's data without an HTTP endpoint that App A explicitly exposes.
|
||||||
|
|
||||||
|
A formal export/import model — where App B exports a collection under a public name and admin grants App A read or read-write access — is a future addition. Until it ships, the escape hatch is function-to-function HTTP calls. Sharing is easier to add than to retract; isolation comes first.
|
||||||
|
|
||||||
|
### Schema Sketch
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE apps (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
slug TEXT NOT NULL UNIQUE, -- URL-safe; used in dashboard paths
|
||||||
|
name TEXT NOT NULL, -- display name; can be edited freely
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
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, -- 'app.example.com' | '*.example.com' | '{tenant}.example.com'
|
||||||
|
shape TEXT NOT NULL, -- 'exact' | 'wildcard' | 'parameterized'
|
||||||
|
shape_key TEXT NOT NULL, -- normalized form for collision check (parameterized → wildcard form)
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
|
||||||
|
UNIQUE (shape_key) -- two apps cannot share the same shape-key
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE scripts ADD COLUMN app_id UUID NOT NULL REFERENCES apps(id) ON DELETE RESTRICT;
|
||||||
|
ALTER TABLE routes ADD COLUMN app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Existing route uniqueness checks remain unchanged; they are now scoped within an app.
|
||||||
|
```
|
||||||
|
|
||||||
|
The `UNIQUE (shape_key)` constraint enforces the "same shape" rule at the DB level. Exact-vs-wildcard coexistence is allowed because exact hosts produce a different `shape_key` from wildcards.
|
||||||
|
|
||||||
|
### Bootstrap & Migration
|
||||||
|
|
||||||
|
The migration's behavior **depends on whether the install already has user content**:
|
||||||
|
|
||||||
|
- **Fresh install** (no pre-existing scripts or routes): seed a **"Hello World"** app with `localhost` as its sole domain claim, a `hello.rhai` script that returns a greeting, and a `/hello` GET route. This serves as the reference example for new users — they can hit `http://localhost:<port>/hello` immediately after first boot and see something work. The seed is intentionally minimal; future iterations may flesh it out.
|
||||||
|
- **Upgrading install** (pre-existing scripts or routes): create a **"default"** app with `slug = 'default'`, `localhost` as its sole domain claim, and assign every existing script and route to it. The Hello World seed is **not** added in this case — adding it would pollute the user's existing content.
|
||||||
|
|
||||||
|
The branch point is detected by inspecting whether `scripts` had any rows before the migration ran.
|
||||||
|
|
||||||
|
### Dashboard URL Layout
|
||||||
|
|
||||||
|
The dashboard is **app-hierarchical**, using the app's `slug` for human-readable URLs:
|
||||||
|
|
||||||
|
```
|
||||||
|
/admin/apps — app list
|
||||||
|
/admin/apps/new — create app
|
||||||
|
/admin/apps/{slug} — app overview
|
||||||
|
/admin/apps/{slug}/scripts — scripts in this app
|
||||||
|
/admin/apps/{slug}/scripts/{id} — script detail (script ID still globally unique; slug is for breadcrumbs)
|
||||||
|
/admin/apps/{slug}/routes — routes in this app
|
||||||
|
/admin/apps/{slug}/domains — domain claims for this app
|
||||||
|
/admin/apps/{slug}/settings — app settings
|
||||||
|
```
|
||||||
|
|
||||||
|
Renaming an app changes its `slug`. The previous slug stays as a **permanent redirect** to the renamed app, persisting until another app (a new app or another rename) tries to claim that retired slug. When such a collision happens, the dashboard shows a warning before letting the operator proceed: *"`old-slug` currently redirects to app `bar` — using it here will break any external links that still target the old slug."* If the operator confirms, the redirect row is dropped and the slug is reused.
|
||||||
|
|
||||||
|
Implementation sketch:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE app_slug_history (
|
||||||
|
slug TEXT PRIMARY KEY, -- the retired slug
|
||||||
|
current_app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
retired_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Slug lookup order:
|
||||||
|
1. `apps.slug = {slug}` → render the page directly.
|
||||||
|
2. `app_slug_history.slug = {slug}` → `301` redirect to `/admin/apps/{current_app.slug}/<rest>`.
|
||||||
|
3. Neither → `404`.
|
||||||
|
|
||||||
|
Slug claim order (create or rename to a slug `S`):
|
||||||
|
1. If `S` matches a current app's slug → reject as a conflict (the usual unique-constraint error).
|
||||||
|
2. If `S` matches a row in `app_slug_history` → return a "needs confirmation" response. Dashboard surfaces the warning; on confirm, delete the history row inside the same transaction as the create/rename.
|
||||||
|
3. Otherwise → proceed normally; if this was a rename, insert the old slug into `app_slug_history`.
|
||||||
|
|
||||||
|
A rename back to an app's own retired slug is a special case: just delete the row from `app_slug_history` and don't warn.
|
||||||
|
|
||||||
|
### API URL Layout
|
||||||
|
|
||||||
|
The HTTP API stays **flat**:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/admin/apps — list apps
|
||||||
|
POST /api/v1/admin/apps — create app
|
||||||
|
GET /api/v1/admin/apps/{id_or_slug} — get app
|
||||||
|
PATCH /api/v1/admin/apps/{id_or_slug} — update app
|
||||||
|
DELETE /api/v1/admin/apps/{id_or_slug} — delete app
|
||||||
|
GET /api/v1/admin/apps/{id_or_slug}/domains — list/manage domain claims
|
||||||
|
POST /api/v1/admin/apps/{id_or_slug}/domains
|
||||||
|
DELETE /api/v1/admin/apps/{id_or_slug}/domains/{domain_id}
|
||||||
|
|
||||||
|
GET /api/v1/admin/scripts — list scripts (now supports ?app={id_or_slug} filter)
|
||||||
|
GET /api/v1/admin/scripts/{id} — unchanged; script IDs are globally unique
|
||||||
|
... (rest of scripts/routes endpoints unchanged)
|
||||||
|
```
|
||||||
|
|
||||||
|
The scripts and routes endpoints keep their existing shape — this avoids forcing API consumers to a v2 migration. The new app-management endpoints are additive. Clients that want app context can use the `?app=` filter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 12. Development Roadmap
|
## 12. Development Roadmap
|
||||||
|
|
||||||
### Phase 1: MVP ✓ (Current)
|
### Phase 1: MVP ✓ (Shipped)
|
||||||
- [x] Orchestrator: REST API for script CRUD + execute
|
- [x] Manager: REST API for script CRUD + executions log
|
||||||
- [x] Executor image: load + run Rhai script
|
- [x] Orchestrator: HTTP ingress, route resolution, dispatch
|
||||||
- [x] Dashboard: upload script, deploy, delete
|
- [x] Executor: embedded Rhai engine with sandbox limits (replaces the original Docker-per-execution model — embedded gives better latency and less infra)
|
||||||
- [x] PostgreSQL: script storage + execution logs
|
- [x] Dashboard (SvelteKit): script upload, edit, routing config, execution log viewer
|
||||||
- [ ] **Timeline**: 4-6 weeks
|
- [x] PostgreSQL: scripts, routes, execution_logs; embedded migrations
|
||||||
|
- [x] Caddy reverse proxy in front of everything
|
||||||
|
|
||||||
**Deliverables:**
|
**Delivered beyond original MVP scope:** custom routing (exact / prefix / param + host-aware) with conflict detection, per-script Rhai sandbox config, four-tab dashboard detail UI, structured versioning scheme (product + SDK + API + schema + wire) with `/version` self-report, Rhai editor with autocomplete / goto / find-usages / formatter, SDK contract + schema snapshot + integration test suites.
|
||||||
- Docker image for executor
|
|
||||||
- Rust binary (Orchestrator)
|
|
||||||
- Static HTML + Alpine.js dashboard
|
|
||||||
- docker-compose.yml for local/prod deployment
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 2: v1.0 (Polish & Usability)
|
### Phase 2: v1.0 (Polish & Usability) ✓ (Shipped)
|
||||||
- Script versioning + rollback
|
- [x] Execution history dashboard
|
||||||
- Execution history dashboard (view logs, timings, errors)
|
- [x] Better error messages (Rhai parse errors, sandbox limits, timeouts)
|
||||||
- Better error messages (script parse errors, timeouts)
|
- [x] Timeout / resource-limit enforcement (per-script sandbox config)
|
||||||
- Timeout/resource limit enforcement
|
- [x] Rhai SDK docs current through SDK 1.1
|
||||||
- Container cleanup/GC
|
|
||||||
- Rhai SDK: `request()` function fully documented
|
|
||||||
|
|
||||||
**Timeline**: 2-3 weeks
|
(Script versioning + rollback remains deferred — see Phase 6.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 3: v1.1 (Expand Capabilities & Services)
|
### Phase 3: v1.0.x — Foundations (Current focus)
|
||||||
- Queue-based triggers (RabbitMQ / Redis)
|
|
||||||
- Scheduled jobs (cron syntax)
|
|
||||||
- Secrets management (encrypted env vars)
|
|
||||||
- **Rhai SDK: KV Store** (`kv.get()`, `kv.set()`, `kv.delete()` with collections)
|
|
||||||
- **Rhai SDK: Document Store** (`docs.create()`, `docs.find()`, `docs.update()`, `docs.delete()` with schema validation)
|
|
||||||
- **Rhai SDK: User Management** (auth, CRUD, roles, permissions, invitations, password reset)
|
|
||||||
- **Rhai SDK: Email** (`email.send(to, subject, body)` via SMTP)
|
|
||||||
- Rhai SDK: `s3.*`, `queue.*`, `invoke()`, `retry.*()`
|
|
||||||
- External HTTP calls from scripts (`http.get()`, `http.post()`)
|
|
||||||
- Script versioning with automatic rollback on error
|
|
||||||
|
|
||||||
**Timeline**: 8-10 weeks
|
Two foundation pieces that must land before the v1.1 service expansion, because retrofitting them later is expensive.
|
||||||
|
|
||||||
|
**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** — ✓ 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 4: v1.2 (Advanced Workflows & Hierarchies)
|
### Phase 4: v1.1 (Expand Capabilities & Services)
|
||||||
|
Ordered roughly by foundation value: each row enables the rows below it.
|
||||||
|
|
||||||
|
1. **Rhai SDK: KV Store** (`kv.get/set/delete/has` with collections, scoped per app)
|
||||||
|
2. **Rhai SDK: Document Store** (`docs.create/find/update/delete/list/query`, scoped per app)
|
||||||
|
3. **Rhai SDK: HTTP** (`http.get/post/put/delete` with SSRF deny-list)
|
||||||
|
4. **Cron triggers** (manager scheduler skeleton already exists; needs schedules table + `FOR UPDATE SKIP LOCKED` dispatch)
|
||||||
|
5. **Rhai SDK: Email** (`email.send` via SMTP; needs per-deploy config)
|
||||||
|
6. **Rhai SDK: User Management** (auth, CRUD, roles, permissions, invitations, password reset; depends on email for invites; scoped per app)
|
||||||
|
7. **Queue triggers** (start with Postgres LISTEN/NOTIFY; RabbitMQ/Redis later if needed)
|
||||||
|
8. **`invoke()` + `retry::*`** (function-to-function calls; execution_logs gain `parent_execution_id`)
|
||||||
|
9. **Secrets management** (encrypted env vars, per app)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: v1.2 (Advanced Workflows & Hierarchies)
|
||||||
- Function workflows (DAG execution, conditional branching, error handling)
|
- Function workflows (DAG execution, conditional branching, error handling)
|
||||||
- Function hierarchy (parent/child invocation, sync/async calls)
|
|
||||||
- Nested workflows
|
- Nested workflows
|
||||||
- Call graph visualization + execution tracing
|
- Call graph visualization + execution tracing
|
||||||
- Advanced query support for document store (`docs.query()` with filters)
|
- Advanced query support for document store (`docs.query()` with filters: `$gt`, `$or`, etc.)
|
||||||
|
- Service interceptors (see section 9.4)
|
||||||
**Timeline**: 6-8 weeks
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 5: v1.3+ (Scaling, Security, Observability)
|
### Phase 6: v1.3+ (Scaling, Security, Observability)
|
||||||
- Multi-user / project namespacing
|
- Cluster mode (split-process manager + per-node orchestrator + executor); cluster-mode wire protocol versioning
|
||||||
|
- Cross-app data sharing (explicit export/import model — see section 11.5)
|
||||||
|
- Script versioning + rollback (keep N historical versions in a side table; rollback endpoint)
|
||||||
- Rate limiting on endpoints
|
- Rate limiting on endpoints
|
||||||
- Auth (API keys, dashboard login)
|
- Auth (richer model: API keys, OAuth, etc.)
|
||||||
- Metrics + monitoring dashboard
|
- Metrics + monitoring dashboard
|
||||||
- Container pooling / warm starts
|
|
||||||
- Distributed tracing (OpenTelemetry)
|
- Distributed tracing (OpenTelemetry)
|
||||||
- Webhooks for execution events
|
- Webhooks for execution events
|
||||||
- S3 integration (object storage reads/writes)
|
- S3 integration (object storage reads/writes)
|
||||||
|
|||||||
Reference in New Issue
Block a user