Compare commits
46 Commits
878cbe9439
...
feat/app-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6fc6e6a0e | ||
|
|
66b84abf6d | ||
|
|
a9fc838577 | ||
|
|
2948875a96 | ||
|
|
b7175cc581 | ||
|
|
d40ebf65a2 | ||
|
|
816a13b920 | ||
|
|
248571dcde | ||
|
|
85bbabcbdf | ||
|
|
1314420fca | ||
|
|
33697a2766 | ||
|
|
6eb32a78bf | ||
|
|
fc35d59236 | ||
|
|
0c9f11558a | ||
|
|
39a6df2bfe | ||
|
|
d21cbdb164 | ||
|
|
700ae7b7d1 | ||
|
|
f16ff22a5a | ||
|
|
bd2258499e | ||
|
|
df691038d7 | ||
|
|
3688c26cb4 | ||
|
|
2aab92af31 | ||
|
|
063595be31 | ||
|
|
30a1584667 | ||
|
|
d229120df6 | ||
|
|
8659a58eb2 | ||
|
|
5f7ddd23ab | ||
|
|
44db8d107a | ||
|
|
abaabb68d8 | ||
|
|
fd6f2b1f13 | ||
|
|
d435322f9c | ||
|
|
5546323cdc | ||
|
|
a393f11344 | ||
|
|
ad5492a4bd | ||
|
|
ee0dbc428f | ||
|
|
4c41374db4 | ||
|
|
6891496589 | ||
|
|
646bd55174 | ||
|
|
56de652f7a | ||
|
|
3d4c7b160b | ||
|
|
267c40f59c | ||
|
|
1dc53a0226 | ||
|
|
6cdb1244b8 | ||
|
|
bc8b512b56 | ||
|
|
a80e6d1ca4 | ||
|
|
0eaf4aee69 |
@@ -29,3 +29,11 @@ RUST_LOG=info,picloud=debug
|
|||||||
# Public base URL the dashboard uses to render full URLs for user routes.
|
# Public base URL the dashboard uses to render full URLs for user routes.
|
||||||
# Set to the host:port (and scheme) users actually reach in their browser.
|
# Set to the host:port (and scheme) users actually reach in their browser.
|
||||||
PICLOUD_PUBLIC_BASE_URL=http://localhost:8000
|
PICLOUD_PUBLIC_BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# ---------- Bootstrap admin ----------
|
||||||
|
# Required. Used once on first startup to seed the admin_users table.
|
||||||
|
# Ignored on subsequent boots if the table is non-empty. For prod,
|
||||||
|
# prefer PICLOUD_ADMIN_PASSWORD_HASH (pre-computed Argon2id PHC) so the
|
||||||
|
# raw password never lands in env or compose files; see blueprint §11.5.
|
||||||
|
PICLOUD_ADMIN_USERNAME=admin
|
||||||
|
PICLOUD_ADMIN_PASSWORD=admin
|
||||||
|
|||||||
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.
|
||||||
|
|||||||
62
Cargo.lock
generated
62
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"
|
||||||
@@ -387,6 +408,12 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
@@ -1233,6 +1260,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,12 +1311,13 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud"
|
name = "picloud"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-test",
|
"axum-test",
|
||||||
|
"chrono",
|
||||||
"figment",
|
"figment",
|
||||||
"picloud-executor-core",
|
"picloud-executor-core",
|
||||||
"picloud-manager-core",
|
"picloud-manager-core",
|
||||||
@@ -1293,11 +1332,12 @@ dependencies = [
|
|||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-executor"
|
name = "picloud-executor"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-executor-core",
|
"picloud-executor-core",
|
||||||
@@ -1309,7 +1349,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-executor-core"
|
name = "picloud-executor-core"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"picloud-shared",
|
"picloud-shared",
|
||||||
@@ -1323,7 +1363,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-manager"
|
name = "picloud-manager"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-manager-core",
|
"picloud-manager-core",
|
||||||
@@ -1335,17 +1375,23 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-manager-core"
|
name = "picloud-manager-core"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"data-encoding",
|
||||||
"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 +1399,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-orchestrator"
|
name = "picloud-orchestrator"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-orchestrator-core",
|
"picloud-orchestrator-core",
|
||||||
@@ -1365,7 +1411,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-orchestrator-core"
|
name = "picloud-orchestrator-core"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1384,7 +1430,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-shared"
|
name = "picloud-shared"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
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,13 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
|||||||
url = "2"
|
url = "2"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
|
||||||
|
# Auth (admin users + sessions + API keys)
|
||||||
|
argon2 = "0.5"
|
||||||
|
rand = { version = "0.8", features = ["getrandom"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
data-encoding = "2.6"
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|
||||||
|
|||||||
@@ -22,3 +22,12 @@ 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
|
||||||
|
data-encoding.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);
|
||||||
112
crates/manager-core/migrations/0006_users_authz.sql
Normal file
112
crates/manager-core/migrations/0006_users_authz.sql
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
-- Phase 3.5 users, roles, and bearer-token auth — see blueprint §11.6.
|
||||||
|
--
|
||||||
|
-- Lays down the schema that the unified can(principal, capability) gate
|
||||||
|
-- runs against, plus the api_keys table that backs `Authorization: Bearer
|
||||||
|
-- pic_…` credentials. No data-plane impact; Phase 4 SDKs (KV, docs, HTTP,
|
||||||
|
-- cron) will plug into this same authz pipeline.
|
||||||
|
--
|
||||||
|
-- Three changes:
|
||||||
|
-- 1. admin_users gains instance_role ('owner'/'admin'/'member') plus a
|
||||||
|
-- reserved email column and mfa_secret slot (neither is read yet).
|
||||||
|
-- Every pre-existing row becomes 'owner' via the DEFAULT — Phase 3a
|
||||||
|
-- had no role concept, so promoting all current admins to owner is
|
||||||
|
-- the only safe interpretation (and matches the spec). The Rust
|
||||||
|
-- startup path logs a warning when more than one active owner
|
||||||
|
-- exists, so operators can demote extras via the admin PATCH.
|
||||||
|
-- 2. app_members records explicit per-app grants for 'member' users.
|
||||||
|
-- Owners and admins get implicit grants in code (owner→app_admin
|
||||||
|
-- everywhere, admin→editor everywhere); no rows here.
|
||||||
|
-- 3. api_keys holds Argon2id-hashed bearer credentials. Lookup is
|
||||||
|
-- prefix-indexed (first 8 chars after `pic_`) then hash-verified;
|
||||||
|
-- raw token only ever exists in the POST response. Optional
|
||||||
|
-- expires_at / app_id implement TTL and app-binding respectively.
|
||||||
|
|
||||||
|
ALTER TABLE admin_users
|
||||||
|
-- DEFAULT 'owner' so the Phase 3a bootstrap admin (and any other
|
||||||
|
-- pre-existing rows) become full owners without a backfill step.
|
||||||
|
-- Multi-owner installs are flagged at startup; demotion is a
|
||||||
|
-- deliberate PATCH, not an automatic migration choice.
|
||||||
|
ADD COLUMN instance_role TEXT NOT NULL DEFAULT 'owner'
|
||||||
|
CHECK (instance_role IN ('owner', 'admin', 'member')),
|
||||||
|
-- Reserved for the eventual invite flow + Phase 4 user-management
|
||||||
|
-- SDK. UNIQUE so we never end up with two rows claiming the same
|
||||||
|
-- contact. Nullable because pre-existing admins have no email on
|
||||||
|
-- file and we don't want to force a backfill.
|
||||||
|
ADD COLUMN email TEXT UNIQUE,
|
||||||
|
-- Reserved slot for TOTP secrets. Not read in Phase 3.5 — present
|
||||||
|
-- now only to avoid a schema bump when MFA lands.
|
||||||
|
ADD COLUMN mfa_secret TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX admin_users_instance_role_idx ON admin_users (instance_role);
|
||||||
|
|
||||||
|
-- Per-(user, app) explicit grant. Owners and admins do NOT appear here;
|
||||||
|
-- their app authority is implicit in their instance_role and resolved in
|
||||||
|
-- code. Only 'member' users need rows in this table — without one, a
|
||||||
|
-- member has no access to the app at all.
|
||||||
|
CREATE TABLE app_members (
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('app_admin', 'editor', 'viewer')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (app_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Lookup pattern is "what apps can this user see?" — needed for the
|
||||||
|
-- membership-filtered GET /admin/apps and GET /admin/scripts.
|
||||||
|
CREATE INDEX app_members_user_id_idx ON app_members (user_id);
|
||||||
|
|
||||||
|
-- Bearer API keys. Format on the wire: `pic_<base32(32 random bytes)>`.
|
||||||
|
-- prefix = first 8 chars after `pic_` (indexed for O(1) candidate lookup)
|
||||||
|
-- hash = Argon2id PHC of the full body after `pic_`
|
||||||
|
-- Raw value is returned exactly once at mint time and never persisted.
|
||||||
|
--
|
||||||
|
-- Optional fields:
|
||||||
|
-- expires_at: TTL. Lookup always filters `expires_at IS NULL OR > NOW()`.
|
||||||
|
-- app_id : "bound key" — capability checks deny any App*(other_app),
|
||||||
|
-- regardless of the owning user's role. Cannot combine with
|
||||||
|
-- instance:* scopes (validated in the mint handler, not SQL).
|
||||||
|
CREATE TABLE api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
hash TEXT NOT NULL,
|
||||||
|
prefix TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
-- TEXT[] keeps the scope set open to additions without a migration;
|
||||||
|
-- the seven legal values are validated at mint time in Rust, not by
|
||||||
|
-- a CHECK constraint here (so new scopes can land without a schema
|
||||||
|
-- bump).
|
||||||
|
scopes TEXT[] NOT NULL,
|
||||||
|
app_id UUID NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
expires_at TIMESTAMPTZ NULL,
|
||||||
|
last_used_at TIMESTAMPTZ NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX api_keys_prefix_idx ON api_keys (prefix);
|
||||||
|
CREATE INDEX api_keys_user_id_idx ON api_keys (user_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Reserved schema room (not built in Phase 3.5)
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- These tables are deliberately commented out, not created. They are
|
||||||
|
-- listed here so the design intent is visible at the migration boundary
|
||||||
|
-- and future authors don't reinvent the shape. Each lands in its own
|
||||||
|
-- numbered migration when the corresponding flow ships.
|
||||||
|
--
|
||||||
|
-- CREATE TABLE invites (
|
||||||
|
-- token TEXT PRIMARY KEY, -- raw at email-link time, hashed at rest
|
||||||
|
-- email TEXT NOT NULL,
|
||||||
|
-- instance_role TEXT NULL CHECK (instance_role IN ('owner','admin','member')),
|
||||||
|
-- app_id UUID NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
-- app_role TEXT NULL CHECK (app_role IN ('app_admin','editor','viewer')),
|
||||||
|
-- invited_by UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
-- expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
-- consumed_at TIMESTAMPTZ NULL
|
||||||
|
-- );
|
||||||
|
--
|
||||||
|
-- CREATE TABLE service_accounts (
|
||||||
|
-- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
-- name TEXT NOT NULL,
|
||||||
|
-- owning_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE RESTRICT,
|
||||||
|
-- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
-- );
|
||||||
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
466
crates/manager-core/src/admin_user_repo.rs
Normal file
466
crates/manager-core/src/admin_user_repo.rs
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
//! 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, InstanceRole};
|
||||||
|
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),
|
||||||
|
|
||||||
|
#[error("email already taken: {0}")]
|
||||||
|
DuplicateEmail(String),
|
||||||
|
|
||||||
|
#[error("invalid instance_role stored in DB: {0}")]
|
||||||
|
InvalidInstanceRole(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 instance_role: InstanceRole,
|
||||||
|
pub email: Option<String>,
|
||||||
|
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,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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>;
|
||||||
|
/// Create a new admin. `instance_role` defaults to `Owner` for the
|
||||||
|
/// env-var bootstrap path; admin-creates-admin flows pass an
|
||||||
|
/// explicit role. `email` is optional — pass `None` to leave the
|
||||||
|
/// column NULL.
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
instance_role: InstanceRole,
|
||||||
|
email: Option<&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>;
|
||||||
|
/// Set or clear the email address. `None` writes NULL to the column.
|
||||||
|
async fn update_email(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
|
/// Update the instance_role. Used by `PATCH /api/v1/admin/admins/{id}`;
|
||||||
|
/// callers enforce the last-owner guard (`count_other_active_owners`)
|
||||||
|
/// before invoking when role transitions away from `Owner`.
|
||||||
|
async fn update_instance_role(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
instance_role: InstanceRole,
|
||||||
|
) -> 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>;
|
||||||
|
/// All active owners — used for the multi-owner startup warning.
|
||||||
|
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
|
||||||
|
/// Count of active owners excluding the given id. Used by the
|
||||||
|
/// last-owner guard when demoting / deactivating / deleting an
|
||||||
|
/// owner: "would this leave zero owners?"
|
||||||
|
async fn count_other_active_owners(
|
||||||
|
&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, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at \
|
||||||
|
FROM admin_users WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_username(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"SELECT id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at \
|
||||||
|
FROM admin_users WHERE username = $1",
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
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, instance_role \
|
||||||
|
FROM admin_users WHERE username = $1",
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"SELECT id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at \
|
||||||
|
FROM admin_users ORDER BY username",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
instance_role: InstanceRole,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"INSERT INTO admin_users (username, password_hash, instance_role, email) \
|
||||||
|
VALUES ($1, $2, $3, $4) \
|
||||||
|
RETURNING id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.bind(password_hash)
|
||||||
|
.bind(instance_role.as_str())
|
||||||
|
.bind(email)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(row) => row.try_into(),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
// username and email both have unique constraints; the
|
||||||
|
// create path can collide on either, so peek at the
|
||||||
|
// constraint name to surface the right error.
|
||||||
|
if e.constraint() == Some("admin_users_email_key") {
|
||||||
|
Err(AdminUserRepositoryError::DuplicateEmail(
|
||||||
|
email.unwrap_or("").to_string(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
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, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(Some(row)) => row.try_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, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(password_hash)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||||
|
.and_then(TryInto::try_into)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_email(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"UPDATE admin_users SET email = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(email)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(Some(row)) => row.try_into(),
|
||||||
|
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||||
|
AdminUserRepositoryError::DuplicateEmail(email.unwrap_or("").to_string()),
|
||||||
|
),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_instance_role(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
instance_role: InstanceRole,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"UPDATE admin_users SET instance_role = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(instance_role.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||||
|
.and_then(TryInto::try_into)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(is_active)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||||
|
.and_then(TryInto::try_into)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"SELECT id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at \
|
||||||
|
FROM admin_users \
|
||||||
|
WHERE is_active AND instance_role = 'owner' \
|
||||||
|
ORDER BY username",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_other_active_owners(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
) -> Result<i64, AdminUserRepositoryError> {
|
||||||
|
let (count,): (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*)::BIGINT FROM admin_users \
|
||||||
|
WHERE is_active AND instance_role = 'owner' 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,
|
||||||
|
instance_role: String,
|
||||||
|
email: Option<String>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
updated_at: DateTime<Utc>,
|
||||||
|
last_login_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<AdminUserRecord> for AdminUserRow {
|
||||||
|
type Error = AdminUserRepositoryError;
|
||||||
|
fn try_from(r: AdminUserRecord) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
id: r.id.into(),
|
||||||
|
username: r.username,
|
||||||
|
is_active: r.is_active,
|
||||||
|
instance_role: InstanceRole::from_db_str(&r.instance_role).ok_or(
|
||||||
|
AdminUserRepositoryError::InvalidInstanceRole(r.instance_role),
|
||||||
|
)?,
|
||||||
|
email: r.email,
|
||||||
|
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,
|
||||||
|
instance_role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<AdminCredsRecord> for AdminUserCredentials {
|
||||||
|
type Error = AdminUserRepositoryError;
|
||||||
|
fn try_from(r: AdminCredsRecord) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
id: r.id.into(),
|
||||||
|
username: r.username,
|
||||||
|
password_hash: r.password_hash,
|
||||||
|
is_active: r.is_active,
|
||||||
|
instance_role: InstanceRole::from_db_str(&r.instance_role).ok_or(
|
||||||
|
AdminUserRepositoryError::InvalidInstanceRole(r.instance_role),
|
||||||
|
)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
533
crates/manager-core/src/admin_users_api.rs
Normal file
533
crates/manager-core/src/admin_users_api.rs
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
//! `/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::{Extension, Router};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::{AdminUserId, InstanceRole, Principal};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::admin_session_repo::AdminSessionRepository;
|
||||||
|
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
|
||||||
|
use crate::api_key_repo::ApiKeyRepository;
|
||||||
|
use crate::auth::hash_password;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
|
|
||||||
|
/// 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>,
|
||||||
|
/// Phase 3.5 deactivation symmetry — flipping `is_active = false`
|
||||||
|
/// also expires every active API key for that user so cookie and
|
||||||
|
/// bearer credentials become inert at the same moment.
|
||||||
|
pub keys: Arc<dyn ApiKeyRepository>,
|
||||||
|
/// Capability gate: every endpoint here requires
|
||||||
|
/// `InstanceManageUsers` (owner / admin).
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 instance_role: InstanceRole,
|
||||||
|
pub email: Option<String>,
|
||||||
|
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,
|
||||||
|
instance_role: r.instance_role,
|
||||||
|
email: r.email,
|
||||||
|
created_at: r.created_at,
|
||||||
|
last_login_at: r.last_login_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateAdminRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
/// Defaults to `Admin` when absent — minting an owner via the API
|
||||||
|
/// is a deliberate step. The env-var bootstrap path is the only
|
||||||
|
/// channel that defaults to `Owner`.
|
||||||
|
#[serde(default = "default_create_role")]
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
/// Optional contact email. Blank/whitespace is normalized to None.
|
||||||
|
#[serde(default)]
|
||||||
|
pub email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_create_role() -> InstanceRole {
|
||||||
|
InstanceRole::Admin
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
pub struct PatchAdminRequest {
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub is_active: Option<bool>,
|
||||||
|
pub instance_role: Option<InstanceRole>,
|
||||||
|
/// JSON Merge Patch (RFC 7396) semantics for email:
|
||||||
|
/// absent → don't change
|
||||||
|
/// null → clear (set DB column to NULL)
|
||||||
|
/// "<string>" → set to that string
|
||||||
|
/// `Option<Option<T>>` is the idiomatic Rust shape for that
|
||||||
|
/// tri-state; the custom deserializer below distinguishes the
|
||||||
|
/// "missing" case from the "present-and-null" case that serde
|
||||||
|
/// would otherwise collapse together.
|
||||||
|
#[allow(clippy::option_option)]
|
||||||
|
#[serde(default, deserialize_with = "deserialize_present_optional")]
|
||||||
|
pub email: Option<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::option_option)]
|
||||||
|
fn deserialize_present_optional<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
|
||||||
|
where
|
||||||
|
T: serde::Deserialize<'de>,
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(Some(Option::<T>::deserialize(deserializer)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_admins(
|
||||||
|
State(state): State<AdminsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
) -> Result<Json<Vec<AdminDto>>, AdminApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let rows = state.users.list().await?;
|
||||||
|
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_admin(
|
||||||
|
State(state): State<AdminsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id): Path<AdminUserId>,
|
||||||
|
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
state
|
||||||
|
.users
|
||||||
|
.get(id)
|
||||||
|
.await?
|
||||||
|
.map(AdminDto::from)
|
||||||
|
.map(Json)
|
||||||
|
.ok_or(AdminApiError::NotFound(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_admin(
|
||||||
|
State(state): State<AdminsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Json(input): Json<CreateAdminRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<AdminDto>), AdminApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// Minting an owner via the API requires the caller to ALSO be an
|
||||||
|
// owner — admin cannot self-elevate (or elevate someone else)
|
||||||
|
// beyond their own ceiling. Owner-creation by env-var bootstrap
|
||||||
|
// bypasses this path.
|
||||||
|
if input.instance_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner
|
||||||
|
{
|
||||||
|
return Err(AdminApiError::CannotEscalate);
|
||||||
|
}
|
||||||
|
let username = input.username.trim();
|
||||||
|
validate_username(username)?;
|
||||||
|
validate_password(&input.password)?;
|
||||||
|
let email = normalize_email(input.email.as_deref())?;
|
||||||
|
let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
|
||||||
|
let row = state
|
||||||
|
.users
|
||||||
|
.create(username, &hash, input.instance_role, email.as_deref())
|
||||||
|
.await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(row.into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_admin(
|
||||||
|
State(state): State<AdminsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id): Path<AdminUserId>,
|
||||||
|
Json(input): Json<PatchAdminRequest>,
|
||||||
|
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// Verify the target exists upfront — keeps the error path uniform
|
||||||
|
// for "rename a missing user" etc.
|
||||||
|
let current = 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(email_patch) = input.email.as_ref() {
|
||||||
|
// email_patch is Some(None) → clear, Some(Some(s)) → set.
|
||||||
|
let normalized = normalize_email(email_patch.as_deref())?;
|
||||||
|
latest = Some(state.users.update_email(id, normalized.as_deref()).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(new_role) = input.instance_role {
|
||||||
|
// Self-elevation guard: only an owner can promote anyone TO
|
||||||
|
// owner. An admin cannot turn themselves (or anyone else)
|
||||||
|
// into one.
|
||||||
|
if new_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner {
|
||||||
|
return Err(AdminApiError::CannotEscalate);
|
||||||
|
}
|
||||||
|
// Last-active-owner guard: a transition off of `Owner` cannot
|
||||||
|
// leave the install with zero owners. The check is on the
|
||||||
|
// source role (current.instance_role) so demoting an
|
||||||
|
// already-non-owner is always fine.
|
||||||
|
if current.instance_role == InstanceRole::Owner && new_role != InstanceRole::Owner {
|
||||||
|
let remaining = state.users.count_other_active_owners(id).await?;
|
||||||
|
if remaining == 0 {
|
||||||
|
return Err(AdminApiError::LastActiveOwner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
latest = Some(state.users.update_instance_role(id, new_role).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// ALSO: if the target is currently the last active owner,
|
||||||
|
// deactivating them leaves no owner. Belt-and-suspenders to
|
||||||
|
// the role guard above (which only triggers on an explicit
|
||||||
|
// role transition).
|
||||||
|
let target_role = latest
|
||||||
|
.as_ref()
|
||||||
|
.map_or(current.instance_role, |r| r.instance_role);
|
||||||
|
if target_role == InstanceRole::Owner {
|
||||||
|
let remaining_owners = state.users.count_other_active_owners(id).await?;
|
||||||
|
if remaining_owners == 0 {
|
||||||
|
return Err(AdminApiError::LastActiveOwner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
latest = Some(state.users.set_active(id, new_active).await?);
|
||||||
|
// Deactivation invalidates BOTH credential surfaces — sessions
|
||||||
|
// (cookie / session bearer) and API keys. Both writes are
|
||||||
|
// logged on failure but do not undo the deactivation; the
|
||||||
|
// alternative (leaving the user active when one cascade fails)
|
||||||
|
// is worse than slightly stale credential rows on a DB blip.
|
||||||
|
if !new_active {
|
||||||
|
if let Err(err) = state.sessions.delete_for_user(id).await {
|
||||||
|
tracing::error!(?err, "failed to delete sessions for deactivated admin");
|
||||||
|
}
|
||||||
|
match state.keys.expire_all_for_user(id).await {
|
||||||
|
Ok(n) => {
|
||||||
|
if n > 0 {
|
||||||
|
tracing::info!(user_id = %id, expired = n, "expired api keys on deactivation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "failed to expire api keys 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>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id): Path<AdminUserId>,
|
||||||
|
) -> Result<StatusCode, AdminApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// Last-owner guard mirrors the role-transition guard in
|
||||||
|
// patch_admin — deleting the only owner is just as bad as
|
||||||
|
// demoting them.
|
||||||
|
if target.instance_role == InstanceRole::Owner {
|
||||||
|
let remaining_owners = state.users.count_other_active_owners(id).await?;
|
||||||
|
if remaining_owners == 0 {
|
||||||
|
return Err(AdminApiError::LastActiveOwner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.users.delete(id).await?;
|
||||||
|
// Sessions + api_keys 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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trim and reject empty / pathological emails, returning the
|
||||||
|
/// canonical form (or None when the input was blank). The shape
|
||||||
|
/// check is intentionally loose — we mainly want to reject blanks
|
||||||
|
/// and obvious junk; real verification is a future concern.
|
||||||
|
fn normalize_email(raw: Option<&str>) -> Result<Option<String>, AdminApiError> {
|
||||||
|
let Some(raw) = raw else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if trimmed.len() > 254 || !trimmed.contains('@') {
|
||||||
|
return Err(AdminApiError::InvalidEmail(
|
||||||
|
"email must contain '@' and be at most 254 characters".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Some(trimmed.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 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("{0}")]
|
||||||
|
InvalidEmail(String),
|
||||||
|
|
||||||
|
#[error("cannot leave the system with zero active admins")]
|
||||||
|
LastActiveAdmin,
|
||||||
|
|
||||||
|
#[error("cannot leave the system with zero active owners")]
|
||||||
|
LastActiveOwner,
|
||||||
|
|
||||||
|
#[error("only an owner can grant the owner role")]
|
||||||
|
CannotEscalate,
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
|
#[error("failed to hash password: {0}")]
|
||||||
|
Hash(String),
|
||||||
|
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Repo(#[from] AdminUserRepositoryError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for AdminApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(_)
|
||||||
|
| AdminUserRepositoryError::DuplicateEmail(_),
|
||||||
|
) => (StatusCode::CONFLICT, self.to_string()),
|
||||||
|
Self::InvalidUsername(_)
|
||||||
|
| Self::InvalidPassword(_)
|
||||||
|
| Self::InvalidEmail(_)
|
||||||
|
| Self::LastActiveAdmin
|
||||||
|
| Self::LastActiveOwner
|
||||||
|
| Self::CannotEscalate
|
||||||
|
| Self::Repo(AdminUserRepositoryError::InvalidInstanceRole(_)) => {
|
||||||
|
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||||
|
}
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "admin_users authz error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error".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,20 @@
|
|||||||
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,
|
Extension, Json, Router,
|
||||||
};
|
};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
|
AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptSandbox, ScriptValidator,
|
||||||
|
ValidationError,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
use crate::repo::{
|
use crate::repo::{
|
||||||
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
||||||
};
|
};
|
||||||
@@ -27,6 +30,13 @@ 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>,
|
||||||
|
/// Phase 3.5 capability checks — every script handler resolves
|
||||||
|
/// `AppRead/Write/LogRead(script.app_id)` against this repo after
|
||||||
|
/// loading the resource.
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
pub validator: Arc<dyn ScriptValidator>,
|
pub validator: Arc<dyn ScriptValidator>,
|
||||||
pub sandbox_ceiling: SandboxCeiling,
|
pub sandbox_ceiling: SandboxCeiling,
|
||||||
}
|
}
|
||||||
@@ -36,6 +46,8 @@ 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(),
|
||||||
|
authz: self.authz.clone(),
|
||||||
validator: self.validator.clone(),
|
validator: self.validator.clone(),
|
||||||
sandbox_ceiling: self.sandbox_ceiling,
|
sandbox_ceiling: self.sandbox_ceiling,
|
||||||
}
|
}
|
||||||
@@ -70,6 +82,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 +97,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,31 +136,83 @@ 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>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Query(q): Query<ListScriptsQuery>,
|
||||||
) -> Result<Json<Vec<Script>>, ApiError> {
|
) -> Result<Json<Vec<Script>>, ApiError> {
|
||||||
|
// Membership filter: `member` users see only scripts in apps they
|
||||||
|
// belong to. `?app=` filters further by app and additionally
|
||||||
|
// requires the member to belong to that app (the read check uses
|
||||||
|
// the resource's app_id).
|
||||||
|
if let Some(ident) = q.app {
|
||||||
|
let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
|
||||||
|
require(state.authz.as_ref(), &principal, Capability::AppRead(app)).await?;
|
||||||
|
return Ok(Json(state.repo.list_for_app(app).await?));
|
||||||
|
}
|
||||||
|
if principal.instance_role == InstanceRole::Member {
|
||||||
|
return Ok(Json(state.repo.list_for_user(principal.user_id).await?));
|
||||||
|
}
|
||||||
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>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
) -> Result<Json<Script>, ApiError> {
|
) -> Result<Json<Script>, ApiError> {
|
||||||
state
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||||
.repo
|
require(
|
||||||
.get(id)
|
state.authz.as_ref(),
|
||||||
.await?
|
&principal,
|
||||||
.map(Json)
|
Capability::AppRead(script.app_id),
|
||||||
.ok_or(ApiError::NotFound(id))
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(script))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Json(input): Json<CreateScriptRequest>,
|
Json(input): Json<CreateScriptRequest>,
|
||||||
) -> Result<(StatusCode, Json<Script>), ApiError> {
|
) -> Result<(StatusCode, Json<Script>), ApiError> {
|
||||||
|
// Capability is bound to the *requested* app_id since there's no
|
||||||
|
// resource to load yet. If the app doesn't exist we 422 below;
|
||||||
|
// checking authz first means a Member trying to create against an
|
||||||
|
// unknown app gets 403 (no enumeration of app existence).
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteScript(input.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
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,
|
||||||
@@ -155,9 +230,17 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
|
|
||||||
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
Json(input): Json<UpdateScriptRequest>,
|
Json(input): Json<UpdateScriptRequest>,
|
||||||
) -> Result<Json<Script>, ApiError> {
|
) -> Result<Json<Script>, ApiError> {
|
||||||
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteScript(script.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
if let Some(src) = input.source.as_deref() {
|
if let Some(src) = input.source.as_deref() {
|
||||||
state.validator.validate(src)?;
|
state.validator.validate(src)?;
|
||||||
}
|
}
|
||||||
@@ -183,8 +266,16 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
|
|
||||||
async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteScript(script.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
state.repo.delete(id).await?;
|
state.repo.delete(id).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -203,9 +294,17 @@ const fn default_limit() -> i64 {
|
|||||||
|
|
||||||
async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
axum::extract::Query(q): axum::extract::Query<LogsQuery>,
|
axum::extract::Query(q): axum::extract::Query<LogsQuery>,
|
||||||
) -> Result<Json<Vec<ExecutionLog>>, ApiError> {
|
) -> Result<Json<Vec<ExecutionLog>>, ApiError> {
|
||||||
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppLogRead(script.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
// Cap to keep the dashboard responsive; the data plane writes are
|
// Cap to keep the dashboard responsive; the data plane writes are
|
||||||
// unbounded over time so a paged read is the only sane default.
|
// unbounded over time so a paged read is the only sane default.
|
||||||
let limit = q.limit.clamp(1, 200);
|
let limit = q.limit.clamp(1, 200);
|
||||||
@@ -223,6 +322,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),
|
||||||
|
|
||||||
@@ -232,18 +334,42 @@ pub enum ApiError {
|
|||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Ceiling(#[from] CeilingError),
|
Ceiling(#[from] CeilingError),
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
#[error("repository error: {0}")]
|
#[error("repository error: {0}")]
|
||||||
Repo(#[from] ScriptRepositoryError),
|
Repo(#[from] ScriptRepositoryError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for ApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IntoResponse for ApiError {
|
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())
|
||||||
}
|
}
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||||
(StatusCode::NOT_FOUND, self.to_string())
|
(StatusCode::NOT_FOUND, self.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
292
crates/manager-core/src/api_key_repo.rs
Normal file
292
crates/manager-core/src/api_key_repo.rs
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
//! CRUD over the `api_keys` table — backs the `Authorization: Bearer
|
||||||
|
//! pic_…` credential flow from blueprint §11.6.
|
||||||
|
//!
|
||||||
|
//! The repo never sees the raw token; only the 8-char `prefix` and the
|
||||||
|
//! Argon2id `hash`. Mint logic (random-bytes generation, prefix split,
|
||||||
|
//! hash compute) lives in `api_keys_api.rs`. Verification logic
|
||||||
|
//! (prefix lookup + Argon2 verify per candidate) lives in
|
||||||
|
//! `auth_middleware.rs`. Both call this repo for the storage layer.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::{AdminUserId, ApiKeyId, AppId, Scope};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ApiKeyRepositoryError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
|
||||||
|
#[error("api key not found: {0}")]
|
||||||
|
NotFound(ApiKeyId),
|
||||||
|
|
||||||
|
#[error("invalid scope stored in DB: {0}")]
|
||||||
|
InvalidScope(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert payload — built by `api_keys_api` after generating the raw
|
||||||
|
/// token and hashing it. `hash` is an Argon2id PHC string covering the
|
||||||
|
/// body of the token (everything after `pic_`); `prefix` is the first
|
||||||
|
/// 8 chars of that body, indexed for fast candidate lookup.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NewApiKey {
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub hash: String,
|
||||||
|
pub prefix: String,
|
||||||
|
pub name: String,
|
||||||
|
pub scopes: Vec<Scope>,
|
||||||
|
pub app_id: Option<AppId>,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public-facing row — never exposes the hash. Used for `GET
|
||||||
|
/// /admin/api-keys` and the `POST` response (alongside the
|
||||||
|
/// one-shot raw token).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ApiKeyRow {
|
||||||
|
pub id: ApiKeyId,
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub prefix: String,
|
||||||
|
pub name: String,
|
||||||
|
pub scopes: Vec<Scope>,
|
||||||
|
pub app_id: Option<AppId>,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
pub last_used_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verification candidate — includes the Argon2id `hash` and `user_id`
|
||||||
|
/// so middleware can verify the supplied token and assemble the
|
||||||
|
/// `Principal`. Kept separate from `ApiKeyRow` so handlers can't leak
|
||||||
|
/// the hash through a careless `Json(row)`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ApiKeyVerification {
|
||||||
|
pub id: ApiKeyId,
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub hash: String,
|
||||||
|
pub scopes: Vec<Scope>,
|
||||||
|
pub app_id: Option<AppId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ApiKeyRepository: Send + Sync {
|
||||||
|
/// Mint. Caller has already hashed the raw token + computed prefix.
|
||||||
|
async fn create(&self, key: NewApiKey) -> Result<ApiKeyRow, ApiKeyRepositoryError>;
|
||||||
|
|
||||||
|
/// Return every non-expired key with the given 8-char prefix. The
|
||||||
|
/// caller (middleware) Argon2-verifies the supplied token against
|
||||||
|
/// each candidate's `hash`. Returning a Vec rather than one row
|
||||||
|
/// keeps the contract correct even if two keys happen to share a
|
||||||
|
/// prefix (statistically near-zero but possible).
|
||||||
|
async fn find_active_by_prefix(
|
||||||
|
&self,
|
||||||
|
prefix: &str,
|
||||||
|
) -> Result<Vec<ApiKeyVerification>, ApiKeyRepositoryError>;
|
||||||
|
|
||||||
|
/// Update `last_used_at` for an authenticated request. Inline (not
|
||||||
|
/// fire-and-forget) so a DB blip surfaces as a 500 rather than
|
||||||
|
/// silent stale timestamps.
|
||||||
|
async fn touch_last_used(&self, id: ApiKeyId) -> Result<(), ApiKeyRepositoryError>;
|
||||||
|
|
||||||
|
/// Caller's own keys, for `GET /admin/api-keys`.
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<Vec<ApiKeyRow>, ApiKeyRepositoryError>;
|
||||||
|
|
||||||
|
/// Look up a key by id — used by `DELETE` to verify ownership
|
||||||
|
/// before issuing the delete.
|
||||||
|
async fn get(&self, id: ApiKeyId) -> Result<Option<ApiKeyRow>, ApiKeyRepositoryError>;
|
||||||
|
|
||||||
|
/// Delete the row only if it belongs to `user_id`. Returns whether
|
||||||
|
/// a row was actually deleted (false = key didn't exist OR wasn't
|
||||||
|
/// theirs — handlers map both to 404 to avoid leaking the
|
||||||
|
/// distinction).
|
||||||
|
async fn delete_by_id_and_user(
|
||||||
|
&self,
|
||||||
|
id: ApiKeyId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<bool, ApiKeyRepositoryError>;
|
||||||
|
|
||||||
|
/// Set `expires_at = NOW()` on every active key for a user. Wired
|
||||||
|
/// into `set_active(false)` so deactivation invalidates both
|
||||||
|
/// sessions (already done by `AdminSessionRepository::delete_for_user`)
|
||||||
|
/// and bearer keys at the same moment.
|
||||||
|
async fn expire_all_for_user(&self, user_id: AdminUserId)
|
||||||
|
-> Result<u64, ApiKeyRepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresApiKeyRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresApiKeyRepository {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ApiKeyRepository for PostgresApiKeyRepository {
|
||||||
|
async fn create(&self, key: NewApiKey) -> Result<ApiKeyRow, ApiKeyRepositoryError> {
|
||||||
|
let scope_strings: Vec<String> =
|
||||||
|
key.scopes.iter().map(|s| s.as_str().to_string()).collect();
|
||||||
|
let row = sqlx::query_as::<_, ApiKeyRecord>(
|
||||||
|
"INSERT INTO api_keys \
|
||||||
|
(user_id, hash, prefix, name, scopes, app_id, expires_at) \
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||||
|
RETURNING id, user_id, prefix, name, scopes, app_id, \
|
||||||
|
expires_at, last_used_at, created_at",
|
||||||
|
)
|
||||||
|
.bind(key.user_id.into_inner())
|
||||||
|
.bind(&key.hash)
|
||||||
|
.bind(&key.prefix)
|
||||||
|
.bind(&key.name)
|
||||||
|
.bind(&scope_strings)
|
||||||
|
.bind(key.app_id.map(picloud_shared::AppId::into_inner))
|
||||||
|
.bind(key.expires_at)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.try_into()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_active_by_prefix(
|
||||||
|
&self,
|
||||||
|
prefix: &str,
|
||||||
|
) -> Result<Vec<ApiKeyVerification>, ApiKeyRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, ApiKeyVerifyRecord>(
|
||||||
|
"SELECT id, user_id, hash, scopes, app_id \
|
||||||
|
FROM api_keys \
|
||||||
|
WHERE prefix = $1 \
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())",
|
||||||
|
)
|
||||||
|
.bind(prefix)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn touch_last_used(&self, id: ApiKeyId) -> Result<(), ApiKeyRepositoryError> {
|
||||||
|
sqlx::query("UPDATE api_keys SET last_used_at = NOW() WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<Vec<ApiKeyRow>, ApiKeyRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, ApiKeyRecord>(
|
||||||
|
"SELECT id, user_id, prefix, name, scopes, app_id, \
|
||||||
|
expires_at, last_used_at, created_at \
|
||||||
|
FROM api_keys WHERE user_id = $1 \
|
||||||
|
ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, id: ApiKeyId) -> Result<Option<ApiKeyRow>, ApiKeyRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, ApiKeyRecord>(
|
||||||
|
"SELECT id, user_id, prefix, name, scopes, app_id, \
|
||||||
|
expires_at, last_used_at, created_at \
|
||||||
|
FROM api_keys WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_by_id_and_user(
|
||||||
|
&self,
|
||||||
|
id: ApiKeyId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<bool, ApiKeyRepositoryError> {
|
||||||
|
let res = sqlx::query("DELETE FROM api_keys WHERE id = $1 AND user_id = $2")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn expire_all_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<u64, ApiKeyRepositoryError> {
|
||||||
|
let res = sqlx::query(
|
||||||
|
"UPDATE api_keys \
|
||||||
|
SET expires_at = NOW() \
|
||||||
|
WHERE user_id = $1 \
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())",
|
||||||
|
)
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct ApiKeyRecord {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
prefix: String,
|
||||||
|
name: String,
|
||||||
|
scopes: Vec<String>,
|
||||||
|
app_id: Option<uuid::Uuid>,
|
||||||
|
expires_at: Option<DateTime<Utc>>,
|
||||||
|
last_used_at: Option<DateTime<Utc>>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<ApiKeyRecord> for ApiKeyRow {
|
||||||
|
type Error = ApiKeyRepositoryError;
|
||||||
|
fn try_from(r: ApiKeyRecord) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
id: r.id.into(),
|
||||||
|
user_id: r.user_id.into(),
|
||||||
|
prefix: r.prefix,
|
||||||
|
name: r.name,
|
||||||
|
scopes: parse_scopes(r.scopes)?,
|
||||||
|
app_id: r.app_id.map(Into::into),
|
||||||
|
expires_at: r.expires_at,
|
||||||
|
last_used_at: r.last_used_at,
|
||||||
|
created_at: r.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct ApiKeyVerifyRecord {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
hash: String,
|
||||||
|
scopes: Vec<String>,
|
||||||
|
app_id: Option<uuid::Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<ApiKeyVerifyRecord> for ApiKeyVerification {
|
||||||
|
type Error = ApiKeyRepositoryError;
|
||||||
|
fn try_from(r: ApiKeyVerifyRecord) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
id: r.id.into(),
|
||||||
|
user_id: r.user_id.into(),
|
||||||
|
hash: r.hash,
|
||||||
|
scopes: parse_scopes(r.scopes)?,
|
||||||
|
app_id: r.app_id.map(Into::into),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_scopes(raw: Vec<String>) -> Result<Vec<Scope>, ApiKeyRepositoryError> {
|
||||||
|
raw.into_iter()
|
||||||
|
.map(|s| Scope::from_wire(&s).ok_or(ApiKeyRepositoryError::InvalidScope(s)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
251
crates/manager-core/src/api_keys_api.rs
Normal file
251
crates/manager-core/src/api_keys_api.rs
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
//! `/api/v1/admin/api-keys/*` — bearer API key CRUD (blueprint §11.6).
|
||||||
|
//!
|
||||||
|
//! All endpoints are guarded by `require_authenticated`. Capability
|
||||||
|
//! checks: none — every authenticated user manages **their own** keys.
|
||||||
|
//! The repo enforces caller ownership on `delete`, and `list` is
|
||||||
|
//! scoped to the caller's user_id. No instance-level authority is
|
||||||
|
//! exposed (no listing other users' keys, no admin-issued keys for
|
||||||
|
//! another user — those flows belong with the invite system).
|
||||||
|
//!
|
||||||
|
//! Mint semantics:
|
||||||
|
//! * raw token is returned **exactly once** in the POST response and
|
||||||
|
//! never logged. Lose it = mint a new key.
|
||||||
|
//! * `app_id` (optional) binds the key to one app; capability checks
|
||||||
|
//! deny every `App*(other_app)`.
|
||||||
|
//! * scopes containing `instance:*` are rejected when `app_id` is
|
||||||
|
//! set — the combination is irreconcilable.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::{delete, get};
|
||||||
|
use axum::{Extension, Router};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::{ApiKeyId, AppId, Principal, Scope};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::api_key_repo::{ApiKeyRepository, ApiKeyRepositoryError, ApiKeyRow, NewApiKey};
|
||||||
|
use crate::auth::generate_api_key;
|
||||||
|
|
||||||
|
/// Validation bounds for the user-supplied `name` field — keeps the
|
||||||
|
/// dashboard's list view tidy and rejects accidental whole-token
|
||||||
|
/// pastes.
|
||||||
|
const NAME_MIN: usize = 1;
|
||||||
|
const NAME_MAX: usize = 64;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ApiKeysState {
|
||||||
|
pub keys: Arc<dyn ApiKeyRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn api_keys_router(state: ApiKeysState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/api-keys", get(list_keys).post(mint_key))
|
||||||
|
.route("/api-keys/{id}", delete(delete_key))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// DTOs
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct MintApiKeyRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub scopes: Vec<Scope>,
|
||||||
|
/// When set, the key is bound to this app — every `App*(other)`
|
||||||
|
/// capability is denied regardless of role.
|
||||||
|
#[serde(default)]
|
||||||
|
pub app_id: Option<AppId>,
|
||||||
|
/// When set, lookup rejects the key after this instant. Absent =
|
||||||
|
/// never expires (until explicit DELETE).
|
||||||
|
#[serde(default)]
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response body for a freshly-minted key. `raw_token` only appears
|
||||||
|
/// here — `GET /api-keys` returns `ApiKeyDto` without it.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct MintApiKeyResponse {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub key: ApiKeyDto,
|
||||||
|
/// The full wire-format token (`pic_<base32>`). Shown exactly once;
|
||||||
|
/// store it client-side immediately.
|
||||||
|
pub raw_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ApiKeyDto {
|
||||||
|
pub id: ApiKeyId,
|
||||||
|
pub prefix: String,
|
||||||
|
pub name: String,
|
||||||
|
pub scopes: Vec<Scope>,
|
||||||
|
pub app_id: Option<AppId>,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
pub last_used_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ApiKeyRow> for ApiKeyDto {
|
||||||
|
fn from(r: ApiKeyRow) -> Self {
|
||||||
|
Self {
|
||||||
|
id: r.id,
|
||||||
|
prefix: r.prefix,
|
||||||
|
name: r.name,
|
||||||
|
scopes: r.scopes,
|
||||||
|
app_id: r.app_id,
|
||||||
|
expires_at: r.expires_at,
|
||||||
|
last_used_at: r.last_used_at,
|
||||||
|
created_at: r.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn mint_key(
|
||||||
|
State(state): State<ApiKeysState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Json(input): Json<MintApiKeyRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<MintApiKeyResponse>), ApiKeysError> {
|
||||||
|
validate_name(&input.name)?;
|
||||||
|
validate_scopes(&input.scopes, input.app_id)?;
|
||||||
|
|
||||||
|
let minted = generate_api_key().map_err(|e| ApiKeysError::Hash(e.to_string()))?;
|
||||||
|
let row = state
|
||||||
|
.keys
|
||||||
|
.create(NewApiKey {
|
||||||
|
user_id: principal.user_id,
|
||||||
|
hash: minted.hash,
|
||||||
|
prefix: minted.prefix,
|
||||||
|
name: input.name,
|
||||||
|
scopes: input.scopes,
|
||||||
|
app_id: input.app_id,
|
||||||
|
expires_at: input.expires_at,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(MintApiKeyResponse {
|
||||||
|
key: row.into(),
|
||||||
|
raw_token: minted.raw,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_keys(
|
||||||
|
State(state): State<ApiKeysState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
) -> Result<Json<Vec<ApiKeyDto>>, ApiKeysError> {
|
||||||
|
let rows = state.keys.list_for_user(principal.user_id).await?;
|
||||||
|
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_key(
|
||||||
|
State(state): State<ApiKeysState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id): Path<ApiKeyId>,
|
||||||
|
) -> Result<StatusCode, ApiKeysError> {
|
||||||
|
let deleted = state
|
||||||
|
.keys
|
||||||
|
.delete_by_id_and_user(id, principal.user_id)
|
||||||
|
.await?;
|
||||||
|
if !deleted {
|
||||||
|
// 404 covers both "doesn't exist" and "exists but not yours" —
|
||||||
|
// we deliberately don't leak the distinction.
|
||||||
|
return Err(ApiKeysError::NotFound(id));
|
||||||
|
}
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Validation
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn validate_name(s: &str) -> Result<(), ApiKeysError> {
|
||||||
|
let trimmed = s.trim();
|
||||||
|
if trimmed.len() < NAME_MIN || trimmed.len() > NAME_MAX {
|
||||||
|
return Err(ApiKeysError::InvalidName(format!(
|
||||||
|
"name must be {NAME_MIN}-{NAME_MAX} characters after trimming"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_scopes(scopes: &[Scope], app_id: Option<AppId>) -> Result<(), ApiKeysError> {
|
||||||
|
if scopes.is_empty() {
|
||||||
|
return Err(ApiKeysError::InvalidScopes(
|
||||||
|
"scopes must be non-empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Bound key + any instance:* scope → irreconcilable.
|
||||||
|
if app_id.is_some() && scopes.iter().any(|s| s.is_instance()) {
|
||||||
|
return Err(ApiKeysError::InvalidScopes(
|
||||||
|
"bound keys (app_id set) cannot carry instance:* scopes".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ApiKeysError {
|
||||||
|
#[error("api key not found: {0}")]
|
||||||
|
NotFound(ApiKeyId),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
InvalidName(String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
InvalidScopes(String),
|
||||||
|
|
||||||
|
#[error("failed to hash key: {0}")]
|
||||||
|
Hash(String),
|
||||||
|
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Repo(#[from] ApiKeyRepositoryError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiKeysError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, message) = match &self {
|
||||||
|
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||||
|
Self::InvalidName(_) | Self::InvalidScopes(_) => {
|
||||||
|
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||||
|
}
|
||||||
|
Self::Hash(_) => {
|
||||||
|
tracing::error!(error = %self, "api key hash failure");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Repo(ApiKeyRepositoryError::NotFound(_)) => {
|
||||||
|
(StatusCode::NOT_FOUND, self.to_string())
|
||||||
|
}
|
||||||
|
Self::Repo(ApiKeyRepositoryError::InvalidScope(_)) => {
|
||||||
|
tracing::error!(error = %self, "api key row carries an unknown scope");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Repo(ApiKeyRepositoryError::Db(e)) => {
|
||||||
|
tracing::error!(error = %e, "api_keys db error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(json!({ "error": message }))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
331
crates/manager-core/src/app_members_api.rs
Normal file
331
crates/manager-core/src/app_members_api.rs
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
//! `/api/v1/admin/apps/{id_or_slug}/members/*` — CRUD over the
|
||||||
|
//! `app_members` table (blueprint §11.6).
|
||||||
|
//!
|
||||||
|
//! Every endpoint is gated on `Capability::AppAdmin(app_id)` after
|
||||||
|
//! resolving the app from `id_or_slug`. Editors and viewers receive
|
||||||
|
//! 403 from list and never see the dashboard's Members tab.
|
||||||
|
//!
|
||||||
|
//! POST is **non-idempotent on purpose**: a duplicate `(app_id,
|
||||||
|
//! user_id)` returns 409 rather than upsert-200, so the UI can show
|
||||||
|
//! "already a member — promote / demote them instead" cleanly. Role
|
||||||
|
//! changes go through PATCH.
|
||||||
|
//!
|
||||||
|
//! No last-app-admin guard: owners always implicitly satisfy
|
||||||
|
//! `Capability::AppAdmin(_)` (authz::role_grants), so removing the
|
||||||
|
//! final explicit `app_admin` membership cannot orphan an app.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::{get, patch};
|
||||||
|
use axum::{Extension, Router};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::{AdminUserId, AppRole, InstanceRole, Principal};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
|
||||||
|
use crate::app_members_repo::{
|
||||||
|
AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow,
|
||||||
|
};
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
|
use crate::repo::ScriptRepositoryError;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppMembersState {
|
||||||
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
pub users: Arc<dyn AdminUserRepository>,
|
||||||
|
pub members: Arc<dyn AppMembersRepository>,
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn app_members_router(state: AppMembersState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}/members",
|
||||||
|
get(list_members).post(grant_member),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}/members/{user_id}",
|
||||||
|
patch(patch_member).delete(remove_member),
|
||||||
|
)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// DTOs
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AppMemberDto {
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub role: AppRole,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppMembershipDetail> for AppMemberDto {
|
||||||
|
fn from(d: AppMembershipDetail) -> Self {
|
||||||
|
Self {
|
||||||
|
user_id: d.user_id,
|
||||||
|
username: d.username,
|
||||||
|
email: d.email,
|
||||||
|
instance_role: d.instance_role,
|
||||||
|
is_active: d.is_active,
|
||||||
|
role: d.role,
|
||||||
|
created_at: d.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compose a DTO from an `AdminUserRow` (fetched for validation) and
|
||||||
|
/// the `AppMembershipRow` returned by `upsert`. Saves a re-fetch on
|
||||||
|
/// POST/PATCH at the cost of trusting the two inputs reference the
|
||||||
|
/// same user_id — caller's responsibility.
|
||||||
|
fn compose_dto(user: AdminUserRow, membership: AppMembershipRow) -> AppMemberDto {
|
||||||
|
AppMemberDto {
|
||||||
|
user_id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
instance_role: user.instance_role,
|
||||||
|
is_active: user.is_active,
|
||||||
|
role: membership.role,
|
||||||
|
created_at: membership.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct GrantMemberRequest {
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub role: AppRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PatchMemberRequest {
|
||||||
|
pub role: AppRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_members(
|
||||||
|
State(s): State<AppMembersState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<AppMemberDto>>, AppMembersApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
let rows = s.members.list_for_app_enriched(app.id).await?;
|
||||||
|
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn grant_member(
|
||||||
|
State(s): State<AppMembersState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<GrantMemberRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<AppMemberDto>), AppMembersApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
|
||||||
|
let user = s
|
||||||
|
.users
|
||||||
|
.get(input.user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppMembersApiError::UserNotFound(input.user_id))?;
|
||||||
|
validate_grant_target(&user)?;
|
||||||
|
|
||||||
|
// Atomic insert — if a row already exists, returns None and we 409.
|
||||||
|
// Avoids the find-then-upsert race where two concurrent POSTs would
|
||||||
|
// both pass the existence check and the second `upsert` would
|
||||||
|
// silently rewrite the role.
|
||||||
|
let row = s
|
||||||
|
.members
|
||||||
|
.try_insert(app.id, user.id, input.role)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppMembersApiError::AlreadyMember {
|
||||||
|
username: user.username.clone(),
|
||||||
|
})?;
|
||||||
|
Ok((StatusCode::CREATED, Json(compose_dto(user, row))))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_member(
|
||||||
|
State(s): State<AppMembersState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
||||||
|
Json(input): Json<PatchMemberRequest>,
|
||||||
|
) -> Result<Json<AppMemberDto>, AppMembersApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
|
||||||
|
let user_id = AdminUserId::from(user_id);
|
||||||
|
let user = s
|
||||||
|
.users
|
||||||
|
.get(user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppMembersApiError::UserNotFound(user_id))?;
|
||||||
|
|
||||||
|
// Atomic update — returns None if no row exists, so 404 is decided
|
||||||
|
// by the same statement that does the write. Eliminates the
|
||||||
|
// find-then-upsert race where a concurrent DELETE between the two
|
||||||
|
// calls would let PATCH silently re-create the row.
|
||||||
|
let row = s
|
||||||
|
.members
|
||||||
|
.update_role(app.id, user_id, input.role)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppMembersApiError::MembershipNotFound)?;
|
||||||
|
Ok(Json(compose_dto(user, row)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_member(
|
||||||
|
State(s): State<AppMembersState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
||||||
|
) -> Result<StatusCode, AppMembersApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
s.members.remove(app.id, AdminUserId::from(user_id)).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Validation + helpers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn validate_grant_target(user: &AdminUserRow) -> Result<(), AppMembersApiError> {
|
||||||
|
if !user.is_active {
|
||||||
|
return Err(AppMembersApiError::TargetInactive {
|
||||||
|
username: user.username.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if user.instance_role != InstanceRole::Member {
|
||||||
|
return Err(AppMembersApiError::TargetNotMember {
|
||||||
|
username: user.username.clone(),
|
||||||
|
instance_role: user.instance_role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_app(
|
||||||
|
apps: &dyn AppRepository,
|
||||||
|
ident: &str,
|
||||||
|
) -> Result<picloud_shared::App, AppMembersApiError> {
|
||||||
|
crate::app_repo::resolve_app(apps, ident)
|
||||||
|
.await?
|
||||||
|
.map(|l| l.app)
|
||||||
|
.ok_or_else(|| AppMembersApiError::AppNotFound(ident.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppMembersApiError {
|
||||||
|
#[error("app not found: {0}")]
|
||||||
|
AppNotFound(String),
|
||||||
|
|
||||||
|
#[error("user not found: {0}")]
|
||||||
|
UserNotFound(AdminUserId),
|
||||||
|
|
||||||
|
#[error("no membership exists for this user on this app")]
|
||||||
|
MembershipNotFound,
|
||||||
|
|
||||||
|
#[error("{username} is already a member of this app — use PATCH to change their role")]
|
||||||
|
AlreadyMember { username: String },
|
||||||
|
|
||||||
|
#[error("{username} is deactivated and cannot be added as a member")]
|
||||||
|
TargetInactive { username: String },
|
||||||
|
|
||||||
|
#[error(
|
||||||
|
"{username} has instance_role {instance_role:?} and already has implicit access \
|
||||||
|
on every app — no explicit membership needed"
|
||||||
|
)]
|
||||||
|
TargetNotMember {
|
||||||
|
username: String,
|
||||||
|
instance_role: InstanceRole,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Members(#[from] AppMembersRepositoryError),
|
||||||
|
|
||||||
|
#[error("user repository error: {0}")]
|
||||||
|
Users(#[from] AdminUserRepositoryError),
|
||||||
|
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Apps(#[from] ScriptRepositoryError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for AppMembersApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppMembersApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, body) = match &self {
|
||||||
|
Self::AppNotFound(_)
|
||||||
|
| Self::UserNotFound(_)
|
||||||
|
| Self::MembershipNotFound
|
||||||
|
| Self::Apps(ScriptRepositoryError::NotFound(_)) => {
|
||||||
|
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
|
Self::AlreadyMember { .. } | Self::Apps(ScriptRepositoryError::Conflict(_)) => {
|
||||||
|
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
|
Self::TargetInactive { .. } | Self::TargetNotMember { .. } => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
json!({ "error": self.to_string() }),
|
||||||
|
),
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "app members authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Members(e) => {
|
||||||
|
tracing::error!(error = %e, "app members repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Users(e) => {
|
||||||
|
tracing::error!(error = %e, "admin users repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Apps(ScriptRepositoryError::Db(e)) => {
|
||||||
|
tracing::error!(error = %e, "apps repo error in app_members");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
340
crates/manager-core/src/app_members_repo.rs
Normal file
340
crates/manager-core/src/app_members_repo.rs
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
//! CRUD over the `app_members` table — explicit per-(user, app) role
|
||||||
|
//! grants for `member` instance-role users. Owners and admins do NOT
|
||||||
|
//! appear here; their app authority is implicit (see authz.rs).
|
||||||
|
//!
|
||||||
|
//! Doubles as the production `AuthzRepo` implementation: the
|
||||||
|
//! membership lookup `can()` needs is the same single-row SELECT as
|
||||||
|
//! `find` here.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::authz::{AuthzError, AuthzRepo};
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppMembersRepositoryError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
|
||||||
|
#[error("membership row not found: app={app_id}, user={user_id}")]
|
||||||
|
NotFound { app_id: AppId, user_id: AdminUserId },
|
||||||
|
|
||||||
|
#[error("invalid app_role stored in DB: {0}")]
|
||||||
|
InvalidRole(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of `app_members`. Returned by `list_for_user` / `list_for_app`
|
||||||
|
/// so handlers can render the cross-reference without joining to apps
|
||||||
|
/// or admin_users themselves.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppMembershipRow {
|
||||||
|
pub app_id: AppId,
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub role: AppRole,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `app_members` row joined with `admin_users` so the dashboard's
|
||||||
|
/// Members tab can render usernames / emails / status without an N+1
|
||||||
|
/// fetch per row. Drives `GET /apps/{id}/members`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppMembershipDetail {
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub role: AppRole,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AppMembersRepository: Send + Sync {
|
||||||
|
/// Single (user, app) lookup. Returns `None` for non-members and
|
||||||
|
/// for unrelated apps. This is the hot path for `authz::can`.
|
||||||
|
async fn find(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Upsert a membership. Used both for first-time grants and role
|
||||||
|
/// promotions/demotions on an existing row.
|
||||||
|
async fn upsert(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<AppMembershipRow, AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Atomic insert. Returns `Some(row)` on success, `None` if a
|
||||||
|
/// membership already exists. Lets the HTTP handler return 409
|
||||||
|
/// without a separate `find` round-trip (no TOCTOU between check
|
||||||
|
/// and insert).
|
||||||
|
async fn try_insert(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Atomic role update. Returns `Some(row)` on success, `None` if no
|
||||||
|
/// membership row exists. Lets PATCH return 404 without a separate
|
||||||
|
/// `find` round-trip (no TOCTOU between check and update).
|
||||||
|
async fn update_role(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Remove a membership. No-op (Ok) when the row doesn't exist —
|
||||||
|
/// the user wasn't a member, which is the desired post-condition.
|
||||||
|
async fn remove(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<(), AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Every membership the user holds. Drives the membership-filtered
|
||||||
|
/// list endpoints (`GET /admin/apps`, `GET /admin/scripts` for
|
||||||
|
/// `member` callers).
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Every membership on a given app. Used by `GET
|
||||||
|
/// /admin/apps/{id}/members` once that surface lands; included now
|
||||||
|
/// so the trait is complete enough for tests.
|
||||||
|
async fn list_for_app(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Like `list_for_app` but joined with `admin_users` so the
|
||||||
|
/// dashboard can render member rows in one round-trip. Ordered by
|
||||||
|
/// username for a stable list.
|
||||||
|
async fn list_for_app_enriched(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresAppMembersRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresAppMembersRepository {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AppMembersRepository for PostgresAppMembersRepository {
|
||||||
|
async fn find(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AppMembersRepositoryError> {
|
||||||
|
let row: Option<(String,)> =
|
||||||
|
sqlx::query_as("SELECT role FROM app_members WHERE user_id = $1 AND app_id = $2")
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(|(role,)| {
|
||||||
|
AppRole::from_db_str(&role).ok_or(AppMembersRepositoryError::InvalidRole(role))
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<AppMembershipRow, AppMembersRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppMembershipRecord>(
|
||||||
|
"INSERT INTO app_members (app_id, user_id, role) \
|
||||||
|
VALUES ($1, $2, $3) \
|
||||||
|
ON CONFLICT (app_id, user_id) DO UPDATE SET role = EXCLUDED.role \
|
||||||
|
RETURNING app_id, user_id, role, created_at",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.bind(role.as_str())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.try_into()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<(), AppMembersRepositoryError> {
|
||||||
|
sqlx::query("DELETE FROM app_members WHERE app_id = $1 AND user_id = $2")
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn try_insert(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppMembershipRecord>(
|
||||||
|
"INSERT INTO app_members (app_id, user_id, role) \
|
||||||
|
VALUES ($1, $2, $3) \
|
||||||
|
ON CONFLICT (app_id, user_id) DO NOTHING \
|
||||||
|
RETURNING app_id, user_id, role, created_at",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.bind(role.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_role(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppMembershipRecord>(
|
||||||
|
"UPDATE app_members SET role = $1 \
|
||||||
|
WHERE app_id = $2 AND user_id = $3 \
|
||||||
|
RETURNING app_id, user_id, role, created_at",
|
||||||
|
)
|
||||||
|
.bind(role.as_str())
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AppMembershipRecord>(
|
||||||
|
"SELECT app_id, user_id, role, created_at \
|
||||||
|
FROM app_members WHERE user_id = $1 \
|
||||||
|
ORDER BY created_at",
|
||||||
|
)
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_app(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AppMembershipRecord>(
|
||||||
|
"SELECT app_id, user_id, role, created_at \
|
||||||
|
FROM app_members WHERE app_id = $1 \
|
||||||
|
ORDER BY created_at",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_app_enriched(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AppMembershipDetailRecord>(
|
||||||
|
"SELECT au.id, au.username, au.email, au.instance_role, au.is_active, \
|
||||||
|
am.role, am.created_at \
|
||||||
|
FROM app_members am \
|
||||||
|
JOIN admin_users au ON au.id = am.user_id \
|
||||||
|
WHERE am.app_id = $1 \
|
||||||
|
ORDER BY au.username",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forwarding impl so the Postgres repo satisfies `AuthzRepo` directly
|
||||||
|
/// — handlers store a single `Arc<dyn AppMembersRepository>` and pass
|
||||||
|
/// it to `authz::can` without casting.
|
||||||
|
#[async_trait]
|
||||||
|
impl AuthzRepo for PostgresAppMembersRepository {
|
||||||
|
async fn membership(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
self.find(user_id, app_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthzError::Repo(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AppMembershipRecord {
|
||||||
|
app_id: uuid::Uuid,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
role: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<AppMembershipRecord> for AppMembershipRow {
|
||||||
|
type Error = AppMembersRepositoryError;
|
||||||
|
fn try_from(r: AppMembershipRecord) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
app_id: r.app_id.into(),
|
||||||
|
user_id: r.user_id.into(),
|
||||||
|
role: AppRole::from_db_str(&r.role)
|
||||||
|
.ok_or(AppMembersRepositoryError::InvalidRole(r.role))?,
|
||||||
|
created_at: r.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AppMembershipDetailRecord {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
username: String,
|
||||||
|
email: Option<String>,
|
||||||
|
instance_role: String,
|
||||||
|
is_active: bool,
|
||||||
|
role: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<AppMembershipDetailRecord> for AppMembershipDetail {
|
||||||
|
type Error = AppMembersRepositoryError;
|
||||||
|
fn try_from(r: AppMembershipDetailRecord) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
user_id: r.id.into(),
|
||||||
|
username: r.username,
|
||||||
|
email: r.email,
|
||||||
|
instance_role: InstanceRole::from_db_str(&r.instance_role)
|
||||||
|
.ok_or(AppMembersRepositoryError::InvalidRole(r.instance_role))?,
|
||||||
|
is_active: r.is_active,
|
||||||
|
role: AppRole::from_db_str(&r.role)
|
||||||
|
.ok_or(AppMembersRepositoryError::InvalidRole(r.role))?,
|
||||||
|
created_at: r.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
450
crates/manager-core/src/app_repo.rs
Normal file
450
crates/manager-core/src/app_repo.rs
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
//! 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::{AdminUserId, App, AppId};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a free-form path param (UUID *or* slug *or* historical slug)
|
||||||
|
/// to an `AppLookup`. UUID lookups never set `redirected`; slug lookups
|
||||||
|
/// fall through to `app_slug_history` and set `redirected: true` when
|
||||||
|
/// they hit it.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(None)` when nothing matches — callers map that to their
|
||||||
|
/// own not-found error variant.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Propagates any underlying repository error.
|
||||||
|
pub async fn resolve_app(
|
||||||
|
apps: &dyn AppRepository,
|
||||||
|
ident: &str,
|
||||||
|
) -> Result<Option<AppLookup>, ScriptRepositoryError> {
|
||||||
|
if let Ok(uuid) = ident.parse::<Uuid>() {
|
||||||
|
return Ok(apps
|
||||||
|
.get_by_id(AppId::from(uuid))
|
||||||
|
.await?
|
||||||
|
.map(|app| AppLookup {
|
||||||
|
app,
|
||||||
|
redirected: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
apps.get_by_slug_or_history(ident).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AppRepository: Send + Sync {
|
||||||
|
/// Every app on the instance. For owner/admin callers — `member`
|
||||||
|
/// users go through `list_for_user`.
|
||||||
|
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||||
|
/// Only apps the user has an `app_members` row for. Drives the
|
||||||
|
/// membership-filtered `GET /admin/apps` for `member` callers.
|
||||||
|
async fn list_for_user(&self, user_id: AdminUserId) -> 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>;
|
||||||
|
/// Delete the app along with all its scripts (which in turn cascades
|
||||||
|
/// routes and execution logs via their `script_id` FK). Domains and
|
||||||
|
/// app-slug-history rows cascade off the app row itself. Runs in a
|
||||||
|
/// single transaction so a partial delete cannot be observed.
|
||||||
|
async fn delete_cascade(&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 list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||||
|
FROM apps a \
|
||||||
|
JOIN app_members m ON m.app_id = a.id \
|
||||||
|
WHERE m.user_id = $1 \
|
||||||
|
ORDER BY a.name",
|
||||||
|
)
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.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 delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
sqlx::query("DELETE FROM scripts WHERE app_id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let res = sqlx::query("DELETE FROM apps WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"app {id} not found"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
620
crates/manager-core/src/apps_api.rs
Normal file
620
crates/manager-core/src/apps_api.rs
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
//! `/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, Query, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::{delete, get, post};
|
||||||
|
use axum::{Extension, Router};
|
||||||
|
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
||||||
|
use picloud_shared::{App, AppDomain, AppId, AppRole, InstanceRole, Principal};
|
||||||
|
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::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||||
|
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>,
|
||||||
|
/// Capability gate — Phase 3.5.
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query params for `DELETE /apps/{id_or_slug}`. `force=true` opts into
|
||||||
|
/// a cascading delete that also removes every script in the app (and
|
||||||
|
/// thereby their routes and execution logs). Without it the request is
|
||||||
|
/// rejected when the app still contains scripts.
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
pub struct DeleteAppQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
pub force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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>,
|
||||||
|
/// The caller's role on this app, used by the dashboard to decide
|
||||||
|
/// whether to render admin-only surfaces (Members tab, settings).
|
||||||
|
/// `Owner` maps to `app_admin`, `Admin` to `editor` (both implicit
|
||||||
|
/// per blueprint §11.6); `Member` carries its explicit
|
||||||
|
/// `app_members.role`.
|
||||||
|
pub my_role: Option<AppRole>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_apps(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
) -> Result<Json<Vec<App>>, AppsApiError> {
|
||||||
|
// Member callers see only apps they're a member of; owner/admin
|
||||||
|
// see everything. Filter at the SQL layer (not just in the
|
||||||
|
// dashboard) — that's the strict-isolation guarantee from §11.6.
|
||||||
|
let apps = if principal.instance_role == InstanceRole::Member {
|
||||||
|
s.apps.list_for_user(principal.user_id).await?
|
||||||
|
} else {
|
||||||
|
s.apps.list().await?
|
||||||
|
};
|
||||||
|
Ok(Json(apps))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_app(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Json(input): Json<CreateAppRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<App>), AppsApiError> {
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
|
||||||
|
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>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<AppLookupResponse>, AppsApiError> {
|
||||||
|
let lookup = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppRead(lookup.app.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let redirect_to = if lookup.redirected {
|
||||||
|
Some(lookup.app.slug.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let my_role = compute_my_role(s.authz.as_ref(), &principal, lookup.app.id).await?;
|
||||||
|
Ok(Json(AppLookupResponse {
|
||||||
|
app: lookup.app,
|
||||||
|
redirect_to,
|
||||||
|
my_role,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the caller's effective `AppRole` on a specific app. Mirrors
|
||||||
|
/// the implicit-grant logic in `authz::role_grants` but returns the
|
||||||
|
/// role itself (for UI gating) rather than a yes/no decision. `Owner`
|
||||||
|
/// is implicit `AppAdmin` everywhere; `Admin` is implicit `Editor`
|
||||||
|
/// everywhere; `Member` consults `app_members`.
|
||||||
|
async fn compute_my_role(
|
||||||
|
authz: &dyn AuthzRepo,
|
||||||
|
principal: &Principal,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AppsApiError> {
|
||||||
|
match principal.instance_role {
|
||||||
|
InstanceRole::Owner => Ok(Some(AppRole::AppAdmin)),
|
||||||
|
InstanceRole::Admin => Ok(Some(AppRole::Editor)),
|
||||||
|
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_app(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
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;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppAdmin(current.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 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>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Query(q): Query<DeleteAppQuery>,
|
||||||
|
) -> Result<StatusCode, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
|
||||||
|
if q.force {
|
||||||
|
s.apps.delete_cascade(app.id).await?;
|
||||||
|
} else {
|
||||||
|
// 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>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<SlugCheckRequest>,
|
||||||
|
) -> Result<Json<SlugCheckResponse>, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
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>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<AppDomain>>, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppRead(app.id)).await?;
|
||||||
|
Ok(Json(s.domains.list_for_app(app.id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_domain(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
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;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppManageDomains(app.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
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>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((id_or_slug, domain_id)): Path<(String, Uuid)>,
|
||||||
|
) -> Result<StatusCode, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppManageDomains(app.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
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> {
|
||||||
|
crate::app_repo::resolve_app(apps, 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("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Repo(#[from] ScriptRepositoryError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for AppsApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzError> for AppsApiError {
|
||||||
|
fn from(e: AuthzError) -> Self {
|
||||||
|
Self::AuthzRepo(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "apps authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
231
crates/manager-core/src/auth.rs
Normal file
231
crates/manager-core/src/auth.rs
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
//! 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 data_encoding::BASE32_NOPAD;
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// API key generation (Phase 3.5)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Wire-format prefix that marks a Bearer value as an API key (vs. a
|
||||||
|
/// session token). Mirrors `auth_middleware::API_KEY_PREFIX` so the
|
||||||
|
/// generator and the verifier agree.
|
||||||
|
pub const API_KEY_WIRE_PREFIX: &str = "pic_";
|
||||||
|
|
||||||
|
/// Length of the indexed prefix portion (the first 8 chars of the
|
||||||
|
/// `pic_`-stripped body). Mirrors `auth_middleware::API_KEY_PREFIX_LEN`.
|
||||||
|
pub const API_KEY_INDEX_PREFIX_LEN: usize = 8;
|
||||||
|
|
||||||
|
/// Newly minted API key — returned exactly once by `POST /api/v1/admin/api-keys`.
|
||||||
|
///
|
||||||
|
/// * `raw` is the full wire-format token (`pic_<base32>`) shown to the
|
||||||
|
/// caller in the response body and never persisted.
|
||||||
|
/// * `prefix` is the indexed 8-char slice persisted to
|
||||||
|
/// `api_keys.prefix` for lookup.
|
||||||
|
/// * `hash` is the Argon2id PHC string persisted to `api_keys.hash`;
|
||||||
|
/// covers the body after `pic_` (i.e., `raw[4..]`).
|
||||||
|
pub struct GeneratedApiKey {
|
||||||
|
pub raw: String,
|
||||||
|
pub prefix: String,
|
||||||
|
pub hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a fresh API key. 32 random bytes → unpadded base32, then
|
||||||
|
/// `pic_` prefix on the wire. The first 8 base32 chars are the index
|
||||||
|
/// key; everything after `pic_` is what the verifier hashes.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `argon2::password_hash::Error` if the Argon2 hash step
|
||||||
|
/// fails (which it shouldn't under normal conditions).
|
||||||
|
pub fn generate_api_key() -> Result<GeneratedApiKey, argon2::password_hash::Error> {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
let body = BASE32_NOPAD.encode(&bytes);
|
||||||
|
debug_assert!(
|
||||||
|
body.len() >= API_KEY_INDEX_PREFIX_LEN,
|
||||||
|
"32 bytes base32 must exceed the 8-char prefix length"
|
||||||
|
);
|
||||||
|
let prefix = body[..API_KEY_INDEX_PREFIX_LEN].to_string();
|
||||||
|
let salt = SaltString::generate(&mut ArgonRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(body.as_bytes(), &salt)?
|
||||||
|
.to_string();
|
||||||
|
let raw = format!("{API_KEY_WIRE_PREFIX}{body}");
|
||||||
|
Ok(GeneratedApiKey { raw, prefix, hash })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a wire-format token body (the portion *after* `pic_`)
|
||||||
|
/// against a stored Argon2id hash. Convenience wrapper around
|
||||||
|
/// `verify_password` named to reflect its caller.
|
||||||
|
#[must_use]
|
||||||
|
pub fn verify_api_key(stored_hash: &str, presented_body: &str) -> bool {
|
||||||
|
verify_password(stored_hash, presented_body)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_api_key_round_trip() {
|
||||||
|
let key = generate_api_key().expect("mint");
|
||||||
|
assert!(
|
||||||
|
key.raw.starts_with(API_KEY_WIRE_PREFIX),
|
||||||
|
"raw must carry the pic_ prefix"
|
||||||
|
);
|
||||||
|
let body = key
|
||||||
|
.raw
|
||||||
|
.strip_prefix(API_KEY_WIRE_PREFIX)
|
||||||
|
.expect("starts with prefix");
|
||||||
|
assert_eq!(
|
||||||
|
&body[..API_KEY_INDEX_PREFIX_LEN],
|
||||||
|
key.prefix,
|
||||||
|
"stored prefix matches the first 8 chars of the body"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
verify_api_key(&key.hash, body),
|
||||||
|
"Argon2 verify must accept the original body"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!verify_api_key(&key.hash, "wrong-body-entirely"),
|
||||||
|
"Argon2 verify must reject anything else"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_api_key_unique() {
|
||||||
|
let a = generate_api_key().expect("mint a");
|
||||||
|
let b = generate_api_key().expect("mint b");
|
||||||
|
assert_ne!(a.raw, b.raw);
|
||||||
|
assert_ne!(a.hash, b.hash);
|
||||||
|
assert_ne!(
|
||||||
|
a.prefix, b.prefix,
|
||||||
|
"32 random bytes → prefix collision is negligible"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
269
crates/manager-core/src/auth_api.rs
Normal file
269
crates/manager-core/src/auth_api.rs
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
//! `/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, InstanceRole};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use picloud_shared::Principal;
|
||||||
|
|
||||||
|
use crate::auth::{generate_session_token, hash_token, verify_password};
|
||||||
|
use crate::auth_middleware::{require_authenticated, AuthState, 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_authenticated));
|
||||||
|
|
||||||
|
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,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub email: Option<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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// username from creds is discarded — the re-fetch below carries the
|
||||||
|
// canonical row used in the response DTO.
|
||||||
|
let (stored_hash, user_id, is_active) = match creds {
|
||||||
|
Some(c) => (c.password_hash, Some(c.id), c.is_active),
|
||||||
|
None => (DUMMY_HASH.to_string(), None, 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();
|
||||||
|
|
||||||
|
// Re-fetch the full row so the login response carries the same
|
||||||
|
// shape /me does (instance_role, email). The credentials struct
|
||||||
|
// intentionally omits email; one extra query per login is fine.
|
||||||
|
let user_row = match state.users.get(user_id).await {
|
||||||
|
Ok(Some(row)) => row,
|
||||||
|
Ok(None) => return invalid_credentials(),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_users lookup after login failed");
|
||||||
|
return internal_error();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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_row.id,
|
||||||
|
username: user_row.username,
|
||||||
|
instance_role: user_row.instance_role,
|
||||||
|
email: user_row.email,
|
||||||
|
},
|
||||||
|
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(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
) -> Response {
|
||||||
|
// /me consumes the resolved Principal directly; we re-fetch the
|
||||||
|
// user row only to surface a fresh username (it can change via
|
||||||
|
// PATCH while a session/key is still valid).
|
||||||
|
match state.users.get(principal.user_id).await {
|
||||||
|
Ok(Some(row)) => Json(AdminUserDto {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
instance_role: row.instance_role,
|
||||||
|
email: row.email,
|
||||||
|
})
|
||||||
|
.into_response(),
|
||||||
|
Ok(None) => invalid_credentials(),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_users lookup for /me failed");
|
||||||
|
internal_error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
331
crates/manager-core/src/auth_bootstrap.rs
Normal file
331
crates/manager-core/src/auth_bootstrap.rs
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
//! 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),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bootstrap admin is always seeded as Owner — Phase 3.5 keys the
|
||||||
|
// first row to full instance control. Subsequent admins minted via
|
||||||
|
// the API default to Admin and can be promoted explicitly.
|
||||||
|
repo.create(
|
||||||
|
&username,
|
||||||
|
&password_hash,
|
||||||
|
picloud_shared::InstanceRole::Owner,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.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, InstanceRole};
|
||||||
|
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,
|
||||||
|
instance_role: InstanceRole,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let row = AdminUserRow {
|
||||||
|
id: AdminUserId::new(),
|
||||||
|
username: username.to_string(),
|
||||||
|
is_active: true,
|
||||||
|
instance_role,
|
||||||
|
email: email.map(str::to_string),
|
||||||
|
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 update_email(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
_e: Option<&str>,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn update_instance_role(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
_r: InstanceRole,
|
||||||
|
) -> 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!()
|
||||||
|
}
|
||||||
|
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn count_other_active_owners(
|
||||||
|
&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", InstanceRole::Owner, None)
|
||||||
|
.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
348
crates/manager-core/src/auth_middleware.rs
Normal file
348
crates/manager-core/src/auth_middleware.rs
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
//! Authentication middleware — resolves the caller's `Principal` from
|
||||||
|
//! either a session cookie / Bearer session-token OR an API key
|
||||||
|
//! (`Authorization: Bearer pic_…`). Both paths converge on the same
|
||||||
|
//! request extension so downstream handlers see one shape.
|
||||||
|
//!
|
||||||
|
//! Capability checks live in `crate::authz` and are called per-handler
|
||||||
|
//! (after the relevant resource is loaded, so the capability binds to
|
||||||
|
//! the actual resource's `app_id`). This middleware is gate-only: it
|
||||||
|
//! ensures *some* `Principal` is attached, or returns 401.
|
||||||
|
//!
|
||||||
|
//! Token discriminator: the `pic_` prefix on a Bearer value selects
|
||||||
|
//! the API-key path; anything else (raw 32-byte base64-url-encoded
|
||||||
|
//! string) takes the session path. The session cookie can only ever
|
||||||
|
//! carry a session token (cookies are never API keys).
|
||||||
|
|
||||||
|
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, Principal};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::admin_session_repo::AdminSessionRepository;
|
||||||
|
use crate::admin_user_repo::AdminUserRepository;
|
||||||
|
use crate::api_key_repo::{ApiKeyRepository, ApiKeyVerification};
|
||||||
|
use crate::auth::{hash_token, verify_password};
|
||||||
|
|
||||||
|
pub const SESSION_COOKIE: &str = "picloud_session";
|
||||||
|
|
||||||
|
/// Prefix on the wire that selects the API-key path. The body that
|
||||||
|
/// follows is `base32(32 random bytes)`; the first 8 chars of the body
|
||||||
|
/// index into `api_keys.prefix` for verification.
|
||||||
|
pub const API_KEY_PREFIX: &str = "pic_";
|
||||||
|
|
||||||
|
/// Length of the indexed prefix portion of an API key (the 8 chars
|
||||||
|
/// immediately after `pic_`). Schema-side index is on this slice.
|
||||||
|
pub const API_KEY_PREFIX_LEN: usize = 8;
|
||||||
|
|
||||||
|
/// Shared state for auth: the user / session / API-key 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 keys: Arc<dyn ApiKeyRepository>,
|
||||||
|
pub ttl: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy request-extension alias retained so the (only remaining)
|
||||||
|
/// handler that pulled `AuthedAdmin` out — `GET /admin/auth/me` —
|
||||||
|
/// keeps compiling during the migration. New handlers should pull
|
||||||
|
/// `Extension<Principal>` directly.
|
||||||
|
#[deprecated(note = "use Extension<Principal> directly")]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuthedAdmin {
|
||||||
|
pub id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Middleware entry point. Wire with
|
||||||
|
/// `axum::middleware::from_fn_with_state(auth_state, require_authenticated)`.
|
||||||
|
/// Inserts `Principal` (and the legacy `AuthedAdmin`) as request
|
||||||
|
/// extensions on success; returns 401 on any failure mode.
|
||||||
|
pub async fn require_authenticated(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
mut req: Request<Body>,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
let Some(token) = extract_token(&req) else {
|
||||||
|
return unauthorized();
|
||||||
|
};
|
||||||
|
let principal = match resolve_principal(&state, &token).await {
|
||||||
|
Ok(Some(p)) => p,
|
||||||
|
Ok(None) => return unauthorized(),
|
||||||
|
Err(InternalError) => return internal_error(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let username_for_legacy = username_for(&state, principal.user_id).await;
|
||||||
|
req.extensions_mut().insert(principal.clone());
|
||||||
|
#[allow(deprecated)]
|
||||||
|
if let Some(username) = username_for_legacy {
|
||||||
|
req.extensions_mut().insert(AuthedAdmin {
|
||||||
|
id: principal.user_id,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next.run(req).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backwards-compatible alias — the single callsite that still names
|
||||||
|
/// `require_admin` keeps working without an immediate rename. New
|
||||||
|
/// wiring should call `require_authenticated`.
|
||||||
|
#[deprecated(note = "renamed to require_authenticated")]
|
||||||
|
pub async fn require_admin(state: State<AuthState>, req: Request<Body>, next: Next) -> Response {
|
||||||
|
require_authenticated(state, req, next).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether the token is an API key (pic_ prefix) or a session
|
||||||
|
/// token, then resolve the corresponding `Principal`. `Ok(None)`
|
||||||
|
/// means the token was structurally valid but didn't match any active
|
||||||
|
/// credential; `Err(InternalError)` means a DB blip.
|
||||||
|
async fn resolve_principal(
|
||||||
|
state: &AuthState,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<Option<Principal>, InternalError> {
|
||||||
|
if let Some(rest) = token.strip_prefix(API_KEY_PREFIX) {
|
||||||
|
return verify_api_key(state, rest).await;
|
||||||
|
}
|
||||||
|
verify_session(state, token).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_session(
|
||||||
|
state: &AuthState,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<Option<Principal>, InternalError> {
|
||||||
|
let token_hash = hash_token(token);
|
||||||
|
|
||||||
|
let lookup = match state.sessions.lookup(&token_hash).await {
|
||||||
|
Ok(Some(l)) => l,
|
||||||
|
Ok(None) => return Ok(None),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_sessions lookup failed");
|
||||||
|
return Err(InternalError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = match state.users.get(lookup.user_id).await {
|
||||||
|
Ok(Some(u)) if u.is_active => u,
|
||||||
|
Ok(_) => return Ok(None),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_users lookup failed");
|
||||||
|
return Err(InternalError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sliding-window bump — inline so a DB blip surfaces as 500 rather
|
||||||
|
// than silent stale sessions. Same shape as Phase 3a.
|
||||||
|
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 Err(InternalError);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(Principal {
|
||||||
|
user_id: user.id,
|
||||||
|
instance_role: user.instance_role,
|
||||||
|
scopes: None,
|
||||||
|
app_binding: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API-key verification path. `rest` is the portion of the bearer
|
||||||
|
/// value *after* `pic_`. We slice off the first 8 chars as the
|
||||||
|
/// indexed lookup key, then Argon2id-verify each candidate's hash
|
||||||
|
/// against the full `rest`. At most one match is expected; multiple
|
||||||
|
/// candidates with the same prefix is statistically negligible but
|
||||||
|
/// handled correctly (verify each, take the first match).
|
||||||
|
async fn verify_api_key(state: &AuthState, rest: &str) -> Result<Option<Principal>, InternalError> {
|
||||||
|
if rest.len() <= API_KEY_PREFIX_LEN {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let prefix = &rest[..API_KEY_PREFIX_LEN];
|
||||||
|
|
||||||
|
let candidates = match state.keys.find_active_by_prefix(prefix).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "api_keys lookup failed");
|
||||||
|
return Err(InternalError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let matched: Option<ApiKeyVerification> = candidates
|
||||||
|
.into_iter()
|
||||||
|
.find(|c| verify_password(&c.hash, rest));
|
||||||
|
let Some(matched) = matched else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve the owning user. is_active = false → reject even if the
|
||||||
|
// key itself hasn't been expired yet (the expire_all_for_user
|
||||||
|
// cascade on deactivation is the primary defense; this is the
|
||||||
|
// belt-and-suspenders check at request time).
|
||||||
|
let user = match state.users.get(matched.user_id).await {
|
||||||
|
Ok(Some(u)) if u.is_active => u,
|
||||||
|
Ok(_) => return Ok(None),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_users lookup for api key failed");
|
||||||
|
return Err(InternalError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = state.keys.touch_last_used(matched.id).await {
|
||||||
|
tracing::error!(?err, "api_keys touch_last_used failed");
|
||||||
|
// Soft-fail: a timestamp blip should not invalidate the
|
||||||
|
// request. Continue with the resolved Principal.
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(Principal {
|
||||||
|
user_id: user.id,
|
||||||
|
instance_role: user.instance_role,
|
||||||
|
scopes: Some(matched.scopes),
|
||||||
|
app_binding: matched.app_id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort username lookup for the legacy `AuthedAdmin` extension.
|
||||||
|
/// Returns `None` on DB error (the caller treats `None` as "skip the
|
||||||
|
/// legacy extension"). New handlers use `Principal` and don't depend
|
||||||
|
/// on this.
|
||||||
|
async fn username_for(state: &AuthState, id: AdminUserId) -> Option<String> {
|
||||||
|
match state.users.get(id).await {
|
||||||
|
Ok(Some(u)) => Some(u.username),
|
||||||
|
Ok(None) => None,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(
|
||||||
|
?err,
|
||||||
|
"username lookup for AuthedAdmin failed; skipping legacy ext"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the bearer token out of an `Authorization` header (preferred)
|
||||||
|
/// or the `picloud_session` cookie (fallback for browser clients).
|
||||||
|
/// Same shape as Phase 3a; the cookie only ever carries session
|
||||||
|
/// tokens — no `pic_` prefix expected there.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sentinel returned from the resolve functions when a DB error should
|
||||||
|
/// produce a 500 rather than a 401. Empty struct because the actual
|
||||||
|
/// error is already logged at the failure site.
|
||||||
|
struct InternalError;
|
||||||
|
|
||||||
|
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;
|
||||||
|
use picloud_shared::InstanceRole;
|
||||||
|
|
||||||
|
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 extracts_bearer_pic_prefixed_token() {
|
||||||
|
let r = req_with_header("authorization", "Bearer pic_abcdefghIJKL");
|
||||||
|
assert_eq!(extract_token(&r).as_deref(), Some("pic_abcdefghIJKL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-trip test for the unused-variable to keep `Principal`
|
||||||
|
// visibly tied to InstanceRole — caught a real bug during dev when
|
||||||
|
// the field order in the struct literal had drifted.
|
||||||
|
#[test]
|
||||||
|
fn principal_construction_is_explicit() {
|
||||||
|
let p = Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: InstanceRole::Owner,
|
||||||
|
scopes: None,
|
||||||
|
app_binding: None,
|
||||||
|
};
|
||||||
|
assert_eq!(p.instance_role, InstanceRole::Owner);
|
||||||
|
assert!(p.scopes.is_none());
|
||||||
|
assert!(p.app_binding.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
599
crates/manager-core/src/authz.rs
Normal file
599
crates/manager-core/src/authz.rs
Normal file
@@ -0,0 +1,599 @@
|
|||||||
|
//! Capability-based authorization — see blueprint §11.6.
|
||||||
|
//!
|
||||||
|
//! Single entry point for every admin endpoint: `can(repo, principal,
|
||||||
|
//! capability)` returns whether the caller can perform the action.
|
||||||
|
//! Handlers call `require` (which wraps `can` + a `Forbidden` error)
|
||||||
|
//! after loading the resource so the capability binds to the resource's
|
||||||
|
//! actual `app_id`, not a path param the caller controls.
|
||||||
|
//!
|
||||||
|
//! Three layers of intersection, evaluated in order:
|
||||||
|
//!
|
||||||
|
//! 1. **Role grant** — does the caller's `InstanceRole` plus any
|
||||||
|
//! `app_members` row authorize this capability?
|
||||||
|
//! 2. **Scope intersection** — if the principal came from an API key
|
||||||
|
//! (`principal.scopes.is_some()`), does the key's scope set cover
|
||||||
|
//! the capability's required scope?
|
||||||
|
//! 3. **App binding** — if the key was minted bound to a specific
|
||||||
|
//! app (`principal.app_binding`), does the capability target the
|
||||||
|
//! same app? (Instance-level capabilities are denied for bound
|
||||||
|
//! keys; the mint handler also rejects the combination upfront.)
|
||||||
|
//!
|
||||||
|
//! The capability set is intentionally finer-grained than the seven
|
||||||
|
//! scopes (e.g., `AppWriteScript` vs `AppWriteRoute` both fall under
|
||||||
|
//! the `script:write` / `route:write` scopes respectively). Keeping
|
||||||
|
//! capabilities precise lets a `script:write`-only key write scripts
|
||||||
|
//! without also being able to mutate routes. The scope set stays at
|
||||||
|
//! seven values — capabilities are the internal check, scopes are the
|
||||||
|
//! external user-facing label.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
|
||||||
|
|
||||||
|
/// Things a caller can attempt to do. Each app-scoped variant carries
|
||||||
|
/// the `AppId` of the resource the action targets — handlers compute
|
||||||
|
/// it from the loaded resource (e.g., `script.app_id`), not from a
|
||||||
|
/// path param.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Capability {
|
||||||
|
/// Create a new app. Owner / admin only.
|
||||||
|
InstanceCreateApp,
|
||||||
|
/// Create / update / delete admin_users rows (other than self
|
||||||
|
/// password change, which is a separate flow). Owner / admin.
|
||||||
|
InstanceManageUsers,
|
||||||
|
/// Mutate instance-wide configuration (sandbox ceiling, etc.).
|
||||||
|
/// Owner only.
|
||||||
|
InstanceManageSettings,
|
||||||
|
/// Read app metadata, scripts, routes. Viewer / editor / app_admin
|
||||||
|
/// (member); implicit for admin / owner.
|
||||||
|
AppRead(AppId),
|
||||||
|
/// Create / update / delete a script in this app.
|
||||||
|
AppWriteScript(AppId),
|
||||||
|
/// Create / update / delete a route in this app.
|
||||||
|
AppWriteRoute(AppId),
|
||||||
|
/// Manage domain claims on this app (add / remove).
|
||||||
|
AppManageDomains(AppId),
|
||||||
|
/// App settings + delete app. app_admin only (or owner via
|
||||||
|
/// implicit grant).
|
||||||
|
AppAdmin(AppId),
|
||||||
|
/// Read execution logs for scripts in this app.
|
||||||
|
AppLogRead(AppId),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Capability {
|
||||||
|
/// Extract the `AppId` for app-scoped capabilities; `None` for
|
||||||
|
/// instance-scoped ones. Used by the app-binding check on API keys.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn app_id(self) -> Option<AppId> {
|
||||||
|
match self {
|
||||||
|
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Self::AppRead(id)
|
||||||
|
| Self::AppWriteScript(id)
|
||||||
|
| Self::AppWriteRoute(id)
|
||||||
|
| Self::AppManageDomains(id)
|
||||||
|
| Self::AppAdmin(id)
|
||||||
|
| Self::AppLogRead(id) => Some(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single scope that authorizes this capability on an API key.
|
||||||
|
/// Strict mapping — a `script:write` key cannot read scripts unless
|
||||||
|
/// it also carries `script:read`. The intent is predictability: a
|
||||||
|
/// key has exactly the scopes it was minted with, no implicit
|
||||||
|
/// upgrades.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn required_scope(self) -> Scope {
|
||||||
|
match self {
|
||||||
|
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||||
|
Scope::InstanceAdmin
|
||||||
|
}
|
||||||
|
Self::AppRead(_) => Scope::ScriptRead,
|
||||||
|
Self::AppWriteScript(_) => Scope::ScriptWrite,
|
||||||
|
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||||
|
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||||
|
Self::AppAdmin(_) => Scope::AppAdmin,
|
||||||
|
Self::AppLogRead(_) => Scope::LogRead,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repo seam for membership lookups. Implemented in the DB-backed
|
||||||
|
/// repos crate (`app_members_repo.rs`); keeping it as a trait here
|
||||||
|
/// means unit tests can stub it.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AuthzRepo: Send + Sync {
|
||||||
|
async fn membership(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repo errors surface here so handlers can map them to 500 without
|
||||||
|
/// dragging sqlx types across the boundary.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AuthzError {
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
Repo(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decision flavor returned by `can` — distinguishes outright denial
|
||||||
|
/// from a partial answer that requires further checks (none today,
|
||||||
|
/// but the shape lets us add audit/explain mode later without rewriting
|
||||||
|
/// every caller).
|
||||||
|
#[must_use = "an authorization decision must be acted on"]
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Decision {
|
||||||
|
Allow,
|
||||||
|
Deny,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Decision {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_allow(self) -> bool {
|
||||||
|
matches!(self, Self::Allow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core authorization check. Walks the three intersection layers in
|
||||||
|
/// order and returns the resulting `Decision`.
|
||||||
|
pub async fn can(
|
||||||
|
repo: &dyn AuthzRepo,
|
||||||
|
principal: &Principal,
|
||||||
|
cap: Capability,
|
||||||
|
) -> Result<Decision, AuthzError> {
|
||||||
|
if !role_grants(repo, principal, cap).await? {
|
||||||
|
return Ok(Decision::Deny);
|
||||||
|
}
|
||||||
|
if !scope_allows(principal, cap) {
|
||||||
|
return Ok(Decision::Deny);
|
||||||
|
}
|
||||||
|
if !binding_allows(principal, cap) {
|
||||||
|
return Ok(Decision::Deny);
|
||||||
|
}
|
||||||
|
Ok(Decision::Allow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: returns `Ok(())` on Allow, `Err(AuthzDenied)` on Deny.
|
||||||
|
/// Handlers call this so the `?` operator threads the 403 through
|
||||||
|
/// naturally.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `AuthzDenied::Denied` when the capability is not granted,
|
||||||
|
/// or `AuthzDenied::Repo` if the underlying membership lookup fails.
|
||||||
|
pub async fn require(
|
||||||
|
repo: &dyn AuthzRepo,
|
||||||
|
principal: &Principal,
|
||||||
|
cap: Capability,
|
||||||
|
) -> Result<(), AuthzDenied> {
|
||||||
|
match can(repo, principal, cap).await {
|
||||||
|
Ok(Decision::Allow) => Ok(()),
|
||||||
|
Ok(Decision::Deny) => Err(AuthzDenied::Denied),
|
||||||
|
Err(e) => Err(AuthzDenied::Repo(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AuthzDenied {
|
||||||
|
#[error("forbidden")]
|
||||||
|
Denied,
|
||||||
|
#[error(transparent)]
|
||||||
|
Repo(#[from] AuthzError),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Layer 1: role-derived grant
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn role_grants(
|
||||||
|
repo: &dyn AuthzRepo,
|
||||||
|
principal: &Principal,
|
||||||
|
cap: Capability,
|
||||||
|
) -> Result<bool, AuthzError> {
|
||||||
|
match principal.instance_role {
|
||||||
|
InstanceRole::Owner => Ok(true),
|
||||||
|
InstanceRole::Admin => Ok(admin_grants(cap)),
|
||||||
|
InstanceRole::Member => member_grants(repo, principal.user_id, cap).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin is implicit `editor` on every app (per blueprint §11.6). They
|
||||||
|
/// can create apps and manage users, but NOT touch instance-wide
|
||||||
|
/// settings or take app-admin-only actions on apps they're not
|
||||||
|
/// explicitly app_admin of. Everything not in this set falls through
|
||||||
|
/// to deny (`InstanceManageSettings`, `AppManageDomains`, `AppAdmin`).
|
||||||
|
const fn admin_grants(cap: Capability) -> bool {
|
||||||
|
matches!(
|
||||||
|
cap,
|
||||||
|
Capability::InstanceCreateApp
|
||||||
|
| Capability::InstanceManageUsers
|
||||||
|
| Capability::AppRead(_)
|
||||||
|
| Capability::AppWriteScript(_)
|
||||||
|
| Capability::AppWriteRoute(_)
|
||||||
|
| Capability::AppLogRead(_)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Member has zero instance authority. App authority requires an
|
||||||
|
/// explicit `app_members` row with sufficient `AppRole`.
|
||||||
|
async fn member_grants(
|
||||||
|
repo: &dyn AuthzRepo,
|
||||||
|
user_id: UserId,
|
||||||
|
cap: Capability,
|
||||||
|
) -> Result<bool, AuthzError> {
|
||||||
|
let Some(app_id) = cap.app_id() else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let Some(role) = repo.membership(user_id, app_id).await? else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
Ok(role_satisfies(role, cap))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does the per-app `AppRole` cover the capability? Viewer can read;
|
||||||
|
/// Editor adds script/route/log mutations; AppAdmin adds settings,
|
||||||
|
/// domain claims, and delete. Roles form a strict subset chain, so
|
||||||
|
/// the check is "is this capability in the role's set?".
|
||||||
|
const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||||
|
let in_viewer = matches!(cap, Capability::AppRead(_) | Capability::AppLogRead(_));
|
||||||
|
let in_editor = in_viewer
|
||||||
|
|| matches!(
|
||||||
|
cap,
|
||||||
|
Capability::AppWriteScript(_) | Capability::AppWriteRoute(_)
|
||||||
|
);
|
||||||
|
let in_app_admin = in_editor
|
||||||
|
|| matches!(
|
||||||
|
cap,
|
||||||
|
Capability::AppManageDomains(_) | Capability::AppAdmin(_)
|
||||||
|
);
|
||||||
|
match role {
|
||||||
|
AppRole::Viewer => in_viewer,
|
||||||
|
AppRole::Editor => in_editor,
|
||||||
|
AppRole::AppAdmin => in_app_admin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Layer 2: API-key scope intersection
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn scope_allows(principal: &Principal, cap: Capability) -> bool {
|
||||||
|
match &principal.scopes {
|
||||||
|
None => true, // cookie session — full role authority
|
||||||
|
Some(scopes) => scopes.contains(&cap.required_scope()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Layer 3: API-key app binding
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn binding_allows(principal: &Principal, cap: Capability) -> bool {
|
||||||
|
let Some(bound_app) = principal.app_binding else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
match cap.app_id() {
|
||||||
|
// Instance-scoped capability + bound key → always denied. The
|
||||||
|
// mint handler also rejects this combination upfront, but
|
||||||
|
// defending in depth here means a stale/malformed row can't
|
||||||
|
// escalate.
|
||||||
|
None => false,
|
||||||
|
Some(target_app) => target_app == bound_app,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use picloud_shared::{AdminUserId, AppId};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
/// In-memory `AuthzRepo` so the unit tests don't need a database.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct InMemoryAuthzRepo {
|
||||||
|
memberships: Mutex<HashMap<(UserId, AppId), AppRole>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryAuthzRepo {
|
||||||
|
async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
|
||||||
|
self.memberships.lock().await.insert((user, app), role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AuthzRepo for InMemoryAuthzRepo {
|
||||||
|
async fn membership(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
Ok(self
|
||||||
|
.memberships
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&(user_id, app_id))
|
||||||
|
.copied())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn principal(role: InstanceRole) -> Principal {
|
||||||
|
Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: role,
|
||||||
|
scopes: None,
|
||||||
|
app_binding: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn owner_can_do_everything() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Owner);
|
||||||
|
let app = AppId::new();
|
||||||
|
for cap in [
|
||||||
|
Capability::InstanceCreateApp,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
Capability::InstanceManageSettings,
|
||||||
|
Capability::AppRead(app),
|
||||||
|
Capability::AppWriteScript(app),
|
||||||
|
Capability::AppWriteRoute(app),
|
||||||
|
Capability::AppManageDomains(app),
|
||||||
|
Capability::AppAdmin(app),
|
||||||
|
Capability::AppLogRead(app),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, cap).await.unwrap(),
|
||||||
|
Decision::Allow,
|
||||||
|
"owner denied {cap:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_cannot_manage_instance_settings_or_app_admin_actions() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Admin);
|
||||||
|
let app = AppId::new();
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
|
||||||
|
Decision::Allow,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::InstanceManageUsers)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Allow,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::InstanceManageSettings)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
);
|
||||||
|
// Editor-like grants succeed
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Allow,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppWriteRoute(app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Allow,
|
||||||
|
);
|
||||||
|
// App-admin grants do not
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppManageDomains(app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn member_without_row_is_denied_everywhere() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
let app = AppId::new();
|
||||||
|
for cap in [
|
||||||
|
Capability::InstanceCreateApp,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
Capability::InstanceManageSettings,
|
||||||
|
Capability::AppRead(app),
|
||||||
|
Capability::AppWriteScript(app),
|
||||||
|
Capability::AppWriteRoute(app),
|
||||||
|
Capability::AppAdmin(app),
|
||||||
|
Capability::AppLogRead(app),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, cap).await.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
"member granted {cap:?} without a membership row"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn member_with_viewer_role_can_read_but_not_write() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
let app = AppId::new();
|
||||||
|
repo.grant(p.user_id, app, AppRole::Viewer).await;
|
||||||
|
|
||||||
|
assert!(can(&repo, &p, Capability::AppRead(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert!(can(&repo, &p, Capability::AppLogRead(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn member_with_editor_role_can_write_scripts_and_routes() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
let app = AppId::new();
|
||||||
|
repo.grant(p.user_id, app, AppRole::Editor).await;
|
||||||
|
|
||||||
|
assert!(can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert!(can(&repo, &p, Capability::AppWriteRoute(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn member_with_app_admin_role_can_do_app_admin_actions() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
let app = AppId::new();
|
||||||
|
repo.grant(p.user_id, app, AppRole::AppAdmin).await;
|
||||||
|
|
||||||
|
assert!(can(&repo, &p, Capability::AppAdmin(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert!(can(&repo, &p, Capability::AppManageDomains(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
// Membership in App A does NOT grant access to App B
|
||||||
|
let other_app = AppId::new();
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppAdmin(other_app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scoped_key_intersects_with_role() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let app = AppId::new();
|
||||||
|
// Owner key with only script:read — cannot write
|
||||||
|
let p = Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: InstanceRole::Owner,
|
||||||
|
scopes: Some(vec![Scope::ScriptRead]),
|
||||||
|
app_binding: None,
|
||||||
|
};
|
||||||
|
assert!(can(&repo, &p, Capability::AppRead(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
// Even though the user is owner — the key's scope set is the
|
||||||
|
// hard ceiling.
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bound_key_cannot_escape_its_app() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let bound_app = AppId::new();
|
||||||
|
let other_app = AppId::new();
|
||||||
|
let p = Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: InstanceRole::Owner,
|
||||||
|
scopes: Some(vec![Scope::ScriptWrite]),
|
||||||
|
app_binding: Some(bound_app),
|
||||||
|
};
|
||||||
|
assert!(can(&repo, &p, Capability::AppWriteScript(bound_app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppWriteScript(other_app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bound_key_cannot_do_instance_actions() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let bound_app = AppId::new();
|
||||||
|
let p = Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: InstanceRole::Owner,
|
||||||
|
scopes: Some(vec![Scope::InstanceAdmin]), // mint handler also rejects this combo
|
||||||
|
app_binding: Some(bound_app),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
"bound key with instance scope must still be denied at the binding layer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capability_app_id_extraction() {
|
||||||
|
let app = AppId::new();
|
||||||
|
assert_eq!(Capability::InstanceCreateApp.app_id(), None);
|
||||||
|
assert_eq!(Capability::AppRead(app).app_id(), Some(app));
|
||||||
|
assert_eq!(Capability::AppAdmin(app).app_id(), Some(app));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capability_required_scope_mapping_is_complete() {
|
||||||
|
// Sanity: every variant returns a scope. Compiler-enforced
|
||||||
|
// exhaustiveness lives in the match itself; this test guards
|
||||||
|
// against accidental drift to a default branch.
|
||||||
|
let app = AppId::new();
|
||||||
|
for cap in [
|
||||||
|
Capability::InstanceCreateApp,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
Capability::InstanceManageSettings,
|
||||||
|
Capability::AppRead(app),
|
||||||
|
Capability::AppWriteScript(app),
|
||||||
|
Capability::AppWriteRoute(app),
|
||||||
|
Capability::AppManageDomains(app),
|
||||||
|
Capability::AppAdmin(app),
|
||||||
|
Capability::AppLogRead(app),
|
||||||
|
] {
|
||||||
|
let _ = cap.required_scope(); // does not panic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,23 @@
|
|||||||
//! 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 api_key_repo;
|
||||||
|
pub mod api_keys_api;
|
||||||
|
pub mod app_bootstrap;
|
||||||
|
pub mod app_domain_repo;
|
||||||
|
pub mod app_members_api;
|
||||||
|
pub mod app_members_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 authz;
|
||||||
pub mod log_sink;
|
pub mod log_sink;
|
||||||
pub mod migrations;
|
pub mod migrations;
|
||||||
pub mod repo;
|
pub mod repo;
|
||||||
@@ -13,7 +29,40 @@ 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 api_key_repo::{
|
||||||
|
ApiKeyRepository, ApiKeyRepositoryError, ApiKeyRow, ApiKeyVerification, NewApiKey,
|
||||||
|
PostgresApiKeyRepository,
|
||||||
|
};
|
||||||
|
pub use api_keys_api::{api_keys_router, ApiKeysState};
|
||||||
|
pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
|
||||||
|
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
|
||||||
|
pub use app_members_api::{app_members_router, AppMembersApiError, AppMembersState};
|
||||||
|
pub use app_members_repo::{
|
||||||
|
AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow,
|
||||||
|
PostgresAppMembersRepository,
|
||||||
|
};
|
||||||
|
pub use app_repo::{resolve_app, 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,
|
||||||
|
};
|
||||||
|
#[allow(deprecated)]
|
||||||
|
pub use auth_middleware::{
|
||||||
|
require_admin, require_authenticated, AuthState, AuthedAdmin, API_KEY_PREFIX,
|
||||||
|
API_KEY_PREFIX_LEN, SESSION_COOKIE,
|
||||||
|
};
|
||||||
|
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
|
||||||
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::{
|
||||||
|
AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
|
||||||
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -21,7 +23,18 @@ 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>;
|
||||||
|
/// Every script in any app the user is a member of. Drives
|
||||||
|
/// `GET /admin/scripts` for `member` instance-role callers so the
|
||||||
|
/// API never returns scripts they shouldn't see — even before the
|
||||||
|
/// per-handler capability check fires.
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> 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 +48,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 +92,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 +104,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 +113,48 @@ 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 list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||||
|
"SELECT s.id, s.app_id, s.name, s.description, s.version, s.source, \
|
||||||
|
s.timeout_seconds, s.memory_limit_mb, s.sandbox, s.created_at, s.updated_at \
|
||||||
|
FROM scripts s \
|
||||||
|
JOIN app_members m ON m.app_id = s.app_id \
|
||||||
|
WHERE m.user_id = $1 \
|
||||||
|
ORDER BY s.name",
|
||||||
|
)
|
||||||
|
.bind(user_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 +168,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 +186,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 +203,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 +215,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 +245,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 +266,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 +340,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 +362,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 +388,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(),
|
||||||
|
|||||||
@@ -10,42 +10,56 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::{delete, get, post},
|
routing::{delete, get, post},
|
||||||
Json, Router,
|
Extension, 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, Principal, 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::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
|
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>,
|
||||||
|
/// Capability gate — Phase 3.5.
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
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(),
|
||||||
|
authz: self.authz.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 +81,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 +102,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 +132,28 @@ 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>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(script_id): Path<ScriptId>,
|
Path(script_id): Path<ScriptId>,
|
||||||
) -> Result<Json<Vec<Route>>, RouteApiError> {
|
) -> Result<Json<Vec<Route>>, RouteApiError> {
|
||||||
|
let script = state
|
||||||
|
.scripts
|
||||||
|
.get(script_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppRead(script.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
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>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
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 +164,28 @@ 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;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteRoute(app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 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 +203,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,23 +217,47 @@ 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>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(route_id): Path<Uuid>,
|
Path(route_id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, RouteApiError> {
|
) -> Result<StatusCode, RouteApiError> {
|
||||||
|
// Resolve the route's app before we delete, so the capability
|
||||||
|
// binds to the actual route's app_id (not a path param).
|
||||||
|
let route = state
|
||||||
|
.routes
|
||||||
|
.get(route_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(RouteApiError::RouteNotFound(route_id))?;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteRoute(route.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
state.routes.delete(route_id).await?;
|
state.routes.delete(route_id).await?;
|
||||||
refresh_table(&state).await?;
|
refresh_table(&state).await?;
|
||||||
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>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Json(input): Json<CheckRouteRequest>,
|
Json(input): Json<CheckRouteRequest>,
|
||||||
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
|
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
|
||||||
|
// routes:check is read-only — peeking at a hypothetical conflict
|
||||||
|
// is bounded by AppRead on the target app (otherwise members
|
||||||
|
// could probe other apps).
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppRead(input.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
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,16 +280,25 @@ 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>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Json(input): Json<MatchRouteRequest>,
|
Json(input): Json<MatchRouteRequest>,
|
||||||
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
|
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppRead(input.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let parsed = url::Url::parse(&input.url)
|
let parsed = url::Url::parse(&input.url)
|
||||||
.map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?;
|
.map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?;
|
||||||
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 +351,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 +365,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 +375,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,10 +466,37 @@ pub enum RouteApiError {
|
|||||||
#[error("bad request: {0}")]
|
#[error("bad request: {0}")]
|
||||||
BadRequest(String),
|
BadRequest(String),
|
||||||
|
|
||||||
|
#[error("script not found: {0}")]
|
||||||
|
ScriptNotFound(ScriptId),
|
||||||
|
|
||||||
|
#[error("route not found: {0}")]
|
||||||
|
RouteNotFound(Uuid),
|
||||||
|
|
||||||
|
#[error("host {host:?} is not claimed by this app")]
|
||||||
|
HostNotClaimed {
|
||||||
|
host: String,
|
||||||
|
available_claims: Vec<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
#[error("repository error: {0}")]
|
#[error("repository error: {0}")]
|
||||||
Repo(#[from] ScriptRepositoryError),
|
Repo(#[from] ScriptRepositoryError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for RouteApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IntoResponse for RouteApiError {
|
impl IntoResponse for RouteApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, body) = match &self {
|
let (status, body) = match &self {
|
||||||
@@ -326,10 +515,34 @@ 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::RouteNotFound(_)
|
||||||
|
| 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::Forbidden => (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
serde_json::json!({ "error": self.to_string() }),
|
||||||
|
),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "route authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
serde_json::json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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,25 @@ 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>;
|
||||||
|
/// Single-row lookup. Used by `DELETE /api/v1/admin/routes/{id}` so
|
||||||
|
/// the capability check binds to the route's actual `app_id`
|
||||||
|
/// (not a path param).
|
||||||
|
async fn get(&self, route_id: Uuid) -> Result<Option<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 +61,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 +70,36 @@ impl RouteRepository for PostgresRouteRepository {
|
|||||||
Ok(rows.into_iter().map(Into::into).collect())
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError> {
|
||||||
|
let row = 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 id = $1",
|
||||||
|
)
|
||||||
|
.bind(route_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +112,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 +151,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 +190,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 +205,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,64 @@
|
|||||||
|
|
||||||
## 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
|
||||||
|
instance_role: text NOT NULL default='owner'::text
|
||||||
|
email: text NULL
|
||||||
|
mfa_secret: text NULL
|
||||||
|
|
||||||
|
table: api_keys
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
user_id: uuid NOT NULL
|
||||||
|
hash: text NOT NULL
|
||||||
|
prefix: text NOT NULL
|
||||||
|
name: text NOT NULL
|
||||||
|
scopes: ARRAY NOT NULL
|
||||||
|
app_id: uuid NULL
|
||||||
|
expires_at: timestamp with time zone NULL
|
||||||
|
last_used_at: timestamp with time zone NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
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_members
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
user_id: uuid NOT NULL
|
||||||
|
role: 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 +74,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 +86,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 +99,119 @@ 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_email_key: public.admin_users USING btree (email)
|
||||||
|
admin_users_instance_role_idx: public.admin_users USING btree (instance_role)
|
||||||
|
admin_users_pkey: public.admin_users USING btree (id)
|
||||||
|
admin_users_username_key: public.admin_users USING btree (username)
|
||||||
|
|
||||||
|
indexes on api_keys:
|
||||||
|
api_keys_pkey: public.api_keys USING btree (id)
|
||||||
|
api_keys_prefix_idx: public.api_keys USING btree (prefix)
|
||||||
|
api_keys_user_id_idx: public.api_keys USING btree (user_id)
|
||||||
|
|
||||||
|
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_members:
|
||||||
|
app_members_pkey: public.app_members USING btree (app_id, user_id)
|
||||||
|
app_members_user_id_idx: public.app_members USING btree (user_id)
|
||||||
|
|
||||||
|
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:
|
||||||
|
[CHECK] admin_users_instance_role_check: CHECK ((instance_role = ANY (ARRAY['owner'::text, 'admin'::text, 'member'::text])))
|
||||||
|
[PRIMARY KEY] admin_users_pkey: PRIMARY KEY (id)
|
||||||
|
[UNIQUE] admin_users_email_key: UNIQUE (email)
|
||||||
|
[UNIQUE] admin_users_username_key: UNIQUE (username)
|
||||||
|
|
||||||
|
constraints on api_keys:
|
||||||
|
[FOREIGN KEY] api_keys_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[FOREIGN KEY] api_keys_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] api_keys_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
|
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_members:
|
||||||
|
[CHECK] app_members_role_check: CHECK ((role = ANY (ARRAY['app_admin'::text, 'editor'::text, 'viewer'::text])))
|
||||||
|
[FOREIGN KEY] app_members_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[FOREIGN KEY] app_members_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] app_members_pkey: PRIMARY KEY (app_id, user_id)
|
||||||
|
|
||||||
|
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
|
||||||
|
0006: users authz
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,3 +39,5 @@ figment.workspace = true
|
|||||||
axum-test = "17"
|
axum-test = "17"
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
|||||||
@@ -6,14 +6,21 @@
|
|||||||
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, api_keys_router, app_members_router, apps_api, apps_router,
|
||||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
auth_router, compile_routes, migrations, require_authenticated, route_admin_router,
|
||||||
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, ApiKeyRepository,
|
||||||
|
ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository,
|
||||||
|
AppsState, AuthState, AuthzRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||||
|
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
||||||
|
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 +31,40 @@ 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 keys: Arc<dyn ApiKeyRepository>,
|
||||||
|
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.clone())),
|
||||||
|
keys: Arc::new(PostgresApiKeyRepository::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 +72,56 @@ 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.
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
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.clone()));
|
||||||
|
// The Postgres app_members repo implements both `AppMembersRepository`
|
||||||
|
// (CRUD over the table) and `AuthzRepo` (single-row membership lookup
|
||||||
|
// for capability checks). Construct it once and clone the Arc into
|
||||||
|
// both trait views — same allocation, two vtables.
|
||||||
|
let members_concrete = Arc::new(PostgresAppMembersRepository::new(pool));
|
||||||
|
let members: Arc<dyn AppMembersRepository> = members_concrete.clone();
|
||||||
|
let authz: Arc<dyn AuthzRepo> = members_concrete;
|
||||||
|
|
||||||
// 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 +129,80 @@ 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(),
|
||||||
|
authz: authz.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(),
|
||||||
|
authz: authz.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,
|
||||||
|
authz: authz.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let auth_state = AuthState {
|
||||||
|
users: auth.users.clone(),
|
||||||
|
sessions: auth.sessions.clone(),
|
||||||
|
keys: auth.keys.clone(),
|
||||||
|
ttl: auth.ttl,
|
||||||
|
};
|
||||||
|
let admins_state = AdminsState {
|
||||||
|
users: auth.users.clone(),
|
||||||
|
sessions: auth.sessions,
|
||||||
|
keys: auth.keys.clone(),
|
||||||
|
authz: authz.clone(),
|
||||||
|
};
|
||||||
|
let app_members_state = AppMembersState {
|
||||||
|
apps: apps_state.apps.clone(),
|
||||||
|
users: auth.users,
|
||||||
|
members,
|
||||||
|
authz,
|
||||||
|
};
|
||||||
|
let api_keys_state = ApiKeysState { keys: auth.keys };
|
||||||
|
|
||||||
|
// /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_authenticated layer; capability
|
||||||
|
// checks live in each handler (after the resource is loaded so the
|
||||||
|
// capability binds to the resource's actual app_id).
|
||||||
|
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))
|
||||||
|
.merge(app_members_router(app_members_state))
|
||||||
|
.merge(api_keys_router(api_keys_state))
|
||||||
|
.layer(from_fn_with_state(
|
||||||
|
auth_state.clone(),
|
||||||
|
require_authenticated,
|
||||||
|
));
|
||||||
|
|
||||||
|
// 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 +270,18 @@ 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 list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: picloud_shared::AdminUserId,
|
||||||
|
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||||
|
self.0.list_for_user(user_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,33 @@ 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?;
|
||||||
|
warn_on_multi_owner_install(&*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 +80,140 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Multi-owner startup warning — Phase 3.5 migration upgraded every
|
||||||
|
/// pre-existing admin_users row to `Owner` via DEFAULT, which for
|
||||||
|
/// installs with several Phase 3a admins means several co-owners.
|
||||||
|
/// Surface this once at boot so the operator can demote extras via
|
||||||
|
/// `PATCH /api/v1/admin/admins/{id}` with `instance_role: "admin"`.
|
||||||
|
/// Soft-fail: a DB blip should not block startup.
|
||||||
|
async fn warn_on_multi_owner_install(users: &dyn AdminUserRepository) {
|
||||||
|
match users.list_active_owners().await {
|
||||||
|
Ok(owners) if owners.len() > 1 => {
|
||||||
|
let names: Vec<String> = owners.into_iter().map(|u| u.username).collect();
|
||||||
|
tracing::warn!(
|
||||||
|
count = names.len(),
|
||||||
|
owners = ?names,
|
||||||
|
"multiple active owners detected — Phase 3.5 promoted every \
|
||||||
|
pre-existing admin to owner. Demote extras via \
|
||||||
|
PATCH /api/v1/admin/admins/{{id}} with instance_role."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(
|
||||||
|
?err,
|
||||||
|
"could not count active owners for multi-owner startup check"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,68 @@ 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;
|
||||||
|
use picloud_shared::InstanceRole;
|
||||||
|
|
||||||
|
let auth = picloud::AuthDeps::from_pool(pool.clone());
|
||||||
|
let hash = hash_password("test-pw").expect("hash");
|
||||||
|
auth.users
|
||||||
|
.create("test-admin", &hash, InstanceRole::Owner, None)
|
||||||
|
.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
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -34,6 +93,68 @@ async fn healthz_responds_ok(pool: PgPool) {
|
|||||||
assert_eq!(r.text(), "ok");
|
assert_eq!(r.text(), "ok");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn auth_me_returns_principal_with_role_and_email(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let r = s.get("/api/v1/admin/auth/me").await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
let body: Value = r.json();
|
||||||
|
assert_eq!(body["username"], "test-admin");
|
||||||
|
assert_eq!(body["instance_role"], "owner");
|
||||||
|
// Seeded admin has no email — must round-trip as null, not be missing.
|
||||||
|
assert!(
|
||||||
|
body.get("email").is_some_and(Value::is_null),
|
||||||
|
"email should be present and null, got: {body}"
|
||||||
|
);
|
||||||
|
assert!(body["id"].as_str().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn create_admin_accepts_email_and_patch_clears_it(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
// Create with email set.
|
||||||
|
let created = s
|
||||||
|
.post("/api/v1/admin/admins")
|
||||||
|
.json(&json!({
|
||||||
|
"username": "alice",
|
||||||
|
"password": "correct-horse-battery",
|
||||||
|
"instance_role": "member",
|
||||||
|
"email": "alice@example.com",
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
created.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let body: Value = created.json();
|
||||||
|
let alice_id = body["id"].as_str().expect("id").to_string();
|
||||||
|
assert_eq!(body["email"], "alice@example.com");
|
||||||
|
|
||||||
|
// Patch with email present-and-null clears it.
|
||||||
|
let cleared = s
|
||||||
|
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
|
||||||
|
.json(&json!({ "email": null }))
|
||||||
|
.await;
|
||||||
|
cleared.assert_status_ok();
|
||||||
|
assert!(cleared.json::<Value>()["email"].is_null());
|
||||||
|
|
||||||
|
// Patch with email omitted is a no-op (doesn't clobber a re-set).
|
||||||
|
let reset = s
|
||||||
|
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
|
||||||
|
.json(&json!({ "email": "alice2@example.com" }))
|
||||||
|
.await;
|
||||||
|
reset.assert_status_ok();
|
||||||
|
let omit = s
|
||||||
|
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
|
||||||
|
.json(&json!({ "username": "alice" })) // no email key
|
||||||
|
.await;
|
||||||
|
omit.assert_status_ok();
|
||||||
|
assert_eq!(omit.json::<Value>()["email"], "alice2@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Script CRUD
|
// Script CRUD
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -41,30 +162,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(
|
||||||
"name": "echo",
|
&app_id,
|
||||||
"description": "test",
|
json!({
|
||||||
"source": "#{ statusCode: 200, body: 42 }",
|
"name": "echo",
|
||||||
}))
|
"description": "test",
|
||||||
|
"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 +202,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 +217,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 +235,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 +257,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 +275,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 +309,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(
|
||||||
"name": "echo",
|
&app_id,
|
||||||
"source": "#{ statusCode: 200, body: ctx.request.body }",
|
json!({
|
||||||
}))
|
"name": "echo",
|
||||||
|
"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 +335,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(
|
||||||
"name": "header-test",
|
&app_id,
|
||||||
"source": "#{ statusCode: 201, headers: #{ \"x-tag\": \"on\" }, body: 1 }",
|
json!({
|
||||||
}))
|
"name": "header-test",
|
||||||
|
"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 +371,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(
|
||||||
"name": "logger",
|
&app_id,
|
||||||
"source": "log::info(\"called\", #{ marker: 7 }); #{ statusCode: 200, body: \"done\" }",
|
json!({
|
||||||
}))
|
"name": "logger",
|
||||||
|
"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 +431,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 +446,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(
|
||||||
"name": "tight",
|
&app_id,
|
||||||
"source": "1",
|
json!({
|
||||||
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
"name": "tight",
|
||||||
}))
|
"source": "1",
|
||||||
|
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
||||||
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -333,14 +476,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(
|
||||||
"name": "too-loose",
|
&app_id,
|
||||||
"source": "1",
|
json!({
|
||||||
"sandbox": { "max_operations": 100_000_000 }
|
"name": "too-loose",
|
||||||
}))
|
"source": "1",
|
||||||
|
"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 +496,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(
|
||||||
"name": "typo",
|
&app_id,
|
||||||
"source": "1",
|
json!({
|
||||||
"sandbox": { "max_operashuns": 500 }
|
"name": "typo",
|
||||||
}))
|
"source": "1",
|
||||||
|
"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 +520,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(
|
||||||
"name": "tight-exec",
|
&app_id,
|
||||||
"source": "let n = 0; for i in 0..10000 { n += 1; } n",
|
json!({
|
||||||
"sandbox": { "max_operations": 500 }
|
"name": "tight-exec",
|
||||||
}))
|
"source": "let n = 0; for i in 0..10000 { n += 1; } n",
|
||||||
|
"sandbox": { "max_operations": 500 }
|
||||||
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -396,14 +548,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(
|
||||||
"name": "patch-target",
|
&app_id,
|
||||||
"source": "1",
|
json!({
|
||||||
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
"name": "patch-target",
|
||||||
}))
|
"source": "1",
|
||||||
|
"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 +584,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 +596,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 +613,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 +623,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 +640,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 +649,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 +666,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 +697,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 +706,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 +722,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 +749,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 +765,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 +778,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 +793,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 +806,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 +857,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 +871,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 +884,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"], 6);
|
||||||
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(
|
||||||
"name": "boom",
|
&app_id,
|
||||||
"source": "1 / 0",
|
json!({
|
||||||
}))
|
"name": "boom",
|
||||||
|
"source": "1 / 0",
|
||||||
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
|
|||||||
1099
crates/picloud/tests/authz.rs
Normal file
1099
crates/picloud/tests/authz.rs
Normal file
File diff suppressed because it is too large
Load Diff
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>,
|
||||||
|
}
|
||||||
242
crates/shared/src/auth.rs
Normal file
242
crates/shared/src/auth.rs
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
//! Cross-crate authn/authz types — Phase 3.5, see blueprint §11.6.
|
||||||
|
//!
|
||||||
|
//! The `Principal` extracted by `manager-core::auth_middleware` lives
|
||||||
|
//! here so handlers in every crate (and, later, the v1.1 SDKs in
|
||||||
|
//! `executor-core`) can refer to the same shape without pulling in the
|
||||||
|
//! manager crate. The authorization rules themselves live in
|
||||||
|
//! `manager-core::authz` — this module is data only.
|
||||||
|
//!
|
||||||
|
//! `UserId` is a transitional alias for `AdminUserId`. Phase 3a named
|
||||||
|
//! the table `admin_users` to leave room for the v1.1 script-level
|
||||||
|
//! `users` SDK feature (see blueprint §11.4 "Naming"); from the
|
||||||
|
//! authorization layer's perspective an admin row is the principal
|
||||||
|
//! identity, so we expose the alias rather than renaming the existing
|
||||||
|
//! id type.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{AdminUserId, AppId};
|
||||||
|
|
||||||
|
/// Transitional alias — see module docs.
|
||||||
|
pub type UserId = AdminUserId;
|
||||||
|
|
||||||
|
/// Instance-wide role carried by every `admin_users` row. The DB
|
||||||
|
/// representation is `text` (`'owner'|'admin'|'member'`), checked via
|
||||||
|
/// a CHECK constraint in migration `0006_users_authz.sql`; this enum
|
||||||
|
/// is the Rust mirror.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum InstanceRole {
|
||||||
|
/// Full instance control, manage other owners, implicit `app_admin`
|
||||||
|
/// on every app. Multiple allowed.
|
||||||
|
Owner,
|
||||||
|
/// Create apps, invite users, implicit `editor` on every app. No
|
||||||
|
/// instance-settings authority and no owner-management.
|
||||||
|
Admin,
|
||||||
|
/// Invited into specific apps via `app_members` only. No app
|
||||||
|
/// creation, no invite authority. List endpoints filter strictly
|
||||||
|
/// by membership at SQL.
|
||||||
|
Member,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InstanceRole {
|
||||||
|
/// Stable string form — matches the DB CHECK constraint values
|
||||||
|
/// exactly. Used by repos and the seed/audit paths.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Owner => "owner",
|
||||||
|
Self::Admin => "admin",
|
||||||
|
Self::Member => "member",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inverse of `as_str` — used when reading a row out of Postgres.
|
||||||
|
/// Returns `None` for unknown values so the caller can decide
|
||||||
|
/// between failing loudly or skipping a bad row.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_db_str(s: &str) -> Option<Self> {
|
||||||
|
match s {
|
||||||
|
"owner" => Some(Self::Owner),
|
||||||
|
"admin" => Some(Self::Admin),
|
||||||
|
"member" => Some(Self::Member),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-app role recorded in `app_members`. Members hold zero-or-one row
|
||||||
|
/// per (user, app); owners and admins are not represented in the table
|
||||||
|
/// (their app authority is implicit via `InstanceRole`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AppRole {
|
||||||
|
/// App settings, domain claims, delete.
|
||||||
|
AppAdmin,
|
||||||
|
/// CRUD on scripts, routes, sandbox config.
|
||||||
|
Editor,
|
||||||
|
/// Read scripts + execution logs.
|
||||||
|
Viewer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppRole {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::AppAdmin => "app_admin",
|
||||||
|
Self::Editor => "editor",
|
||||||
|
Self::Viewer => "viewer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_db_str(s: &str) -> Option<Self> {
|
||||||
|
match s {
|
||||||
|
"app_admin" => Some(Self::AppAdmin),
|
||||||
|
"editor" => Some(Self::Editor),
|
||||||
|
"viewer" => Some(Self::Viewer),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API-key scope. Exactly seven values; new scopes need a blueprint
|
||||||
|
/// edit before they're added here. Wire form is the colon-separated
|
||||||
|
/// string (`"script:read"`, etc.) — matches the `text[]` stored in
|
||||||
|
/// `api_keys.scopes` and the strings shown to operators.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Scope {
|
||||||
|
ScriptRead,
|
||||||
|
ScriptWrite,
|
||||||
|
RouteWrite,
|
||||||
|
DomainManage,
|
||||||
|
LogRead,
|
||||||
|
AppAdmin,
|
||||||
|
InstanceAdmin,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scope {
|
||||||
|
pub const ALL: &'static [Scope] = &[
|
||||||
|
Scope::ScriptRead,
|
||||||
|
Scope::ScriptWrite,
|
||||||
|
Scope::RouteWrite,
|
||||||
|
Scope::DomainManage,
|
||||||
|
Scope::LogRead,
|
||||||
|
Scope::AppAdmin,
|
||||||
|
Scope::InstanceAdmin,
|
||||||
|
];
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::ScriptRead => "script:read",
|
||||||
|
Self::ScriptWrite => "script:write",
|
||||||
|
Self::RouteWrite => "route:write",
|
||||||
|
Self::DomainManage => "domain:manage",
|
||||||
|
Self::LogRead => "log:read",
|
||||||
|
Self::AppAdmin => "app:admin",
|
||||||
|
Self::InstanceAdmin => "instance:admin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_wire(s: &str) -> Option<Self> {
|
||||||
|
Self::ALL.iter().copied().find(|sc| sc.as_str() == s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True for scopes that only make sense on an unbound key — bound
|
||||||
|
/// keys (api_keys.app_id IS NOT NULL) cannot claim instance-wide
|
||||||
|
/// authority and the mint handler rejects the combination at 422.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_instance(self) -> bool {
|
||||||
|
matches!(self, Self::InstanceAdmin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom serde so the wire form is the colon-separated string. The
|
||||||
|
// stored DB value lives in a `text[]`, so the repo converts between
|
||||||
|
// `Vec<String>` and `Vec<Scope>` using `as_str`/`from_wire`.
|
||||||
|
impl Serialize for Scope {
|
||||||
|
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||||
|
s.serialize_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Scope {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||||
|
let s = String::deserialize(d)?;
|
||||||
|
Self::from_wire(&s).ok_or_else(|| serde::de::Error::custom(format!("unknown scope: {s}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved caller identity. Produced by `manager-core::auth_middleware`
|
||||||
|
/// for both the cookie-session path (then `scopes`/`app_binding` are
|
||||||
|
/// `None`) and the bearer-API-key path (then both fields carry the
|
||||||
|
/// key's constraints).
|
||||||
|
///
|
||||||
|
/// The capability check in `manager-core::authz::can` intersects
|
||||||
|
/// `instance_role` with `scopes` and `app_binding` to decide whether
|
||||||
|
/// a given `Capability` is granted.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Principal {
|
||||||
|
pub user_id: UserId,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
/// `None` for cookie sessions (no scope restriction beyond the
|
||||||
|
/// role itself); `Some` for API keys, in which case the effective
|
||||||
|
/// authority is `role ∩ scopes`.
|
||||||
|
pub scopes: Option<Vec<Scope>>,
|
||||||
|
/// `Some(app)` for keys bound to a single app at mint time. Every
|
||||||
|
/// `App*(other)` capability is denied regardless of role.
|
||||||
|
pub app_binding: Option<AppId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instance_role_round_trip() {
|
||||||
|
for role in [
|
||||||
|
InstanceRole::Owner,
|
||||||
|
InstanceRole::Admin,
|
||||||
|
InstanceRole::Member,
|
||||||
|
] {
|
||||||
|
assert_eq!(InstanceRole::from_db_str(role.as_str()), Some(role));
|
||||||
|
}
|
||||||
|
assert_eq!(InstanceRole::from_db_str("bogus"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_role_round_trip() {
|
||||||
|
for role in [AppRole::AppAdmin, AppRole::Editor, AppRole::Viewer] {
|
||||||
|
assert_eq!(AppRole::from_db_str(role.as_str()), Some(role));
|
||||||
|
}
|
||||||
|
assert_eq!(AppRole::from_db_str("bogus"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_round_trip_covers_all() {
|
||||||
|
for &scope in Scope::ALL {
|
||||||
|
assert_eq!(Scope::from_wire(scope.as_str()), Some(scope));
|
||||||
|
}
|
||||||
|
assert_eq!(Scope::from_wire("script:nope"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_is_instance_flags_only_instance_admin() {
|
||||||
|
for &scope in Scope::ALL {
|
||||||
|
let expected = scope == Scope::InstanceAdmin;
|
||||||
|
assert_eq!(scope.is_instance(), expected, "scope {}", scope.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_serde_uses_wire_form() {
|
||||||
|
let s = serde_json::to_string(&Scope::ScriptWrite).unwrap();
|
||||||
|
assert_eq!(s, "\"script:write\"");
|
||||||
|
let back: Scope = serde_json::from_str(&s).unwrap();
|
||||||
|
assert_eq!(back, Scope::ScriptWrite);
|
||||||
|
let err = serde_json::from_str::<Scope>("\"unknown\"").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("unknown scope"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 @@ 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);
|
||||||
|
id_type!(ApiKeyId);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
//! 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 auth;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod execution_log;
|
pub mod execution_log;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
@@ -14,9 +16,11 @@ pub mod script;
|
|||||||
pub mod validator;
|
pub mod validator;
|
||||||
pub mod version;
|
pub mod version;
|
||||||
|
|
||||||
|
pub use app::{App, AppDomain, DomainShape};
|
||||||
|
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
|
||||||
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, ApiKeyId, 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,
|
||||||
|
|||||||
635
dashboard/package-lock.json
generated
635
dashboard/package-lock.json
generated
@@ -1,12 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.1.0",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.1.0",
|
"version": "0.6.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",
|
||||||
@@ -1131,7 +1275,6 @@
|
|||||||
"integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
|
"integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||||
@@ -1174,7 +1317,6 @@
|
|||||||
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
|
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
|
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
|
||||||
"debug": "^4.4.1",
|
"debug": "^4.4.1",
|
||||||
@@ -1209,6 +1351,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 +1369,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",
|
||||||
@@ -1236,7 +1396,6 @@
|
|||||||
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -1293,7 +1452,6 @@
|
|||||||
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.59.4",
|
"@typescript-eslint/scope-manager": "8.59.4",
|
||||||
"@typescript-eslint/types": "8.59.4",
|
"@typescript-eslint/types": "8.59.4",
|
||||||
@@ -1401,7 +1559,6 @@
|
|||||||
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
|
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
},
|
},
|
||||||
@@ -1532,13 +1689,127 @@
|
|||||||
"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",
|
||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1606,6 +1877,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 +1915,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 +1935,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 +1969,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 +2005,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 +2057,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 +2109,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 +2143,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",
|
||||||
@@ -1855,7 +2211,6 @@
|
|||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -2082,6 +2437,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 +2457,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 +2685,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 +2807,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 +2973,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",
|
||||||
@@ -2597,7 +3003,6 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -2625,7 +3030,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -2759,7 +3163,6 @@
|
|||||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -2923,6 +3326,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 +3358,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 +3385,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",
|
||||||
@@ -2980,7 +3423,6 @@
|
|||||||
"integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==",
|
"integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
@@ -3058,6 +3500,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 +3531,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",
|
||||||
@@ -3117,7 +3603,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -3180,7 +3665,6 @@
|
|||||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
@@ -3250,6 +3734,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 +3777,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 +3872,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.6.0",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
256
dashboard/src/lib/ActionMenu.svelte
Normal file
256
dashboard/src/lib/ActionMenu.svelte
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
<!--
|
||||||
|
Per-row "⋮" kebab menu. Hides secondary actions (edit, deactivate,
|
||||||
|
delete, etc.) behind a single trigger so list rows stay tidy.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
<ActionMenu
|
||||||
|
items={[
|
||||||
|
{ label: 'Edit', onClick: () => openEdit(row) },
|
||||||
|
{ label: row.is_active ? 'Deactivate' : 'Reactivate',
|
||||||
|
onClick: () => toggleActive(row) },
|
||||||
|
{ label: 'Delete', danger: true, onClick: () => openDelete(row),
|
||||||
|
disabled: !canDelete(row) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
Closes on: item click, click outside, ESC, scroll/resize. Keyboard:
|
||||||
|
Enter/Space opens; Up/Down navigate; Enter activates; ESC closes and
|
||||||
|
re-focuses the trigger. The popover is absolutely positioned relative
|
||||||
|
to the trigger and right-anchored — the parent must allow overflow
|
||||||
|
(`overflow: visible`) for it to extend past the row.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
export interface MenuItem {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: MenuItem[];
|
||||||
|
/** Accessible label for the trigger button. */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { items, label = 'More actions' }: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let triggerEl = $state<HTMLButtonElement | null>(null);
|
||||||
|
let menuEl = $state<HTMLDivElement | null>(null);
|
||||||
|
let activeIndex = $state(-1);
|
||||||
|
|
||||||
|
let enabledIndices = $derived(
|
||||||
|
items
|
||||||
|
.map((it, i) => (it.disabled ? -1 : i))
|
||||||
|
.filter((i) => i >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
open ? close() : openMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMenu() {
|
||||||
|
open = true;
|
||||||
|
activeIndex = enabledIndices[0] ?? -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(refocus = false) {
|
||||||
|
open = false;
|
||||||
|
activeIndex = -1;
|
||||||
|
if (refocus) triggerEl?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function activate(index: number) {
|
||||||
|
const item = items[index];
|
||||||
|
if (!item || item.disabled) return;
|
||||||
|
close();
|
||||||
|
item.onClick();
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveActive(step: 1 | -1) {
|
||||||
|
if (enabledIndices.length === 0) return;
|
||||||
|
const cur = enabledIndices.indexOf(activeIndex);
|
||||||
|
const next =
|
||||||
|
cur === -1
|
||||||
|
? enabledIndices[0]
|
||||||
|
: enabledIndices[(cur + step + enabledIndices.length) % enabledIndices.length];
|
||||||
|
activeIndex = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTriggerKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!open) openMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMenuKeydown(e: KeyboardEvent) {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
moveActive(1);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
moveActive(-1);
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeIndex >= 0) activate(activeIndex);
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
close(true);
|
||||||
|
break;
|
||||||
|
case 'Tab':
|
||||||
|
close();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowMouseDown(e: MouseEvent) {
|
||||||
|
if (!open) return;
|
||||||
|
const target = e.target as Node;
|
||||||
|
if (menuEl?.contains(target) || triggerEl?.contains(target)) return;
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close on viewport changes — naive but enough; without a portal a
|
||||||
|
// scrolling list would otherwise leave the popover drifting away from
|
||||||
|
// its row.
|
||||||
|
function onViewportChange() {
|
||||||
|
if (open) close();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
onmousedown={onWindowMouseDown}
|
||||||
|
onscroll={onViewportChange}
|
||||||
|
onresize={onViewportChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
<button
|
||||||
|
bind:this={triggerEl}
|
||||||
|
type="button"
|
||||||
|
class="trigger"
|
||||||
|
class:open
|
||||||
|
aria-label={label}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
onclick={toggle}
|
||||||
|
onkeydown={onTriggerKeydown}
|
||||||
|
>
|
||||||
|
<!-- vertical ellipsis ⋮ — kept inline as text so it inherits color -->
|
||||||
|
<span aria-hidden="true">⋮</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
bind:this={menuEl}
|
||||||
|
class="menu"
|
||||||
|
role="menu"
|
||||||
|
tabindex="-1"
|
||||||
|
onkeydown={onMenuKeydown}
|
||||||
|
>
|
||||||
|
{#each items as item, i (i)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
class="item"
|
||||||
|
class:danger={item.danger}
|
||||||
|
class:active={i === activeIndex}
|
||||||
|
disabled={item.disabled}
|
||||||
|
onclick={() => activate(i)}
|
||||||
|
onmouseenter={() => {
|
||||||
|
if (!item.disabled) activeIndex = i;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger:hover,
|
||||||
|
.trigger:focus-visible,
|
||||||
|
.trigger.open {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-color: #334155;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 9rem;
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: 0 10px 25px -10px rgba(0, 0, 0, 0.6);
|
||||||
|
padding: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
background: transparent;
|
||||||
|
color: #cbd5e1;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.active:not(:disabled) {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.danger {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.danger.active:not(:disabled) {
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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>
|
||||||
328
dashboard/src/lib/ConfirmModal.svelte
Normal file
328
dashboard/src/lib/ConfirmModal.svelte
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
<!--
|
||||||
|
Confirmation modal — replaces window.confirm/prompt for destructive
|
||||||
|
actions so the dashboard can render the full context (counts, lists,
|
||||||
|
warnings) in its own style.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
{#if showing}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete app"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Delete app"
|
||||||
|
confirmPhrase={app.slug}
|
||||||
|
onConfirm={() => doDelete()}
|
||||||
|
onCancel={() => (showing = false)}
|
||||||
|
>
|
||||||
|
<p>Body content — counts, lists, warnings.</p>
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
When `confirmPhrase` is set the confirm button stays disabled until
|
||||||
|
the user types the phrase exactly — same pattern GitHub uses for
|
||||||
|
irreversible repo deletes. Omit it for low-stakes confirmations.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
type Variant = 'danger' | 'neutral';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
children: Snippet;
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
variant?: Variant;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
/** When set, the confirm button is disabled until the user types
|
||||||
|
* this string exactly (case-sensitive). */
|
||||||
|
confirmPhrase?: string;
|
||||||
|
/** Shown above the confirm input. Defaults to a sensible message
|
||||||
|
* that mentions the phrase. */
|
||||||
|
confirmPhrasePrompt?: string;
|
||||||
|
/** While true the buttons are disabled and the confirm label is
|
||||||
|
* replaced with a "busy" form (e.g. "Deleting…"). */
|
||||||
|
busy?: boolean;
|
||||||
|
busyLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
variant = 'neutral',
|
||||||
|
confirmLabel = 'Confirm',
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
confirmPhrase,
|
||||||
|
confirmPhrasePrompt,
|
||||||
|
busy = false,
|
||||||
|
busyLabel
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let typed = $state('');
|
||||||
|
let phraseMatches = $derived(
|
||||||
|
confirmPhrase === undefined || typed === confirmPhrase
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && !busy) {
|
||||||
|
event.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdrop(event: MouseEvent) {
|
||||||
|
// Only cancel when clicking the backdrop itself, not bubbled
|
||||||
|
// clicks from the dialog content.
|
||||||
|
if (event.target === event.currentTarget && !busy) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
if (!phraseMatches || busy) return;
|
||||||
|
await onConfirm();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus management: when the modal mounts, focus the slug-retype
|
||||||
|
// input (if present) or the cancel button (so an accidental Enter
|
||||||
|
// doesn't auto-confirm a destructive action).
|
||||||
|
let inputRef = $state<HTMLInputElement | null>(null);
|
||||||
|
let cancelRef = $state<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (inputRef) inputRef.focus();
|
||||||
|
else if (cancelRef) cancelRef.focus();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={handleBackdrop}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="dialog"
|
||||||
|
class:danger={variant === 'danger'}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="confirm-title"
|
||||||
|
>
|
||||||
|
<h2 id="confirm-title">{title}</h2>
|
||||||
|
<div class="body">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if confirmPhrase}
|
||||||
|
<label class="phrase">
|
||||||
|
<span>
|
||||||
|
{confirmPhrasePrompt ?? `Type the slug to confirm:`}
|
||||||
|
<code>{confirmPhrase}</code>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
bind:this={inputRef}
|
||||||
|
bind:value={typed}
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
bind:this={cancelRef}
|
||||||
|
onclick={onCancel}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={variant === 'danger' ? 'danger' : ''}
|
||||||
|
onclick={handleConfirm}
|
||||||
|
disabled={!phraseMatches || busy}
|
||||||
|
>
|
||||||
|
{busy ? (busyLabel ?? `${confirmLabel}…`) : confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(2, 6, 23, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 32rem;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog.danger {
|
||||||
|
border-color: #7f1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog.danger h2 {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(p) {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(code) {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(strong) {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(ul) {
|
||||||
|
margin: 0.5rem 0 0.75rem;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(.impact-list) {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
max-height: 12rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(.impact-list li) {
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(.impact-list li + li) {
|
||||||
|
border-top: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(.modal-error) {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(.muted) {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase code {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase input {
|
||||||
|
background: #0b1220;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font: inherit;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: #7f1d1d;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
69
dashboard/src/lib/RoleChip.svelte
Normal file
69
dashboard/src/lib/RoleChip.svelte
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { InstanceRole } from '$lib/auth';
|
||||||
|
import type { AppRole } from '$lib/api';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
role?: InstanceRole;
|
||||||
|
appRole?: AppRole;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
let { role, appRole, size = 'md' }: Props = $props();
|
||||||
|
|
||||||
|
// Display label: app roles read better with a space ("app admin")
|
||||||
|
// than their wire form ("app_admin").
|
||||||
|
const label = $derived(
|
||||||
|
appRole ? appRole.replace('_', ' ') : (role ?? '')
|
||||||
|
);
|
||||||
|
const cls = $derived(appRole ? `chip-${appRole}` : `chip-${role}`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="chip {cls}" class:sm={size === 'sm'}>{label}</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.chip.sm {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
}
|
||||||
|
.chip-owner {
|
||||||
|
background: #78350f;
|
||||||
|
color: #fbbf24;
|
||||||
|
border-color: #b45309;
|
||||||
|
}
|
||||||
|
.chip-admin {
|
||||||
|
background: #164e63;
|
||||||
|
color: #67e8f9;
|
||||||
|
border-color: #0e7490;
|
||||||
|
}
|
||||||
|
.chip-member {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #cbd5e1;
|
||||||
|
border-color: #334155;
|
||||||
|
}
|
||||||
|
.chip-app_admin {
|
||||||
|
background: #4c1d95;
|
||||||
|
color: #c4b5fd;
|
||||||
|
border-color: #6d28d9;
|
||||||
|
}
|
||||||
|
.chip-editor {
|
||||||
|
background: #1e3a8a;
|
||||||
|
color: #93c5fd;
|
||||||
|
border-color: #1d4ed8;
|
||||||
|
}
|
||||||
|
.chip-viewer {
|
||||||
|
background: #1f2937;
|
||||||
|
color: #9ca3af;
|
||||||
|
border-color: #374151;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,13 @@
|
|||||||
// 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 InstanceRole } from './auth';
|
||||||
|
|
||||||
|
export type { InstanceRole };
|
||||||
|
|
||||||
export interface ScriptSandbox {
|
export interface ScriptSandbox {
|
||||||
max_operations?: number;
|
max_operations?: number;
|
||||||
max_string_size?: number;
|
max_string_size?: number;
|
||||||
@@ -16,6 +23,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 +35,71 @@ 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 AppRole = 'app_admin' | 'editor' | 'viewer';
|
||||||
|
|
||||||
|
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;
|
||||||
|
/// The caller's role on this app — owners are implicit `app_admin`,
|
||||||
|
/// admins implicit `editor`, members carry their `app_members.role`.
|
||||||
|
/// `null` only when a member somehow reaches the endpoint without
|
||||||
|
/// a membership (the server normally 403s first).
|
||||||
|
my_role: AppRole | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +169,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 +203,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 +241,160 @@ function safeJson(text: string): unknown {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Scope =
|
||||||
|
| 'script:read'
|
||||||
|
| 'script:write'
|
||||||
|
| 'route:write'
|
||||||
|
| 'domain:manage'
|
||||||
|
| 'log:read'
|
||||||
|
| 'app:admin'
|
||||||
|
| 'instance:admin';
|
||||||
|
|
||||||
|
export const ALL_SCOPES: readonly Scope[] = [
|
||||||
|
'script:read',
|
||||||
|
'script:write',
|
||||||
|
'route:write',
|
||||||
|
'domain:manage',
|
||||||
|
'log:read',
|
||||||
|
'app:admin',
|
||||||
|
'instance:admin'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function isInstanceScope(s: Scope): boolean {
|
||||||
|
return s.startsWith('instance:');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeDto {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
instance_role: InstanceRole;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDto {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
is_active: boolean;
|
||||||
|
instance_role: InstanceRole;
|
||||||
|
email: string | null;
|
||||||
|
created_at: string;
|
||||||
|
last_login_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAdminInput {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
instance_role?: InstanceRole;
|
||||||
|
email?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatchAdminInput {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
instance_role?: InstanceRole;
|
||||||
|
email?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppMemberDto {
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
instance_role: InstanceRole;
|
||||||
|
is_active: boolean;
|
||||||
|
role: AppRole;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrantAppMemberInput {
|
||||||
|
user_id: string;
|
||||||
|
role: AppRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyDto {
|
||||||
|
id: string;
|
||||||
|
prefix: string;
|
||||||
|
name: string;
|
||||||
|
scopes: Scope[];
|
||||||
|
app_id: string | null;
|
||||||
|
expires_at: string | null;
|
||||||
|
last_used_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MintApiKeyInput {
|
||||||
|
name: string;
|
||||||
|
scopes: Scope[];
|
||||||
|
app_id?: string | null;
|
||||||
|
expires_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MintApiKeyResponse extends ApiKeyDto {
|
||||||
|
raw_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
user: MeDto;
|
||||||
|
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<MeDto> => {
|
||||||
|
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<MeDto>('/api/v1/admin/auth/me')
|
||||||
|
},
|
||||||
|
|
||||||
|
admins: {
|
||||||
|
list: () => adminRequest<AdminDto[]>('/api/v1/admin/admins'),
|
||||||
|
get: (id: string) => adminRequest<AdminDto>(`/api/v1/admin/admins/${id}`),
|
||||||
|
create: (input: CreateAdminInput) =>
|
||||||
|
adminRequest<AdminDto>('/api/v1/admin/admins', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}),
|
||||||
|
update: (id: string, input: PatchAdminInput) =>
|
||||||
|
adminRequest<AdminDto>(`/api/v1/admin/admins/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}),
|
||||||
|
remove: (id: string) =>
|
||||||
|
adminRequest<null>(`/api/v1/admin/admins/${id}`, { method: 'DELETE' })
|
||||||
|
},
|
||||||
|
|
||||||
|
apiKeys: {
|
||||||
|
list: () => adminRequest<ApiKeyDto[]>('/api/v1/admin/api-keys'),
|
||||||
|
mint: (input: MintApiKeyInput) =>
|
||||||
|
adminRequest<MintApiKeyResponse>('/api/v1/admin/api-keys', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}),
|
||||||
|
revoke: (id: string) =>
|
||||||
|
adminRequest<null>(`/api/v1/admin/api-keys/${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 +405,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 +446,76 @@ 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, opts: { force?: boolean } = {}) => {
|
||||||
|
const qs = opts.force ? '?force=true' : '';
|
||||||
|
return adminRequest<null>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}${qs}`,
|
||||||
|
{ 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' }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
appMembers: {
|
||||||
|
list: (idOrSlug: string) =>
|
||||||
|
adminRequest<AppMemberDto[]>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members`
|
||||||
|
),
|
||||||
|
add: (idOrSlug: string, input: GrantAppMemberInput) =>
|
||||||
|
adminRequest<AppMemberDto>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members`,
|
||||||
|
{ method: 'POST', body: JSON.stringify(input) }
|
||||||
|
),
|
||||||
|
setRole: (idOrSlug: string, userId: string, role: AppRole) =>
|
||||||
|
adminRequest<AppMemberDto>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members/${userId}`,
|
||||||
|
{ method: 'PATCH', body: JSON.stringify({ role }) }
|
||||||
|
),
|
||||||
|
remove: (idOrSlug: string, userId: string) =>
|
||||||
|
adminRequest<null>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members/${userId}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
execute: async (
|
execute: async (
|
||||||
id: string,
|
id: string,
|
||||||
body: unknown,
|
body: unknown,
|
||||||
|
|||||||
64
dashboard/src/lib/auth.ts
Normal file
64
dashboard/src/lib/auth.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// 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 type InstanceRole = 'owner' | 'admin' | 'member';
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
instance_role: InstanceRole;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
25
dashboard/src/lib/password-gen.ts
Normal file
25
dashboard/src/lib/password-gen.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Cryptographically random password generator for the user-create
|
||||||
|
// and reset-password flows. PiCloud has no email yet, so the admin
|
||||||
|
// invites a user by generating a password locally, posting it to the
|
||||||
|
// backend, and copying the cleartext out of the one-time reveal panel
|
||||||
|
// to share through whatever channel they trust.
|
||||||
|
//
|
||||||
|
// Charset is alphanumeric plus a small printable symbol set — enough
|
||||||
|
// entropy at 16 chars (~95 bits) to be uncopyable by hand mistakes,
|
||||||
|
// avoidant of characters that ship awkwardly through chat clients
|
||||||
|
// (no quotes, slashes, or backticks).
|
||||||
|
|
||||||
|
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
|
||||||
|
|
||||||
|
export function generatePassword(length = 16): string {
|
||||||
|
if (length < 8) {
|
||||||
|
throw new Error('password length must be at least 8');
|
||||||
|
}
|
||||||
|
const buf = new Uint32Array(length);
|
||||||
|
crypto.getRandomValues(buf);
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
out += CHARSET[buf[i] % CHARSET.length];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
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,4 +1,4 @@
|
|||||||
import type { HostKind, PathKind } from './api';
|
import type { AppDomain, HostKind, PathKind } from './api';
|
||||||
|
|
||||||
/** Guess a path kind from the literal user input. The dashboard pre-fills
|
/** Guess a path kind from the literal user input. The dashboard pre-fills
|
||||||
* the kind selector but the user can override (the backend trusts the
|
* the kind selector but the user can override (the backend trusts the
|
||||||
@@ -30,3 +30,97 @@ export function pathKindMismatchWarning(raw: string, kind: PathKind): string | n
|
|||||||
: 'this looks like a literal path';
|
: 'this looks like a literal path';
|
||||||
return `Selected kind is "${kind}", but ${hint}. Routing will use the selected kind.`;
|
return `Selected kind is "${kind}", but ${hint}. Routing will use the selected kind.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse the user's free-text host input into the (kind, stored host)
|
||||||
|
* pair the API expects. Mirrors the dashboard's pre-existing storage
|
||||||
|
* convention: wildcard route hosts are stored as the bare suffix
|
||||||
|
* (`*.foo.com` → `foo.com`); display-time formatting re-adds the `*.`. */
|
||||||
|
export interface ParsedHostInput {
|
||||||
|
kind: HostKind;
|
||||||
|
/** What gets sent in the API payload as `host`. */
|
||||||
|
host: string;
|
||||||
|
/** Canonical display form, for chips/labels. */
|
||||||
|
display: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseHostInput(raw: string): ParsedHostInput {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed === '' || trimmed === '*') {
|
||||||
|
return { kind: 'any', host: '', display: '*' };
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith('*.')) {
|
||||||
|
const suffix = trimmed.slice(2);
|
||||||
|
return { kind: 'wildcard', host: suffix, display: `*.${suffix}` };
|
||||||
|
}
|
||||||
|
return { kind: 'strict', host: trimmed, display: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Frontend mirror of `validate_route_host_against_app` in
|
||||||
|
* crates/manager-core/src/route_admin.rs. Lets us surface a live
|
||||||
|
* warning instead of waiting for a 422. The server runs the same
|
||||||
|
* check on submit — this is purely an early signal. */
|
||||||
|
export type HostClaimCheck =
|
||||||
|
| { ok: true; matched: string }
|
||||||
|
| { ok: false; reason: string };
|
||||||
|
|
||||||
|
export function checkHostAgainstClaims(
|
||||||
|
parsed: ParsedHostInput,
|
||||||
|
claims: AppDomain[]
|
||||||
|
): HostClaimCheck {
|
||||||
|
if (parsed.kind === 'any') return { ok: true, matched: '*' };
|
||||||
|
if (claims.length === 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: 'this app has no domain claims yet — add one in the app’s Domains tab'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const hostLower = parsed.host.toLowerCase();
|
||||||
|
for (const claim of claims) {
|
||||||
|
const claimLower = claim.pattern.toLowerCase();
|
||||||
|
const claimSuffix = claimLower.split('.').slice(1).join('.');
|
||||||
|
if (parsed.kind === 'strict') {
|
||||||
|
if (claim.shape === 'exact' && hostLower === claimLower) {
|
||||||
|
return { ok: true, matched: claim.pattern };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(claim.shape === 'wildcard' || claim.shape === 'parameterized') &&
|
||||||
|
claimSuffix &&
|
||||||
|
hostLower.endsWith(`.${claimSuffix}`)
|
||||||
|
) {
|
||||||
|
return { ok: true, matched: claim.pattern };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// wildcard route
|
||||||
|
if (
|
||||||
|
(claim.shape === 'wildcard' || claim.shape === 'parameterized') &&
|
||||||
|
claimSuffix === hostLower
|
||||||
|
) {
|
||||||
|
return { ok: true, matched: claim.pattern };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `${parsed.display} is not covered by any of this app’s claims`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suggestion strings for the host input's <datalist>. We always
|
||||||
|
* include the wildcard `*` (any host) as the first option, then the
|
||||||
|
* app's claims rendered in the form the route input expects. */
|
||||||
|
export function hostSuggestions(claims: AppDomain[]): string[] {
|
||||||
|
const items: string[] = ['*'];
|
||||||
|
for (const claim of claims) {
|
||||||
|
if (claim.shape === 'exact') {
|
||||||
|
items.push(claim.pattern);
|
||||||
|
} else {
|
||||||
|
// Both wildcard and parameterized claims are usable as a
|
||||||
|
// wildcard route. Render as `*.suffix` — we can't preserve
|
||||||
|
// the {param} binding name through the current route form.
|
||||||
|
const suffix = claim.pattern.split('.').slice(1).join('.');
|
||||||
|
if (suffix) items.push(`*.${suffix}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dedupe while preserving order.
|
||||||
|
return [...new Set(items)];
|
||||||
|
}
|
||||||
|
|||||||
30
dashboard/src/lib/slugify.ts
Normal file
30
dashboard/src/lib/slugify.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// Slug normalization for app slugs, mirrored against the backend's
|
||||||
|
// validate_slug rules in crates/manager-core/src/apps_api.rs:
|
||||||
|
// - regex: ^[a-z0-9][a-z0-9-]{0,62}$
|
||||||
|
// - 1..=63 chars, lowercase ascii alphanumerics + `-`
|
||||||
|
// - must start with [a-z0-9]
|
||||||
|
// - reserved words are enforced server-side only
|
||||||
|
//
|
||||||
|
// Normalization rules are GitLab-style (close to `Babosa::Latin#to_slug`):
|
||||||
|
// 1. NFKD-decompose Unicode and drop combining marks (é → e, ñ → n,
|
||||||
|
// ü → u, etc.).
|
||||||
|
// 2. ß → ss (a single common case the strip-marks pass misses).
|
||||||
|
// 3. Lowercase.
|
||||||
|
// 4. Replace any run of non-[a-z0-9] with a single `-`.
|
||||||
|
// 5. Trim leading/trailing `-`.
|
||||||
|
// 6. Truncate to 63 chars.
|
||||||
|
|
||||||
|
export const SLUG_MAX = 63;
|
||||||
|
|
||||||
|
export function slugify(input: string): string {
|
||||||
|
if (!input) return '';
|
||||||
|
let s = input.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
|
||||||
|
s = s.toLowerCase().replace(/ß/g, 'ss');
|
||||||
|
s = s.replace(/[^a-z0-9]+/g, '-');
|
||||||
|
s = s.replace(/^-+|-+$/g, '');
|
||||||
|
if (s.length > SLUG_MAX) {
|
||||||
|
// Truncate, then re-trim in case the cut landed on a `-`.
|
||||||
|
s = s.slice(0, SLUG_MAX).replace(/-+$/g, '');
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
@@ -1,17 +1,73 @@
|
|||||||
<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';
|
||||||
|
import RoleChip from '$lib/RoleChip.svelte';
|
||||||
|
|
||||||
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>
|
||||||
|
{#if user && user.instance_role !== 'member'}
|
||||||
|
<a href={base + '/users'}>Users</a>
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
{#if user}
|
||||||
|
<div class="usermenu">
|
||||||
|
<a href={base + '/profile'} class="profile-chip" title="View profile">
|
||||||
|
<RoleChip role={user.instance_role} size="sm" />
|
||||||
|
<span class="username">{user.username}</span>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="logout" onclick={handleLogout}>Logout</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
{@render children?.()}
|
{#if booting}
|
||||||
|
<p class="boot">Loading…</p>
|
||||||
|
{:else}
|
||||||
|
{@render children?.()}
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -45,6 +101,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 +116,50 @@
|
|||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usermenu {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.profile-chip:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
border-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 +168,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>
|
||||||
|
|||||||
342
dashboard/src/routes/apps/+page.svelte
Normal file
342
dashboard/src/routes/apps/+page.svelte
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { api, ApiError, type App } from '$lib/api';
|
||||||
|
import { slugify, SLUG_MAX } from '$lib/slugify';
|
||||||
|
|
||||||
|
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('');
|
||||||
|
// Auto-derive slug from name until the user takes manual control of
|
||||||
|
// the slug field. Clearing the slug input releases the lock so the
|
||||||
|
// auto-derive resumes — matches the GitLab project-create UX.
|
||||||
|
let slugTouched = $state(false);
|
||||||
|
let creating = $state(false);
|
||||||
|
let createError = $state<string | null>(null);
|
||||||
|
let createHistoricalConflict = $state<App | null>(null);
|
||||||
|
|
||||||
|
function onNameInput(event: Event) {
|
||||||
|
const value = (event.target as HTMLInputElement).value;
|
||||||
|
createName = value;
|
||||||
|
if (!slugTouched) {
|
||||||
|
createSlug = slugify(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSlugInput(event: Event) {
|
||||||
|
const raw = (event.target as HTMLInputElement).value;
|
||||||
|
const normalized = slugify(raw);
|
||||||
|
createSlug = normalized;
|
||||||
|
// Re-sync the input element so a paste of "Hello World!" shows
|
||||||
|
// "hello-world" immediately, not the raw value.
|
||||||
|
if (raw !== normalized) {
|
||||||
|
(event.target as HTMLInputElement).value = normalized;
|
||||||
|
}
|
||||||
|
slugTouched = normalized.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
slugTouched = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>Name</span>
|
||||||
|
<input
|
||||||
|
value={createName}
|
||||||
|
oninput={onNameInput}
|
||||||
|
required
|
||||||
|
placeholder="My App"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Slug</span>
|
||||||
|
<input
|
||||||
|
value={createSlug}
|
||||||
|
oninput={onSlugInput}
|
||||||
|
required
|
||||||
|
pattern="[a-z0-9][a-z0-9-]*"
|
||||||
|
maxlength={SLUG_MAX}
|
||||||
|
placeholder="my-app"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
1081
dashboard/src/routes/apps/[slug]/+page.svelte
Normal file
1081
dashboard/src/routes/apps/[slug]/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
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>
|
||||||
760
dashboard/src/routes/profile/+page.svelte
Normal file
760
dashboard/src/routes/profile/+page.svelte
Normal file
@@ -0,0 +1,760 @@
|
|||||||
|
<!--
|
||||||
|
/admin/profile — every authenticated principal lands here for their
|
||||||
|
own identity + API-key management. No role gating: a member can mint
|
||||||
|
keys for the apps they belong to just like an admin can. Users-admin
|
||||||
|
actions live under /admin/users.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
ApiError,
|
||||||
|
ALL_SCOPES,
|
||||||
|
isInstanceScope,
|
||||||
|
type ApiKeyDto,
|
||||||
|
type App,
|
||||||
|
type MintApiKeyResponse,
|
||||||
|
type Scope
|
||||||
|
} from '$lib/api';
|
||||||
|
import { currentUser } from '$lib/auth';
|
||||||
|
import RoleChip from '$lib/RoleChip.svelte';
|
||||||
|
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||||
|
|
||||||
|
const me = $derived($currentUser);
|
||||||
|
|
||||||
|
let keys = $state<ApiKeyDto[]>([]);
|
||||||
|
let apps = $state<App[]>([]);
|
||||||
|
let appBySlug = $derived(new Map(apps.map((a) => [a.id, a])));
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
|
||||||
|
|
||||||
|
// Surface the cross-page "access denied" notice when /users bounces
|
||||||
|
// a member back here. One-shot — clears as soon as the user
|
||||||
|
// navigates away or dismisses.
|
||||||
|
const deniedFromUsers = $derived(page.url.searchParams.get('denied') === 'users');
|
||||||
|
|
||||||
|
let mintOpen = $state(false);
|
||||||
|
let mintForm = $state<{
|
||||||
|
name: string;
|
||||||
|
scopes: Set<Scope>;
|
||||||
|
app_id: string | '';
|
||||||
|
expires_at: string;
|
||||||
|
}>({ name: '', scopes: new Set(), app_id: '', expires_at: '' });
|
||||||
|
let mintPending = $state(false);
|
||||||
|
let mintError = $state<string | null>(null);
|
||||||
|
|
||||||
|
let reveal = $state<MintApiKeyResponse | null>(null);
|
||||||
|
let revealAck = $state(false);
|
||||||
|
let copyState = $state<'idle' | 'copied'>('idle');
|
||||||
|
|
||||||
|
let revokeTarget = $state<ApiKeyDto | null>(null);
|
||||||
|
let revokePending = $state(false);
|
||||||
|
|
||||||
|
const NAME_MAX = 64;
|
||||||
|
const scopeIsInstance = (s: Scope) => isInstanceScope(s);
|
||||||
|
const boundToApp = $derived(mintForm.app_id !== '');
|
||||||
|
|
||||||
|
const canSubmit = $derived(
|
||||||
|
mintForm.name.trim().length > 0 &&
|
||||||
|
mintForm.name.trim().length <= NAME_MAX &&
|
||||||
|
mintForm.scopes.size > 0 &&
|
||||||
|
!mintPending
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await Promise.all([refreshKeys(), loadApps()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshKeys() {
|
||||||
|
try {
|
||||||
|
keys = await api.apiKeys.list();
|
||||||
|
loadError = null;
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e instanceof ApiError ? e.message : 'failed to load API keys';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadApps() {
|
||||||
|
try {
|
||||||
|
apps = await api.apps.list();
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: the form falls back to "no app options" and the
|
||||||
|
// list shows the bare UUID in the binding column.
|
||||||
|
apps = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(kind: 'error' | 'info', message: string) {
|
||||||
|
banner = { kind, message };
|
||||||
|
setTimeout(() => {
|
||||||
|
if (banner?.message === message) banner = null;
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMint() {
|
||||||
|
mintForm = { name: '', scopes: new Set(), app_id: '', expires_at: '' };
|
||||||
|
mintError = null;
|
||||||
|
mintOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelMint() {
|
||||||
|
mintOpen = false;
|
||||||
|
mintError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleScope(s: Scope) {
|
||||||
|
const next = new Set(mintForm.scopes);
|
||||||
|
if (next.has(s)) next.delete(s);
|
||||||
|
else next.add(s);
|
||||||
|
mintForm = { ...mintForm, scopes: next };
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the user binds the key to an app, instance:* scopes are
|
||||||
|
// mutually exclusive — drop them from the selection so submit
|
||||||
|
// doesn't 422.
|
||||||
|
$effect(() => {
|
||||||
|
if (!boundToApp) return;
|
||||||
|
const filtered = new Set<Scope>();
|
||||||
|
let dropped = false;
|
||||||
|
for (const s of mintForm.scopes) {
|
||||||
|
if (scopeIsInstance(s)) dropped = true;
|
||||||
|
else filtered.add(s);
|
||||||
|
}
|
||||||
|
if (dropped) {
|
||||||
|
mintForm = { ...mintForm, scopes: filtered };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submitMint(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
mintPending = true;
|
||||||
|
mintError = null;
|
||||||
|
try {
|
||||||
|
const r = await api.apiKeys.mint({
|
||||||
|
name: mintForm.name.trim(),
|
||||||
|
scopes: Array.from(mintForm.scopes),
|
||||||
|
app_id: mintForm.app_id === '' ? null : mintForm.app_id,
|
||||||
|
expires_at: mintForm.expires_at === ''
|
||||||
|
? null
|
||||||
|
: new Date(mintForm.expires_at + 'T23:59:59Z').toISOString()
|
||||||
|
});
|
||||||
|
reveal = r;
|
||||||
|
revealAck = false;
|
||||||
|
copyState = 'idle';
|
||||||
|
mintOpen = false;
|
||||||
|
await refreshKeys();
|
||||||
|
} catch (e) {
|
||||||
|
mintError = e instanceof ApiError ? e.message : 'failed to mint API key';
|
||||||
|
} finally {
|
||||||
|
mintPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToken() {
|
||||||
|
if (!reveal) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(reveal.raw_token);
|
||||||
|
copyState = 'copied';
|
||||||
|
setTimeout(() => (copyState = 'idle'), 2000);
|
||||||
|
} catch {
|
||||||
|
flash('error', 'Clipboard write failed — select and copy manually.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissReveal() {
|
||||||
|
reveal = null;
|
||||||
|
revealAck = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRevoke(key: ApiKeyDto) {
|
||||||
|
revokeTarget = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRevoke() {
|
||||||
|
if (!revokeTarget) return;
|
||||||
|
revokePending = true;
|
||||||
|
const target = revokeTarget;
|
||||||
|
try {
|
||||||
|
await api.apiKeys.revoke(target.id);
|
||||||
|
revokeTarget = null;
|
||||||
|
keys = keys.filter((k) => k.id !== target.id);
|
||||||
|
flash('info', `Revoked "${target.name}".`);
|
||||||
|
} catch (e) {
|
||||||
|
flash('error', e instanceof ApiError ? e.message : 'failed to revoke key');
|
||||||
|
} finally {
|
||||||
|
revokePending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appLabel(app_id: string | null): string {
|
||||||
|
if (!app_id) return 'Instance-wide';
|
||||||
|
const a = appBySlug.get(app_id);
|
||||||
|
return a ? a.slug : app_id.slice(0, 8) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(iso: string | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relative(iso: string | null): string {
|
||||||
|
if (!iso) return 'Never';
|
||||||
|
const then = new Date(iso).getTime();
|
||||||
|
const sec = Math.round((Date.now() - then) / 1000);
|
||||||
|
if (sec < 60) return `${sec}s ago`;
|
||||||
|
const min = Math.round(sec / 60);
|
||||||
|
if (min < 60) return `${min}m ago`;
|
||||||
|
const hr = Math.round(min / 60);
|
||||||
|
if (hr < 24) return `${hr}h ago`;
|
||||||
|
const day = Math.round(hr / 24);
|
||||||
|
if (day < 7) return `${day}d ago`;
|
||||||
|
return shortDate(iso);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if me}
|
||||||
|
<section class="identity">
|
||||||
|
<div class="identity-head">
|
||||||
|
<h1>{me.username}</h1>
|
||||||
|
<RoleChip role={me.instance_role} />
|
||||||
|
</div>
|
||||||
|
<dl class="identity-meta">
|
||||||
|
<div>
|
||||||
|
<dt>Email</dt>
|
||||||
|
<dd>{me.email ?? 'No email set'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>User ID</dt>
|
||||||
|
<dd class="mono">{me.id}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if deniedFromUsers}
|
||||||
|
<div class="banner banner-info">
|
||||||
|
You don't have access to the Users page. Ask an admin if you need to manage users.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if banner}
|
||||||
|
<div class="banner banner-{banner.kind}">{banner.message}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="keys-section">
|
||||||
|
<header class="section-head">
|
||||||
|
<h2>API keys</h2>
|
||||||
|
{#if !mintOpen && !reveal}
|
||||||
|
<button type="button" class="primary" onclick={openMint}>+ Mint API key</button>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if reveal}
|
||||||
|
<div class="reveal">
|
||||||
|
<h3>Save this token now — it will never be shown again.</h3>
|
||||||
|
<p class="reveal-sub">
|
||||||
|
Paste it into your CLI config or external integration. PiCloud only ever stores a hash; if
|
||||||
|
you lose it, mint a new one.
|
||||||
|
</p>
|
||||||
|
<div class="token-row">
|
||||||
|
<code class="token">{reveal.raw_token}</code>
|
||||||
|
<button type="button" class="ghost" onclick={copyToken}>
|
||||||
|
{copyState === 'copied' ? 'Copied ✓' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="ack">
|
||||||
|
<input type="checkbox" bind:checked={revealAck} />
|
||||||
|
<span>I've saved this token somewhere safe.</span>
|
||||||
|
</label>
|
||||||
|
<div class="reveal-actions">
|
||||||
|
<button type="button" class="primary" disabled={!revealAck} onclick={dismissReveal}>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if mintOpen}
|
||||||
|
<form class="mint" onsubmit={submitMint}>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span>Name</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={mintForm.name}
|
||||||
|
maxlength={NAME_MAX}
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="e.g. ci-deploy"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small>1–{NAME_MAX} chars. Only you see it.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Binding</span>
|
||||||
|
<select bind:value={mintForm.app_id}>
|
||||||
|
<option value="">Instance-wide</option>
|
||||||
|
{#each apps as a (a.id)}
|
||||||
|
<option value={a.id}>{a.slug} ({a.name})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<small>Pick an app to scope this key, or leave instance-wide.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Expires</span>
|
||||||
|
<input type="date" bind:value={mintForm.expires_at} />
|
||||||
|
<small>Leave blank for no expiry.</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="scopes">
|
||||||
|
<legend>Scopes</legend>
|
||||||
|
<div class="scope-grid">
|
||||||
|
{#each ALL_SCOPES as scope (scope)}
|
||||||
|
{@const instanceScope = scopeIsInstance(scope)}
|
||||||
|
{@const disabled = boundToApp && instanceScope}
|
||||||
|
<label
|
||||||
|
class="scope-chip"
|
||||||
|
class:disabled
|
||||||
|
title={disabled ? "Bound keys can't carry instance scopes" : undefined}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={mintForm.scopes.has(scope)}
|
||||||
|
disabled={disabled || mintPending}
|
||||||
|
onchange={() => toggleScope(scope)}
|
||||||
|
/>
|
||||||
|
<span class="scope-name">{scope}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<small class="scope-hint">
|
||||||
|
{mintForm.scopes.size === 0
|
||||||
|
? 'Pick at least one scope.'
|
||||||
|
: `${mintForm.scopes.size} scope${mintForm.scopes.size === 1 ? '' : 's'} selected.`}
|
||||||
|
</small>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{#if mintError}
|
||||||
|
<div class="error">{mintError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="ghost" onclick={cancelMint}>Cancel</button>
|
||||||
|
<button type="submit" class="primary" disabled={!canSubmit}>
|
||||||
|
{mintPending ? 'Minting…' : 'Mint key'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<div class="error">
|
||||||
|
{loadError}
|
||||||
|
<button type="button" class="retry" onclick={refreshKeys}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if keys.length === 0 && !reveal && !mintOpen}
|
||||||
|
<p class="empty">
|
||||||
|
No API keys yet. Mint one to authenticate the CLI or external integrations.
|
||||||
|
</p>
|
||||||
|
{:else if keys.length > 0}
|
||||||
|
<div class="table">
|
||||||
|
<div class="row head-row">
|
||||||
|
<div>Name</div>
|
||||||
|
<div>Prefix</div>
|
||||||
|
<div>Scopes</div>
|
||||||
|
<div>Binding</div>
|
||||||
|
<div>Created</div>
|
||||||
|
<div>Last used</div>
|
||||||
|
<div>Expires</div>
|
||||||
|
<div class="actions-col"></div>
|
||||||
|
</div>
|
||||||
|
{#each keys as key (key.id)}
|
||||||
|
<div class="row">
|
||||||
|
<div class="name-cell">{key.name}</div>
|
||||||
|
<div class="mono prefix">pic_{key.prefix}…</div>
|
||||||
|
<div class="scopes-cell">
|
||||||
|
{#each key.scopes as s (s)}
|
||||||
|
<span class="scope-pill">{s}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div>{appLabel(key.app_id)}</div>
|
||||||
|
<div>{shortDate(key.created_at)}</div>
|
||||||
|
<div title={key.last_used_at ?? ''}>{relative(key.last_used_at)}</div>
|
||||||
|
<div>{key.expires_at ? shortDate(key.expires_at) : 'Never'}</div>
|
||||||
|
<div class="actions-col">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="danger-link"
|
||||||
|
onclick={() => openRevoke(key)}
|
||||||
|
aria-label="Revoke {key.name}"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if revokeTarget}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Revoke API key?"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Revoke"
|
||||||
|
busy={revokePending}
|
||||||
|
busyLabel="Revoking…"
|
||||||
|
onConfirm={confirmRevoke}
|
||||||
|
onCancel={() => (revokeTarget = null)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Revoking <strong>{revokeTarget.name}</strong> (<code>{revokeTarget.prefix}</code>) takes
|
||||||
|
effect immediately. Any CLI or integration using it will start returning <code>401</code>
|
||||||
|
on the next request.
|
||||||
|
</p>
|
||||||
|
<p class="muted">This can't be undone — mint a new key if you need one again.</p>
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.identity {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.identity-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.identity h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.identity-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||||
|
gap: 0.75rem 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.identity-meta div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
.identity-meta dt {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.identity-meta dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.section-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #ca8a04;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.reveal h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
.reveal-sub {
|
||||||
|
margin: 0;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.token-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.token {
|
||||||
|
flex: 1;
|
||||||
|
background: #020617;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ack {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.reveal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mint {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
.field small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scopes {
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.scopes legend {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
}
|
||||||
|
.scope-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||||
|
gap: 0.4rem 0.75rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.scope-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.scope-chip.disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.scope-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.retry {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
border: 1px dashed #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #0b1220;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.3fr 0.9fr 2fr 1fr 0.8fr 0.8fr 0.8fr 0.7fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.head-row {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
.name-cell {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
.prefix {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.scopes-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.scope-pill {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.actions-col {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.danger-link {
|
||||||
|
background: transparent;
|
||||||
|
color: #fca5a5;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.danger-link:hover {
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
button.primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
button.ghost:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
ApiError,
|
ApiError,
|
||||||
|
type AppDomain,
|
||||||
type ExecutionLog,
|
type ExecutionLog,
|
||||||
type Route,
|
type Route,
|
||||||
type RouteInput,
|
type RouteInput,
|
||||||
@@ -12,7 +13,26 @@
|
|||||||
type VersionInfo
|
type VersionInfo
|
||||||
} 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 {
|
||||||
|
checkHostAgainstClaims,
|
||||||
|
guessPathKind,
|
||||||
|
hostSuggestions,
|
||||||
|
parseHostInput,
|
||||||
|
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 +45,9 @@
|
|||||||
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);
|
||||||
|
let appDomains = $state<AppDomain[]>([]);
|
||||||
|
|
||||||
async function loadScript() {
|
async function loadScript() {
|
||||||
scriptLoading = true;
|
scriptLoading = true;
|
||||||
scriptError = null;
|
scriptError = null;
|
||||||
@@ -35,6 +58,23 @@
|
|||||||
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 and its
|
||||||
|
// domain claims for the route form's suggestions + live
|
||||||
|
// validation. Both are non-fatal — the page works without
|
||||||
|
// them.
|
||||||
|
const appId = script.app_id;
|
||||||
|
void api.apps
|
||||||
|
.get(appId)
|
||||||
|
.then((a) => {
|
||||||
|
appSlug = a.slug;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
void api.domains
|
||||||
|
.listForApp(appId)
|
||||||
|
.then((d) => {
|
||||||
|
appDomains = d;
|
||||||
|
})
|
||||||
|
.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 +95,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 +123,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>;
|
||||||
@@ -120,12 +192,14 @@
|
|||||||
let routesLoading = $state(true);
|
let routesLoading = $state(true);
|
||||||
|
|
||||||
let showAddRoute = $state(false);
|
let showAddRoute = $state(false);
|
||||||
let newRoutePath = $state('');
|
let newRoutePath = $state('/');
|
||||||
let newRoutePathKind = $state<'exact' | 'prefix' | 'param'>('exact');
|
let newRoutePathKind = $state<'exact' | 'prefix' | 'param'>('exact');
|
||||||
let newRouteHost = $state('');
|
// Host input is free-form; the kind is derived from what the user
|
||||||
let newRouteHostKind = $state<'any' | 'strict' | 'wildcard'>('any');
|
// typed (see `parsedHost` below). Default `*` = Any, matching the
|
||||||
|
// canonical display form for an unrestricted host.
|
||||||
|
let newRouteHost = $state('*');
|
||||||
let newRouteMethod = $state('');
|
let newRouteMethod = $state('');
|
||||||
let routeKindAutoUpdate = $state(true);
|
let pathKindAutoUpdate = $state(true);
|
||||||
let creatingRoute = $state(false);
|
let creatingRoute = $state(false);
|
||||||
let createRouteError = $state<string | null>(null);
|
let createRouteError = $state<string | null>(null);
|
||||||
|
|
||||||
@@ -133,17 +207,17 @@
|
|||||||
let previewMethod = $state('GET');
|
let previewMethod = $state('GET');
|
||||||
let previewResult = $state<string | null>(null);
|
let previewResult = $state<string | null>(null);
|
||||||
|
|
||||||
// Auto-update kind selectors as the user types.
|
// Auto-update the path-kind selector as the user types.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (routeKindAutoUpdate) {
|
if (pathKindAutoUpdate) {
|
||||||
newRoutePathKind = guessPathKind(newRoutePath);
|
newRoutePathKind = guessPathKind(newRoutePath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$effect(() => {
|
|
||||||
if (routeKindAutoUpdate) {
|
let parsedHost = $derived(parseHostInput(newRouteHost));
|
||||||
newRouteHostKind = guessHostKind(newRouteHost);
|
let hostCheck = $derived(checkHostAgainstClaims(parsedHost, appDomains));
|
||||||
}
|
let hostDatalistId = 'route-host-suggestions';
|
||||||
});
|
let suggestions = $derived(hostSuggestions(appDomains));
|
||||||
|
|
||||||
let pathKindWarning = $derived(
|
let pathKindWarning = $derived(
|
||||||
newRoutePath.trim() ? pathKindMismatchWarning(newRoutePath, newRoutePathKind) : null
|
newRoutePath.trim() ? pathKindMismatchWarning(newRoutePath, newRoutePathKind) : null
|
||||||
@@ -167,18 +241,18 @@
|
|||||||
createRouteError = null;
|
createRouteError = null;
|
||||||
try {
|
try {
|
||||||
const input: RouteInput = {
|
const input: RouteInput = {
|
||||||
host_kind: newRouteHostKind,
|
host_kind: parsedHost.kind,
|
||||||
host: newRouteHostKind === 'any' ? '' : newRouteHost.trim(),
|
host: parsedHost.host,
|
||||||
path_kind: newRoutePathKind,
|
path_kind: newRoutePathKind,
|
||||||
path: newRoutePath.trim(),
|
path: newRoutePath.trim(),
|
||||||
method: newRouteMethod.trim() || null
|
method: newRouteMethod.trim() || null
|
||||||
};
|
};
|
||||||
await api.routes.create(id, input);
|
await api.routes.create(id, input);
|
||||||
showAddRoute = false;
|
showAddRoute = false;
|
||||||
newRoutePath = '';
|
newRoutePath = '/';
|
||||||
newRouteHost = '';
|
newRouteHost = '*';
|
||||||
newRouteMethod = '';
|
newRouteMethod = '';
|
||||||
routeKindAutoUpdate = true;
|
pathKindAutoUpdate = true;
|
||||||
await loadRoutes();
|
await loadRoutes();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && e.status === 409) {
|
if (e instanceof ApiError && e.status === 409) {
|
||||||
@@ -206,8 +280,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 +398,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 +433,16 @@
|
|||||||
{#if tab === 'edit'}
|
{#if tab === 'edit'}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Source</h2>
|
<header class="editor-header">
|
||||||
<textarea bind:value={editableSource} rows="14" spellcheck="false"></textarea>
|
<h2>Source</h2>
|
||||||
|
<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 +459,30 @@
|
|||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Test invoke</h2>
|
<h2>Test invoke</h2>
|
||||||
<label>
|
<div class="json-block">
|
||||||
<span>Request body (JSON)</span>
|
<header class="json-header">
|
||||||
<textarea bind:value={testBody} rows="5" spellcheck="false"></textarea>
|
<span>Request body (JSON)</span>
|
||||||
</label>
|
<button type="button" class="ghost small" onclick={formatTestBody}>
|
||||||
<label>
|
Format
|
||||||
<span>Headers (JSON object)</span>
|
</button>
|
||||||
<textarea bind:value={testHeaders} rows="3" spellcheck="false"></textarea>
|
</header>
|
||||||
</label>
|
<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>
|
||||||
|
<button type="button" class="ghost small" onclick={formatTestHeaders}>
|
||||||
|
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'}
|
||||||
@@ -415,7 +521,7 @@
|
|||||||
<span>Path</span>
|
<span>Path</span>
|
||||||
<input
|
<input
|
||||||
bind:value={newRoutePath}
|
bind:value={newRoutePath}
|
||||||
oninput={() => (routeKindAutoUpdate = true)}
|
oninput={() => (pathKindAutoUpdate = true)}
|
||||||
placeholder="/greet, /greet/:name, /webhooks/*"
|
placeholder="/greet, /greet/:name, /webhooks/*"
|
||||||
required
|
required
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
@@ -426,7 +532,7 @@
|
|||||||
<span>Path kind</span>
|
<span>Path kind</span>
|
||||||
<select
|
<select
|
||||||
bind:value={newRoutePathKind}
|
bind:value={newRoutePathKind}
|
||||||
onchange={() => (routeKindAutoUpdate = false)}
|
onchange={() => (pathKindAutoUpdate = false)}
|
||||||
>
|
>
|
||||||
<option value="exact">exact</option>
|
<option value="exact">exact</option>
|
||||||
<option value="param">param</option>
|
<option value="param">param</option>
|
||||||
@@ -445,31 +551,43 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<label class="full">
|
||||||
<label>
|
<span class="host-label">
|
||||||
<span>Host kind</span>
|
Host
|
||||||
<select
|
<span class="kind-chip kind-{parsedHost.kind}">
|
||||||
bind:value={newRouteHostKind}
|
{parsedHost.kind}
|
||||||
onchange={() => (routeKindAutoUpdate = false)}
|
</span>
|
||||||
>
|
</span>
|
||||||
<option value="any">ANY</option>
|
<input
|
||||||
<option value="strict">strict</option>
|
bind:value={newRouteHost}
|
||||||
<option value="wildcard">wildcard</option>
|
placeholder="* · app.example.com · *.example.com"
|
||||||
</select>
|
list={hostDatalistId}
|
||||||
</label>
|
autocomplete="off"
|
||||||
<label class:disabled={newRouteHostKind === 'any'}>
|
autocapitalize="off"
|
||||||
<span>Host</span>
|
autocorrect="off"
|
||||||
<input
|
spellcheck="false"
|
||||||
bind:value={newRouteHost}
|
/>
|
||||||
oninput={() => (routeKindAutoUpdate = true)}
|
<datalist id={hostDatalistId}>
|
||||||
disabled={newRouteHostKind === 'any'}
|
{#each suggestions as s (s)}
|
||||||
placeholder={newRouteHostKind === 'wildcard'
|
<option value={s}></option>
|
||||||
? '*.example.com'
|
{/each}
|
||||||
: 'sub.example.com'}
|
</datalist>
|
||||||
autocomplete="off"
|
<small class="muted">
|
||||||
/>
|
<code>*</code> = any host claimed by this app ·
|
||||||
</label>
|
<code>*.foo.com</code> = wildcard · <code>foo.com</code> =
|
||||||
</div>
|
strict
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
{#if !hostCheck.ok}
|
||||||
|
<div class="warning inline">
|
||||||
|
{hostCheck.reason}.
|
||||||
|
{#if appDomains.length > 0}
|
||||||
|
Claims:
|
||||||
|
{#each appDomains as d, i (d.id)}<code>{d.pattern}</code>{#if i < appDomains.length - 1},
|
||||||
|
{/if}{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if pathKindWarning}
|
{#if pathKindWarning}
|
||||||
<div class="warning inline">{pathKindWarning}</div>
|
<div class="warning inline">{pathKindWarning}</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -687,6 +805,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 +862,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 +959,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea,
|
|
||||||
input,
|
input,
|
||||||
select {
|
select {
|
||||||
background: #0b1220;
|
background: #0b1220;
|
||||||
@@ -811,12 +971,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;
|
||||||
@@ -828,9 +982,6 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
label.disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
label.full {
|
label.full {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -947,6 +1098,31 @@
|
|||||||
background: #581c87;
|
background: #581c87;
|
||||||
color: #e9d5ff;
|
color: #e9d5ff;
|
||||||
}
|
}
|
||||||
|
.host-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.kind-chip {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.kind-chip.kind-any {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.kind-chip.kind-strict {
|
||||||
|
background: #14532d;
|
||||||
|
color: #bbf7d0;
|
||||||
|
}
|
||||||
|
.kind-chip.kind-wildcard {
|
||||||
|
background: #1e3a8a;
|
||||||
|
color: #bfdbfe;
|
||||||
|
}
|
||||||
.route-row .method {
|
.route-row .method {
|
||||||
color: #fbbf24;
|
color: #fbbf24;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
937
dashboard/src/routes/users/+page.svelte
Normal file
937
dashboard/src/routes/users/+page.svelte
Normal file
@@ -0,0 +1,937 @@
|
|||||||
|
<!--
|
||||||
|
/admin/users — owner + admin only. Members get bounced to /profile
|
||||||
|
with ?denied=users. Replaces the pre-3.5 /admin/admins page; this
|
||||||
|
one knows about roles, email, and the last-owner/last-admin guards.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
ApiError,
|
||||||
|
type AdminDto,
|
||||||
|
type InstanceRole
|
||||||
|
} from '$lib/api';
|
||||||
|
import { currentUser } from '$lib/auth';
|
||||||
|
import RoleChip from '$lib/RoleChip.svelte';
|
||||||
|
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||||
|
import ActionMenu from '$lib/ActionMenu.svelte';
|
||||||
|
import { generatePassword } from '$lib/password-gen';
|
||||||
|
|
||||||
|
const me = $derived($currentUser);
|
||||||
|
const myRole = $derived(me?.instance_role);
|
||||||
|
const isOwner = $derived(myRole === 'owner');
|
||||||
|
|
||||||
|
// Member guard. The backend already 403s the list call, but
|
||||||
|
// surfacing a friendly redirect avoids the dead-end empty page.
|
||||||
|
$effect(() => {
|
||||||
|
if (me && me.instance_role === 'member') {
|
||||||
|
void goto(`${base}/profile?denied=users`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let admins = $state<AdminDto[]>([]);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
|
||||||
|
|
||||||
|
let search = $state('');
|
||||||
|
const filtered = $derived(
|
||||||
|
(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return admins;
|
||||||
|
return admins.filter(
|
||||||
|
(a) =>
|
||||||
|
a.username.toLowerCase().includes(q) ||
|
||||||
|
(a.email ?? '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invite (create) modal --------------------------------------------------
|
||||||
|
let inviteOpen = $state(false);
|
||||||
|
let inviteForm = $state<{ username: string; email: string; instance_role: 'admin' | 'member' }>({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
instance_role: 'admin'
|
||||||
|
});
|
||||||
|
let invitePending = $state(false);
|
||||||
|
let inviteError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// One-time password reveal (used by both invite + reset)
|
||||||
|
let revealPassword = $state<string | null>(null);
|
||||||
|
let revealForUsername = $state<string>('');
|
||||||
|
let revealKind = $state<'invite' | 'reset'>('invite');
|
||||||
|
let revealAck = $state(false);
|
||||||
|
let copyState = $state<'idle' | 'copied'>('idle');
|
||||||
|
|
||||||
|
// Edit modal -------------------------------------------------------------
|
||||||
|
let editTarget = $state<AdminDto | null>(null);
|
||||||
|
let editForm = $state<{
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
instance_role: InstanceRole;
|
||||||
|
}>({ username: '', email: '', instance_role: 'admin' });
|
||||||
|
let editPending = $state(false);
|
||||||
|
let editError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Delete modal -----------------------------------------------------------
|
||||||
|
let deleteTarget = $state<AdminDto | null>(null);
|
||||||
|
let deletePending = $state(false);
|
||||||
|
|
||||||
|
// Validation rules (mirror backend: 2-32, [a-z0-9._-]) -------------------
|
||||||
|
const USERNAME_RE = /^[a-z0-9._-]{2,32}$/;
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
const inviteUsernameValid = $derived(USERNAME_RE.test(inviteForm.username));
|
||||||
|
const inviteEmailValid = $derived(
|
||||||
|
inviteForm.email.trim() === '' || EMAIL_RE.test(inviteForm.email.trim())
|
||||||
|
);
|
||||||
|
const canInvite = $derived(inviteUsernameValid && inviteEmailValid && !invitePending);
|
||||||
|
|
||||||
|
const editUsernameValid = $derived(USERNAME_RE.test(editForm.username));
|
||||||
|
const editEmailValid = $derived(
|
||||||
|
editForm.email.trim() === '' || EMAIL_RE.test(editForm.email.trim())
|
||||||
|
);
|
||||||
|
const canSubmitEdit = $derived(editUsernameValid && editEmailValid && !editPending);
|
||||||
|
|
||||||
|
// Admin (non-owner) cannot touch owner rows for delete or role demote.
|
||||||
|
function canDelete(row: AdminDto): boolean {
|
||||||
|
if (isOwner) return true;
|
||||||
|
return row.instance_role !== 'owner';
|
||||||
|
}
|
||||||
|
|
||||||
|
const editRoleOptions = $derived<InstanceRole[]>(
|
||||||
|
isOwner ? ['owner', 'admin', 'member'] : ['admin', 'member']
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(refresh);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loadError = null;
|
||||||
|
try {
|
||||||
|
admins = await api.admins.list();
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e instanceof ApiError ? e.message : 'failed to load users';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(kind: 'error' | 'info', message: string) {
|
||||||
|
banner = { kind, message };
|
||||||
|
setTimeout(() => {
|
||||||
|
if (banner?.message === message) banner = null;
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInvite() {
|
||||||
|
inviteForm = { username: '', email: '', instance_role: 'admin' };
|
||||||
|
inviteError = null;
|
||||||
|
inviteOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitInvite(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canInvite) return;
|
||||||
|
invitePending = true;
|
||||||
|
inviteError = null;
|
||||||
|
const password = generatePassword(16);
|
||||||
|
try {
|
||||||
|
const created = await api.admins.create({
|
||||||
|
username: inviteForm.username,
|
||||||
|
password,
|
||||||
|
instance_role: inviteForm.instance_role,
|
||||||
|
email: inviteForm.email.trim() === '' ? null : inviteForm.email.trim()
|
||||||
|
});
|
||||||
|
admins = [...admins, created].sort((a, b) => a.username.localeCompare(b.username));
|
||||||
|
inviteOpen = false;
|
||||||
|
revealPassword = password;
|
||||||
|
revealForUsername = created.username;
|
||||||
|
revealKind = 'invite';
|
||||||
|
revealAck = false;
|
||||||
|
copyState = 'idle';
|
||||||
|
} catch (e) {
|
||||||
|
inviteError = e instanceof ApiError ? e.message : 'failed to create user';
|
||||||
|
} finally {
|
||||||
|
invitePending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: AdminDto) {
|
||||||
|
editTarget = row;
|
||||||
|
editForm = {
|
||||||
|
username: row.username,
|
||||||
|
email: row.email ?? '',
|
||||||
|
instance_role: row.instance_role
|
||||||
|
};
|
||||||
|
editError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEdit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!editTarget || !canSubmitEdit) return;
|
||||||
|
editPending = true;
|
||||||
|
editError = null;
|
||||||
|
const patch: {
|
||||||
|
username?: string;
|
||||||
|
email?: string | null;
|
||||||
|
instance_role?: InstanceRole;
|
||||||
|
} = {};
|
||||||
|
if (editForm.username !== editTarget.username) patch.username = editForm.username;
|
||||||
|
if ((editTarget.email ?? '') !== editForm.email.trim()) {
|
||||||
|
patch.email = editForm.email.trim() === '' ? null : editForm.email.trim();
|
||||||
|
}
|
||||||
|
if (editForm.instance_role !== editTarget.instance_role) {
|
||||||
|
patch.instance_role = editForm.instance_role;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await api.admins.update(editTarget.id, patch);
|
||||||
|
admins = admins
|
||||||
|
.map((a) => (a.id === updated.id ? updated : a))
|
||||||
|
.sort((a, b) => a.username.localeCompare(b.username));
|
||||||
|
const name = updated.username;
|
||||||
|
editTarget = null;
|
||||||
|
flash('info', `Updated "${name}".`);
|
||||||
|
} catch (e) {
|
||||||
|
editError = e instanceof ApiError ? e.message : 'failed to update user';
|
||||||
|
} finally {
|
||||||
|
editPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPassword() {
|
||||||
|
if (!editTarget) return;
|
||||||
|
const target = editTarget;
|
||||||
|
const password = generatePassword(16);
|
||||||
|
editPending = true;
|
||||||
|
editError = null;
|
||||||
|
try {
|
||||||
|
await api.admins.update(target.id, { password });
|
||||||
|
editTarget = null;
|
||||||
|
revealPassword = password;
|
||||||
|
revealForUsername = target.username;
|
||||||
|
revealKind = 'reset';
|
||||||
|
revealAck = false;
|
||||||
|
copyState = 'idle';
|
||||||
|
} catch (e) {
|
||||||
|
editError = e instanceof ApiError ? e.message : 'failed to reset password';
|
||||||
|
} finally {
|
||||||
|
editPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActive(row: AdminDto) {
|
||||||
|
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 user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDelete(row: AdminDto) {
|
||||||
|
deleteTarget = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// Self-delete: bail out to login.
|
||||||
|
await api.auth.logout();
|
||||||
|
await goto(`${base}/login`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
admins = admins.filter((a) => a.id !== target.id);
|
||||||
|
flash('info', `Deleted "${target.username}".`);
|
||||||
|
} catch (e) {
|
||||||
|
flash('error', e instanceof ApiError ? e.message : 'failed to delete user');
|
||||||
|
} finally {
|
||||||
|
deletePending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPassword() {
|
||||||
|
if (!revealPassword) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(revealPassword);
|
||||||
|
copyState = 'copied';
|
||||||
|
setTimeout(() => (copyState = 'idle'), 2000);
|
||||||
|
} catch {
|
||||||
|
flash('error', 'Clipboard write failed — select and copy manually.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissReveal() {
|
||||||
|
revealPassword = null;
|
||||||
|
revealAck = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relative(iso: string | null): string {
|
||||||
|
if (!iso) return 'Never';
|
||||||
|
const sec = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||||
|
if (sec < 60) return `${sec}s ago`;
|
||||||
|
const min = Math.round(sec / 60);
|
||||||
|
if (min < 60) return `${min}m ago`;
|
||||||
|
const hr = Math.round(min / 60);
|
||||||
|
if (hr < 24) return `${hr}h ago`;
|
||||||
|
const day = Math.round(hr / 24);
|
||||||
|
if (day < 7) return `${day}d ago`;
|
||||||
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(iso: string): string {
|
||||||
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="head">
|
||||||
|
<h1>Users</h1>
|
||||||
|
<div class="head-controls">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search by username or email…"
|
||||||
|
bind:value={search}
|
||||||
|
class="search"
|
||||||
|
/>
|
||||||
|
<button type="button" class="primary" onclick={openInvite}>+ Invite user</button>
|
||||||
|
</div>
|
||||||
|
</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 users yet. Invite one to get started.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="table">
|
||||||
|
<div class="row head-row">
|
||||||
|
<div>Username</div>
|
||||||
|
<div>Role</div>
|
||||||
|
<div>Email</div>
|
||||||
|
<div>Status</div>
|
||||||
|
<div>Created</div>
|
||||||
|
<div>Last login</div>
|
||||||
|
<div class="actions-col"></div>
|
||||||
|
</div>
|
||||||
|
{#each filtered as row (row.id)}
|
||||||
|
<div class="row">
|
||||||
|
<div class="name-cell">
|
||||||
|
<span class="name">{row.username}</span>
|
||||||
|
{#if me && me.id === row.id}
|
||||||
|
<span class="you-tag">(you)</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div><RoleChip role={row.instance_role} size="sm" /></div>
|
||||||
|
<div class="email-cell">{row.email ?? '—'}</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={row.last_login_at ?? ''}>{relative(row.last_login_at)}</div>
|
||||||
|
<div class="actions-col">
|
||||||
|
<ActionMenu
|
||||||
|
label="User actions for {row.username}"
|
||||||
|
items={[
|
||||||
|
{ label: 'Edit', onClick: () => openEdit(row) },
|
||||||
|
{
|
||||||
|
label: row.is_active ? 'Deactivate' : 'Reactivate',
|
||||||
|
onClick: () => toggleActive(row)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
disabled: !canDelete(row),
|
||||||
|
onClick: () => openDelete(row)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if filtered.length === 0 && admins.length > 0}
|
||||||
|
<div class="row empty-row">No matches for "{search}".</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Invite modal -->
|
||||||
|
{#if inviteOpen}
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !invitePending) inviteOpen = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form class="modal" onsubmit={submitInvite}>
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>Invite user</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="x"
|
||||||
|
aria-label="Close"
|
||||||
|
disabled={invitePending}
|
||||||
|
onclick={() => (inviteOpen = false)}>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p class="modal-intro">
|
||||||
|
A random password will be generated and shown to you exactly once. PiCloud cannot send
|
||||||
|
email — copy and share through your own channel.
|
||||||
|
</p>
|
||||||
|
<label class="field">
|
||||||
|
<span>Username</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={inviteForm.username}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small>2–32 chars. Lowercase letters, digits, <code>.</code> <code>_</code> <code>-</code>.</small>
|
||||||
|
{#if inviteForm.username && !inviteUsernameValid}
|
||||||
|
<small class="invalid">Doesn't match the allowed pattern.</small>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Email <span class="opt">(optional)</span></span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={inviteForm.email}
|
||||||
|
/>
|
||||||
|
{#if !inviteEmailValid}
|
||||||
|
<small class="invalid">Doesn't look like an email address.</small>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<fieldset class="field">
|
||||||
|
<legend>Role</legend>
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio" bind:group={inviteForm.instance_role} value="admin" />
|
||||||
|
<span>Admin — can manage users, scripts, and all apps.</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio" bind:group={inviteForm.instance_role} value="member" />
|
||||||
|
<span>Member — only sees apps they're added to.</span>
|
||||||
|
</label>
|
||||||
|
<small>
|
||||||
|
Owners can't be created here — promote via Edit after creation.
|
||||||
|
</small>
|
||||||
|
</fieldset>
|
||||||
|
{#if inviteError}
|
||||||
|
<div class="error">{inviteError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="ghost" onclick={() => (inviteOpen = false)} disabled={invitePending}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="primary" disabled={!canInvite}>
|
||||||
|
{invitePending ? 'Creating…' : 'Create user'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Edit modal -->
|
||||||
|
{#if editTarget}
|
||||||
|
{@const target = editTarget}
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !editPending) editTarget = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form class="modal" onsubmit={submitEdit}>
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>Edit {target.username}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="x"
|
||||||
|
aria-label="Close"
|
||||||
|
disabled={editPending}
|
||||||
|
onclick={() => (editTarget = null)}>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label class="field">
|
||||||
|
<span>Username</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={editForm.username}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if editForm.username && !editUsernameValid}
|
||||||
|
<small class="invalid">2–32 chars, lowercase + digits + . _ - only.</small>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Email <span class="opt">(optional)</span></span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={editForm.email}
|
||||||
|
/>
|
||||||
|
{#if !editEmailValid}
|
||||||
|
<small class="invalid">Doesn't look like an email address.</small>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Role</span>
|
||||||
|
<select bind:value={editForm.instance_role}>
|
||||||
|
{#each editRoleOptions as r (r)}
|
||||||
|
<option value={r}>{r}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<small>
|
||||||
|
{#if target.instance_role === 'owner' && !isOwner}
|
||||||
|
Only owners can change another owner's role.
|
||||||
|
{:else if !isOwner}
|
||||||
|
Admins can grant admin or member; only owners can grant owner.
|
||||||
|
{:else}
|
||||||
|
The last active owner can't be demoted — the request will 422 if that's the case.
|
||||||
|
{/if}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
{#if editError}
|
||||||
|
<div class="error">{editError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-actions split">
|
||||||
|
<button type="button" class="ghost" onclick={resetPassword} disabled={editPending}>
|
||||||
|
Reset password
|
||||||
|
</button>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ghost"
|
||||||
|
onclick={() => (editTarget = null)}
|
||||||
|
disabled={editPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="primary" disabled={!canSubmitEdit}>
|
||||||
|
{editPending ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Password reveal (post-invite or post-reset) -->
|
||||||
|
{#if revealPassword}
|
||||||
|
<div class="modal-backdrop" role="presentation">
|
||||||
|
<div class="modal reveal-modal">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>
|
||||||
|
{revealKind === 'invite' ? 'User created' : 'Password reset'} — {revealForUsername}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p class="banner banner-warn">
|
||||||
|
Save this password now — it will never be shown again. PiCloud cannot send email yet,
|
||||||
|
so copy it and share through your own channel.
|
||||||
|
</p>
|
||||||
|
<div class="token-row">
|
||||||
|
<code class="token">{revealPassword}</code>
|
||||||
|
<button type="button" class="ghost" onclick={copyPassword}>
|
||||||
|
{copyState === 'copied' ? 'Copied ✓' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="ack">
|
||||||
|
<input type="checkbox" bind:checked={revealAck} />
|
||||||
|
<span>I've shared this with the user.</span>
|
||||||
|
</label>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="primary" disabled={!revealAck} onclick={dismissReveal}>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete confirmation -->
|
||||||
|
{#if deleteTarget}
|
||||||
|
{@const dt = deleteTarget}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete user?"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Delete user"
|
||||||
|
confirmPhrase={dt.username}
|
||||||
|
confirmPhrasePrompt="Type the username to confirm:"
|
||||||
|
busy={deletePending}
|
||||||
|
busyLabel="Deleting…"
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => (deleteTarget = null)}
|
||||||
|
>
|
||||||
|
{#if me && me.id === dt.id}
|
||||||
|
<p>
|
||||||
|
You're about to delete <strong>your own</strong> account. You'll be signed out
|
||||||
|
immediately and won't be able to sign back in.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p>
|
||||||
|
This permanently removes <strong>{dt.username}</strong>, all their sessions, and all
|
||||||
|
their API keys. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<p class="muted">
|
||||||
|
If they're the only remaining owner or active admin the server will reject the request
|
||||||
|
with a 422 — promote/activate someone else first.
|
||||||
|
</p>
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.head h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.head-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-width: 16rem;
|
||||||
|
}
|
||||||
|
.search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.banner-warn {
|
||||||
|
background: #2a1d04;
|
||||||
|
border: 1px solid #ca8a04;
|
||||||
|
color: #fde68a;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
border: 1px dashed #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #0b1220;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.3fr 0.7fr 1.5fr 0.9fr 0.8fr 0.9fr 2.5rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.head-row {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
.empty-row {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.name-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.you-tag {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.email-cell {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.status-active {
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
.status-inactive {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.actions-col {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
button.primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
button.ghost:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
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.2rem 0.55rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(2, 6, 23, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 28rem;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
.reveal-modal {
|
||||||
|
border-color: #ca8a04;
|
||||||
|
}
|
||||||
|
.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-intro {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.field legend {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.field input[type='text'],
|
||||||
|
.field input[type='email'],
|
||||||
|
.field select {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
.field small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.field small.invalid {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
.field small code {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #cbd5e1;
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
}
|
||||||
|
.opt {
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.token-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.token {
|
||||||
|
flex: 1;
|
||||||
|
background: #020617;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ack {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.modal-actions.split {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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'
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -40,6 +40,12 @@ services:
|
|||||||
DATABASE_URL: postgres://${POSTGRES_USER:-picloud}:${POSTGRES_PASSWORD:-picloud}@postgres:5432/${POSTGRES_DB:-picloud}
|
DATABASE_URL: postgres://${POSTGRES_USER:-picloud}:${POSTGRES_PASSWORD:-picloud}@postgres:5432/${POSTGRES_DB:-picloud}
|
||||||
RUST_LOG: ${RUST_LOG:-info}
|
RUST_LOG: ${RUST_LOG:-info}
|
||||||
PICLOUD_PUBLIC_BASE_URL: ${PICLOUD_PUBLIC_BASE_URL:-http://localhost:8000}
|
PICLOUD_PUBLIC_BASE_URL: ${PICLOUD_PUBLIC_BASE_URL:-http://localhost:8000}
|
||||||
|
# Bootstrap admin (Phase 3a). Read once on first start to seed the
|
||||||
|
# admin_users table; ignored on subsequent boots if the table is
|
||||||
|
# non-empty. No defaults on purpose — leaving these unset in prod
|
||||||
|
# is a foot-gun. For dev, .env.example documents sensible values.
|
||||||
|
PICLOUD_ADMIN_USERNAME: ${PICLOUD_ADMIN_USERNAME:?set PICLOUD_ADMIN_USERNAME (see .env.example)}
|
||||||
|
PICLOUD_ADMIN_PASSWORD: ${PICLOUD_ADMIN_PASSWORD:?set PICLOUD_ADMIN_PASSWORD (see .env.example)}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -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.6.0` |
|
||||||
| 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/*` and `/api/v1/admin/api-keys/*` endpoints, `?app=` filter on script list, `Authorization: Bearer pic_…` credential type, 403 responses on previously-401-only admin endpoints when the caller lacks the required capability) |
|
||||||
| Schema | `3` (matches `migrations/0003_routes.sql`) |
|
| Schema | `6` (matches `migrations/0006_users_authz.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'
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Project Blueprint: Lightweight Event-Based Serverless Cloud
|
# Project Blueprint: Lightweight Event-Based Serverless Cloud
|
||||||
|
|
||||||
**Status**: Phase 4 — Blueprint Complete
|
**Status**: Phase 4 — Blueprint Complete
|
||||||
**Last Updated**: 2026-04-10
|
**Last Updated**: 2026-05-27
|
||||||
**Audience**: Solo developer (DIY self-hosted)
|
**Audience**: Solo developer (DIY self-hosted)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -732,68 +732,540 @@ 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11.6 Users, roles, and bearer-token auth (Phase 3.5) — Pending
|
||||||
|
|
||||||
|
**Status**: pending. Targets `crates/manager-core/src/{authz,api_keys_api,api_key_repo}.rs`, an extended `auth_middleware.rs`, new shared types under `crates/shared/src/auth.rs`, migration `0006_users_authz.sql`.
|
||||||
|
|
||||||
|
**Purpose**: bridge Phase 3b → Phase 4. Phase 4's v1.1 SDKs (KV, docs, HTTP, cron) each gate access on the calling principal. Without a real authorization model in place, every SDK addition has to either invent its own gate or stay open. Phase 3.5 lands `can(principal, capability)` as the single check every future SDK + admin endpoint goes through, so v1.1 work focuses on data plane shape, not on re-litigating auth.
|
||||||
|
|
||||||
|
**Why this slot**: same logic as Phase 3b. Adding a `Principal` parameter and a capability check to surfaces that don't exist yet is free; retrofitting them onto live SDK services after v1.1 ships is a refactor of every gate.
|
||||||
|
|
||||||
|
### Principal Model
|
||||||
|
|
||||||
|
One `Principal` value represents a human admin user. Service accounts (CI bots, Rhai scripts calling out) get **schema room** in this phase but no runtime support — `users.kind` style differentiation lands when Phase 4's `users.*` SDK arrives. Until then, every authenticated request resolves to exactly one admin row, whether the credential is a session cookie or a bearer API key.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Principal {
|
||||||
|
pub user_id: UserId, // alias of AdminUserId for the transition
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub scopes: Option<Vec<Scope>>, // None = cookie session (full role authority)
|
||||||
|
// Some = API key (intersect with role)
|
||||||
|
pub app_binding: Option<AppId>, // API key bound to one app; denies other apps
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Instance Roles (one per user)
|
||||||
|
|
||||||
|
| Role | Powers |
|
||||||
|
|---|---|
|
||||||
|
| `owner` | full instance control, manage other owners, implicit `app_admin` on every app. Multiple owners allowed. |
|
||||||
|
| `admin` | create apps, invite users, implicit `editor` on every app. Cannot manage instance-wide settings or other owners. |
|
||||||
|
| `member` | invited into specific apps only. Cannot create apps, cannot invite. **Strict isolation enforced at SQL** — list endpoints `WHERE app_id IN (SELECT app_id FROM app_members WHERE user_id = $1)`; the API never returns apps a member isn't part of. |
|
||||||
|
|
||||||
|
The current Phase 3a `admin_users` rows all become `owner` via `DEFAULT 'owner'` on the new column. Multi-owner installs get a startup `tracing::warn!` listing the active owner usernames so the operator can demote extras via `PATCH /api/v1/admin/admins/{id}`.
|
||||||
|
|
||||||
|
### App-Scoped Roles (zero-to-many per user × app)
|
||||||
|
|
||||||
|
| Role | Grants |
|
||||||
|
|---|---|
|
||||||
|
| `app_admin` | settings, domain claims, delete app |
|
||||||
|
| `editor` | CRUD on scripts, routes, sandbox config |
|
||||||
|
| `viewer` | read scripts + execution logs |
|
||||||
|
|
||||||
|
Implicit grants from instance role: every `owner` is `app_admin` on every app; every `admin` is `editor` on every app. Explicit `app_members` rows are the only path for `member` users.
|
||||||
|
|
||||||
|
### Auth Methods — Same Principal, Different Extractor
|
||||||
|
|
||||||
|
Two credential types feed the same middleware:
|
||||||
|
|
||||||
|
1. **Session cookie** (Phase 3a, unchanged) — `picloud_session=<token>`. Extracted by header or cookie. SHA-256 lookup against `admin_sessions.token_hash`. Sliding 24h TTL. Produces `Principal { scopes: None, app_binding: None }`.
|
||||||
|
|
||||||
|
2. **Bearer API key** (new) — `Authorization: Bearer pic_<base32(32 random bytes)>`. The `pic_` prefix is the discriminator: present → API key path; absent → session path. The 8 chars immediately after `pic_` are indexed (`api_keys.prefix`); the full body after `pic_` is Argon2id-verified against each candidate's `hash`. Last-used timestamp updates inline.
|
||||||
|
|
||||||
|
Both paths converge on the same `Principal` extension; handlers cannot tell which credential was presented unless they introspect `principal.scopes`.
|
||||||
|
|
||||||
|
### API Key Format & Storage
|
||||||
|
|
||||||
|
- Raw form: `pic_<base32(32 random bytes, no padding)>` — ~56 chars total.
|
||||||
|
- Stored: 8-char prefix + Argon2id PHC hash of the body. Raw value returned **exactly once** in the `POST /api/v1/admin/api-keys` response; never logged, never readable again.
|
||||||
|
- Optional `expires_at`. Lookup queries always filter `expires_at IS NULL OR expires_at > NOW()`.
|
||||||
|
- Optional `app_id` ("bound key") — every `App*(other_app)` capability is denied for this key, regardless of the user's role.
|
||||||
|
|
||||||
|
### Scope Set (intentionally narrow)
|
||||||
|
|
||||||
|
Exactly seven scopes; no further subdivision until a real use case appears:
|
||||||
|
|
||||||
|
`script:read`, `script:write`, `route:write`, `domain:manage`, `log:read`, `app:admin`, `instance:admin`
|
||||||
|
|
||||||
|
Mint-time validation rejects unknown values. Bound keys (`app_id` set) cannot carry `instance:*` scopes — the combination is irreconcilable (a bound credential cannot claim instance-wide authority) and is rejected with 422.
|
||||||
|
|
||||||
|
### Effective Capability — `can(principal, capability)`
|
||||||
|
|
||||||
|
```
|
||||||
|
allow = role_grants(principal.instance_role, capability)
|
||||||
|
∧ (principal.scopes.is_none() ∨ required_scope(capability) ∈ principal.scopes)
|
||||||
|
∧ (principal.app_binding.is_none() ∨ capability.app_id() == principal.app_binding)
|
||||||
|
```
|
||||||
|
|
||||||
|
`role_grants` collapses the three tables (instance role + implicit app grants + explicit `app_members`) into a single yes/no. Each handler calls `state.authz.require(&principal, Capability::AppWrite(script.app_id))` after loading the resource (so the capability binds to the resource's actual `app_id`, not a path param the caller controls).
|
||||||
|
|
||||||
|
### Deactivation Symmetry
|
||||||
|
|
||||||
|
Phase 3a's `set_active(false)` wipes that user's `admin_sessions`. Phase 3.5 extends it to also set `expires_at = NOW()` on every row in `api_keys WHERE user_id = $1` — both credential surfaces become inert at the same moment, no enumeration window.
|
||||||
|
|
||||||
|
### CLI Auth Posture (forward note)
|
||||||
|
|
||||||
|
The eventual `picloud` CLI authenticates by **paste-the-token**, not OAuth: the user runs `picloud login`, the dashboard mints a fresh key (or the user mints one via `POST /api/v1/admin/api-keys`), and the CLI prompts for the raw token. The CLI binary itself is deferred; the dashboard surface and the bearer credential type land here so the CLI is a thin wrapper when it arrives.
|
||||||
|
|
||||||
|
### Schema (Migration 0006)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE admin_users
|
||||||
|
ADD COLUMN instance_role TEXT NOT NULL DEFAULT 'owner'
|
||||||
|
CHECK (instance_role IN ('owner','admin','member')),
|
||||||
|
ADD COLUMN email TEXT UNIQUE,
|
||||||
|
ADD COLUMN mfa_secret TEXT; -- reserved slot, not built
|
||||||
|
|
||||||
|
CREATE TABLE app_members (
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('app_admin','editor','viewer')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (app_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX app_members_user_id_idx ON app_members (user_id);
|
||||||
|
|
||||||
|
CREATE TABLE api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
hash TEXT NOT NULL, -- Argon2id PHC
|
||||||
|
prefix TEXT NOT NULL, -- first 8 chars after `pic_`
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
scopes TEXT[] NOT NULL, -- intersected with role at check time
|
||||||
|
app_id UUID NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
expires_at TIMESTAMPTZ NULL,
|
||||||
|
last_used_at TIMESTAMPTZ NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX api_keys_prefix_idx ON api_keys (prefix);
|
||||||
|
CREATE INDEX api_keys_user_id_idx ON api_keys (user_id);
|
||||||
|
|
||||||
|
-- Reserved (not built this phase):
|
||||||
|
-- invites (token, email, instance_role, app_id, app_role, invited_by, expires_at, consumed_at)
|
||||||
|
-- service_accounts (id, name, owning_user_id, …)
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Endpoints (additive — no API major bump)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/admin/api-keys — { name, scopes[], app_id?, expires_at? }
|
||||||
|
→ 201 { …, raw_token } (raw returned exactly once)
|
||||||
|
GET /api/v1/admin/api-keys — list caller's own keys (no raw)
|
||||||
|
DELETE /api/v1/admin/api-keys/{id} — caller's own only
|
||||||
|
```
|
||||||
|
|
||||||
|
Every existing `/api/v1/admin/*` endpoint is re-gated from "any authed admin" to a specific `Capability`. Request/response shapes are unchanged; what changes is the set of callers each endpoint accepts (a `member` now gets 403 on app surfaces they're not part of, where before they would have been 401-or-200 depending only on session validity).
|
||||||
|
|
||||||
|
### App Member Management Endpoints
|
||||||
|
|
||||||
|
Exposes the `app_members` table as a first-class CRUD surface so app admins can manage who they share an app with from the dashboard, not just from SQL.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/admin/apps/{id_or_slug}/members — list members (ordered by username),
|
||||||
|
joined with admin_users for
|
||||||
|
username / email / instance_role / is_active
|
||||||
|
POST /api/v1/admin/apps/{id_or_slug}/members — { user_id, role } → 201 enriched DTO
|
||||||
|
409 on duplicate (promotions go through PATCH)
|
||||||
|
422 if target user is_active = false
|
||||||
|
422 if target user instance_role != 'member'
|
||||||
|
(owners/admins have implicit authority;
|
||||||
|
an explicit row would be dead weight)
|
||||||
|
PATCH /api/v1/admin/apps/{id_or_slug}/members/{user_id} — { role } → 200 enriched DTO
|
||||||
|
404 if no existing membership
|
||||||
|
DELETE /api/v1/admin/apps/{id_or_slug}/members/{user_id} — 204 (idempotent — 204 also when missing)
|
||||||
|
```
|
||||||
|
|
||||||
|
All four are gated on `Capability::AppAdmin(app_id)`. Editors and viewers get 403 on list and never see the dashboard's Members tab.
|
||||||
|
|
||||||
|
**`my_role` on the app lookup endpoint.** `GET /api/v1/admin/apps/{id_or_slug}` now returns an additional `my_role: Option<AppRole>`, computed server-side from the principal: `Owner → app_admin`, `Admin → editor`, `Member → app_members.role`. The dashboard uses this single field to decide whether to render the Members tab (visible iff `my_role == app_admin`), keeping API and UI gate logic identical.
|
||||||
|
|
||||||
|
**No last-app-admin guard.** Unlike the last-owner protection on `admin_users`, removing the final `app_admin` row from `app_members` is allowed. Every `owner` instance-role user implicitly satisfies `Capability::AppAdmin(_)` via the top-level `role_grants` branch, so no app can become permanently orphaned — an owner can always re-issue grants. The `admin` instance role is only implicit *editor*, so it does **not** provide a fallback path; the owner guarantee alone is what makes the no-guard position safe.
|
||||||
|
|
||||||
|
**Dead-row sweep on promotion (deferred).** Promoting a user from `member` → `admin`/`owner` leaves their `app_members` rows in place. They become inert (implicit grants supersede), but are not auto-deleted. A future hook can sweep them; harmless for now.
|
||||||
|
|
||||||
|
Additive within `/api/v1/admin/...` — no API major bump per [docs/versioning.md](docs/versioning.md).
|
||||||
|
|
||||||
|
### Out of Scope (Phase 3.5)
|
||||||
|
|
||||||
|
Schema room only, not built:
|
||||||
|
|
||||||
|
- **Invites** — email-based join flow; `invites` table reserved in the migration comment block.
|
||||||
|
- **MFA / TOTP** — `mfa_secret` column reserved on `admin_users`.
|
||||||
|
- **Service accounts** — reserved as a future table; for now, every API key belongs to a human `admin_users` row.
|
||||||
|
|
||||||
|
Defer to follow-up sessions: dashboard surfaces for invites / key minting (curl is the supported interface this phase — member management has a dashboard tab; see above), OIDC / SAML / SCIM, the `picloud` CLI binary itself, email/SMTP delivery of invites, audit log shipping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 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
|
Three 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.
|
||||||
|
|
||||||
|
**3c. Users, roles, and bearer-token auth** — pending. See section 11.6. Adds `instance_role` to `admin_users` (`owner`/`admin`/`member`), `app_members` for per-app `app_admin`/`editor`/`viewer` grants, and `api_keys` for `Authorization: Bearer pic_…` credentials. Unifies cookie-session and API-key paths behind a single `can(principal, capability)` gate; list endpoints filter by membership at SQL for `member` users. Dashboard surfaces, invites, MFA, service accounts, and the `picloud` CLI binary are deferred — schema room only.
|
||||||
|
|
||||||
|
**Why all three before v1.1**: every v1.1 service (KV, docs, users, etc.) needs both an `app_id` scoping key in its schema and a `Principal` to authorize against. Adding both now is one migration each on a small surface; adding them after the SDKs ship is many migrations on populated data plus a re-gate of every SDK call.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 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