Compare commits
57 Commits
878cbe9439
...
test/front
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec3c768262 | ||
|
|
3e72ddde78 | ||
|
|
cd20ffb580 | ||
|
|
cddd479fd2 | ||
|
|
8bbcdd86aa | ||
|
|
2d56e42699 | ||
|
|
f9d9ed8cb4 | ||
|
|
c17f8a5bd9 | ||
|
|
7198fb4d0e | ||
|
|
029a4a199f | ||
|
|
74f7b3b631 | ||
|
|
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.
|
||||
# Set to the host:port (and scheme) users actually reach in their browser.
|
||||
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
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -30,6 +30,17 @@ config.local.toml
|
||||
/dashboard/build
|
||||
/dashboard/.env
|
||||
|
||||
# Dashboard — Playwright E2E
|
||||
/dashboard/tests/e2e/.auth
|
||||
/dashboard/tests/e2e/.results
|
||||
/dashboard/playwright-report
|
||||
/dashboard/test-results
|
||||
/dashboard/.playwright
|
||||
# When playwright is invoked from the repo root by accident, these
|
||||
# also land here.
|
||||
/playwright-report
|
||||
/test-results
|
||||
|
||||
# Caddy
|
||||
/caddy/data
|
||||
/caddy/config
|
||||
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
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.
|
||||
|
||||
- `/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)
|
||||
- `/admin/*` — dashboard SPA (SvelteKit, `paths.base = '/admin'`)
|
||||
- `/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.
|
||||
|
||||
**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
|
||||
|
||||
- **Rust 1.92+** workspace, pinned via `rust-toolchain.toml`
|
||||
@@ -102,4 +108,6 @@ docs/
|
||||
|
||||
## 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"
|
||||
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]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
@@ -206,6 +218,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -387,6 +408,12 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -1233,6 +1260,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "pear"
|
||||
version = "0.2.9"
|
||||
@@ -1273,12 +1311,13 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "picloud"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"axum-test",
|
||||
"chrono",
|
||||
"figment",
|
||||
"picloud-executor-core",
|
||||
"picloud-manager-core",
|
||||
@@ -1293,11 +1332,12 @@ dependencies = [
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "picloud-executor"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"picloud-executor-core",
|
||||
@@ -1309,7 +1349,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-executor-core"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"picloud-shared",
|
||||
@@ -1323,7 +1363,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-manager"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"picloud-manager-core",
|
||||
@@ -1335,17 +1375,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-manager-core"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64",
|
||||
"chrono",
|
||||
"data-encoding",
|
||||
"picloud-orchestrator-core",
|
||||
"picloud-shared",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
@@ -1353,7 +1399,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-orchestrator"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"picloud-orchestrator-core",
|
||||
@@ -1365,7 +1411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-orchestrator-core"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -1384,7 +1430,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-shared"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
|
||||
@@ -12,7 +12,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.92"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -66,6 +66,13 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
||||
url = "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]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
|
||||
@@ -22,3 +22,12 @@ uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
sqlx.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 axum::{
|
||||
extract::{Path, State},
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_shared::{
|
||||
ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
|
||||
AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptSandbox, ScriptValidator,
|
||||
ValidationError,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||
use crate::repo::{
|
||||
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
||||
};
|
||||
@@ -27,6 +30,13 @@ use crate::sandbox::{CeilingError, SandboxCeiling};
|
||||
pub struct AdminState<R, L> {
|
||||
pub repo: Arc<R>,
|
||||
pub logs: Arc<L>,
|
||||
/// App lookups: validates `app_id` on create, resolves `?app=<slug>`
|
||||
/// filter on list. Trait-object so apps_repo can stay separate.
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
/// 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 sandbox_ceiling: SandboxCeiling,
|
||||
}
|
||||
@@ -36,6 +46,8 @@ impl<R, L> Clone for AdminState<R, L> {
|
||||
Self {
|
||||
repo: self.repo.clone(),
|
||||
logs: self.logs.clone(),
|
||||
apps: self.apps.clone(),
|
||||
authz: self.authz.clone(),
|
||||
validator: self.validator.clone(),
|
||||
sandbox_ceiling: self.sandbox_ceiling,
|
||||
}
|
||||
@@ -70,6 +82,9 @@ where
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateScriptRequest {
|
||||
/// Owning app. Required since Phase 3b — scripts cannot exist
|
||||
/// outside an app. Use `/api/v1/admin/apps` to list known ids.
|
||||
pub app_id: AppId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub source: String,
|
||||
@@ -82,6 +97,14 @@ pub struct CreateScriptRequest {
|
||||
pub sandbox: ScriptSandbox,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListScriptsQuery {
|
||||
/// Optional filter: list scripts belonging to a single app, by id
|
||||
/// or slug. Absent = all scripts across all apps (admin-global view).
|
||||
#[serde(default)]
|
||||
pub app: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateScriptRequest {
|
||||
pub name: Option<String>,
|
||||
@@ -113,31 +136,83 @@ where
|
||||
|
||||
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Query(q): Query<ListScriptsQuery>,
|
||||
) -> 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?))
|
||||
}
|
||||
|
||||
/// 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>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<ScriptId>,
|
||||
) -> Result<Json<Script>, ApiError> {
|
||||
state
|
||||
.repo
|
||||
.get(id)
|
||||
.await?
|
||||
.map(Json)
|
||||
.ok_or(ApiError::NotFound(id))
|
||||
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppRead(script.app_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(script))
|
||||
}
|
||||
|
||||
async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<CreateScriptRequest>,
|
||||
) -> 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.sandbox_ceiling.check(&input.sandbox)?;
|
||||
// Refuse early if the app_id doesn't exist — a clean 422 beats a
|
||||
// raw FK violation surfacing as 500.
|
||||
if state.apps.get_by_id(input.app_id).await?.is_none() {
|
||||
return Err(ApiError::AppNotFound(input.app_id.to_string()));
|
||||
}
|
||||
let created = state
|
||||
.repo
|
||||
.create(NewScript {
|
||||
app_id: input.app_id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
source: input.source,
|
||||
@@ -155,9 +230,17 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
|
||||
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<ScriptId>,
|
||||
Json(input): Json<UpdateScriptRequest>,
|
||||
) -> 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() {
|
||||
state.validator.validate(src)?;
|
||||
}
|
||||
@@ -183,8 +266,16 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
|
||||
async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<ScriptId>,
|
||||
) -> 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?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -203,9 +294,17 @@ const fn default_limit() -> i64 {
|
||||
|
||||
async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<ScriptId>,
|
||||
axum::extract::Query(q): axum::extract::Query<LogsQuery>,
|
||||
) -> 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
|
||||
// unbounded over time so a paged read is the only sane default.
|
||||
let limit = q.limit.clamp(1, 200);
|
||||
@@ -223,6 +322,9 @@ pub enum ApiError {
|
||||
#[error("script not found: {0}")]
|
||||
NotFound(ScriptId),
|
||||
|
||||
#[error("app not found: {0}")]
|
||||
AppNotFound(String),
|
||||
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
@@ -232,18 +334,42 @@ pub enum ApiError {
|
||||
#[error("{0}")]
|
||||
Ceiling(#[from] CeilingError),
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
|
||||
#[error("repository error: {0}")]
|
||||
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 {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
Self::AppNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
||||
Self::Invalid(_) | Self::Ceiling(_) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||
}
|
||||
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(_)) => {
|
||||
(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
|
||||
//! 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_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 migrations;
|
||||
pub mod repo;
|
||||
@@ -13,7 +29,40 @@ pub mod route_repo;
|
||||
pub mod sandbox;
|
||||
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_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 repo::{
|
||||
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
|
||||
|
||||
@@ -28,15 +28,16 @@ impl ExecutionLogSink for PostgresExecutionLogSink {
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO execution_logs ( \
|
||||
id, script_id, request_id, \
|
||||
id, app_id, script_id, request_id, \
|
||||
request_path, request_headers, request_body, \
|
||||
response_code, response_body, \
|
||||
logs, duration_ms, status, created_at \
|
||||
) VALUES ( \
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 \
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 \
|
||||
)",
|
||||
)
|
||||
.bind(log.id)
|
||||
.bind(log.app_id.into_inner())
|
||||
.bind(log.script_id.into_inner())
|
||||
.bind(log.request_id.into_inner())
|
||||
.bind(&log.request_path)
|
||||
|
||||
@@ -2,7 +2,9 @@ use std::collections::BTreeMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
||||
use picloud_shared::{ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox};
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -21,7 +23,18 @@ pub enum ScriptRepositoryError {
|
||||
#[async_trait]
|
||||
pub trait ScriptRepository: Send + Sync {
|
||||
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError>;
|
||||
/// Every script across all apps. Mostly for tests and admin
|
||||
/// "global" views; the dashboard reaches scripts via `list_for_app`.
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||
/// 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 update(
|
||||
&self,
|
||||
@@ -35,6 +48,7 @@ pub trait ScriptRepository: Send + Sync {
|
||||
/// constraints; the repo enforces them in the DB regardless.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewScript {
|
||||
pub app_id: AppId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub source: String,
|
||||
@@ -78,7 +92,7 @@ impl PostgresScriptRepository {
|
||||
impl ScriptRepository for PostgresScriptRepository {
|
||||
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, ScriptRow>(
|
||||
"SELECT id, name, description, version, source, \
|
||||
"SELECT id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||
FROM scripts WHERE id = $1",
|
||||
)
|
||||
@@ -90,7 +104,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||
"SELECT id, name, description, version, source, \
|
||||
"SELECT id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||
FROM scripts ORDER BY name",
|
||||
)
|
||||
@@ -99,17 +113,48 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||
"SELECT id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||
FROM scripts WHERE app_id = $1 ORDER BY name",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn 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> {
|
||||
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let res = sqlx::query_as::<_, ScriptRow>(
|
||||
"INSERT INTO scripts ( \
|
||||
name, description, source, \
|
||||
app_id, name, description, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox \
|
||||
) VALUES ($1, $2, $3, COALESCE($4, 30), COALESCE($5, 256), $6) \
|
||||
RETURNING id, name, description, version, source, \
|
||||
) VALUES ($1, $2, $3, $4, COALESCE($5, 30), COALESCE($6, 256), $7) \
|
||||
RETURNING id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||
)
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(&input.name)
|
||||
.bind(input.description.as_deref())
|
||||
.bind(&input.source)
|
||||
@@ -123,7 +168,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
Err(ScriptRepositoryError::Conflict(format!(
|
||||
"a script named {:?} already exists",
|
||||
"a script named {:?} already exists in this app",
|
||||
input.name
|
||||
)))
|
||||
}
|
||||
@@ -141,12 +186,13 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
|
||||
// Sandbox is replaced wholesale when present; per-field merging
|
||||
// happens in the API layer (clearer semantics for a "PUT a new
|
||||
// sandbox config" call).
|
||||
// sandbox config" call). app_id is immutable — moving a script
|
||||
// to another app is a copy-and-delete, not an in-place edit.
|
||||
let sandbox_json = patch
|
||||
.sandbox
|
||||
.as_ref()
|
||||
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
||||
let row = sqlx::query_as::<_, ScriptRow>(
|
||||
let res = sqlx::query_as::<_, ScriptRow>(
|
||||
"UPDATE scripts SET \
|
||||
name = COALESCE($2, name), \
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
@@ -157,7 +203,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
version = version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, name, description, version, source, \
|
||||
RETURNING id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
@@ -169,10 +215,18 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
.bind(patch.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
.await;
|
||||
|
||||
row.map(Into::into)
|
||||
.ok_or(ScriptRepositoryError::NotFound(id))
|
||||
match res {
|
||||
Ok(Some(row)) => Ok(row.into()),
|
||||
Ok(None) => Err(ScriptRepositoryError::NotFound(id)),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
Err(ScriptRepositoryError::Conflict(
|
||||
"a script with that name already exists in this app".into(),
|
||||
))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError> {
|
||||
@@ -191,6 +245,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ScriptRow {
|
||||
id: uuid::Uuid,
|
||||
app_id: uuid::Uuid,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
version: i32,
|
||||
@@ -211,6 +266,7 @@ impl From<ScriptRow> for Script {
|
||||
let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default();
|
||||
Self {
|
||||
id: r.id.into(),
|
||||
app_id: r.app_id.into(),
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
version: r.version,
|
||||
@@ -284,7 +340,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
|
||||
offset: i64,
|
||||
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ExecutionLogRow>(
|
||||
"SELECT id, script_id, request_id, \
|
||||
"SELECT id, app_id, script_id, request_id, \
|
||||
request_path, request_headers, request_body, \
|
||||
response_code, response_body, \
|
||||
logs, duration_ms, status, created_at \
|
||||
@@ -306,6 +362,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ExecutionLogRow {
|
||||
id: uuid::Uuid,
|
||||
app_id: uuid::Uuid,
|
||||
script_id: uuid::Uuid,
|
||||
request_id: uuid::Uuid,
|
||||
request_path: Option<String>,
|
||||
@@ -331,6 +388,7 @@ impl From<ExecutionLogRow> for ExecutionLog {
|
||||
};
|
||||
Self {
|
||||
id: r.id,
|
||||
app_id: r.app_id.into(),
|
||||
script_id: r.script_id.into(),
|
||||
request_id: RequestId::from(r.request_id),
|
||||
request_path: r.request_path.unwrap_or_default(),
|
||||
|
||||
@@ -10,42 +10,56 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
|
||||
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
|
||||
use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
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};
|
||||
|
||||
pub struct RouteAdminState<RR> {
|
||||
pub struct RouteAdminState<RR, SR> {
|
||||
pub routes: Arc<RR>,
|
||||
/// Used to resolve `script_id → app_id` when creating routes (the
|
||||
/// route inherits the script's app) and to scope conflict checks.
|
||||
pub scripts: Arc<SR>,
|
||||
/// Used to validate the route's host against the parent app's
|
||||
/// declared domain claims.
|
||||
pub domains: Arc<dyn AppDomainRepository>,
|
||||
pub table: Arc<RouteTable>,
|
||||
/// 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 {
|
||||
Self {
|
||||
routes: self.routes.clone(),
|
||||
scripts: self.scripts.clone(),
|
||||
domains: self.domains.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
|
||||
RR: RouteRepository + 'static,
|
||||
SR: ScriptRepository + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route(
|
||||
"/scripts/{id}/routes",
|
||||
get(list_routes::<RR>).post(create_route::<RR>),
|
||||
get(list_routes::<RR, SR>).post(create_route::<RR, SR>),
|
||||
)
|
||||
.route("/routes/{route_id}", delete(delete_route::<RR>))
|
||||
.route("/routes:check", post(check_route::<RR>))
|
||||
.route("/routes:match", post(match_route::<RR>))
|
||||
.route("/routes/{route_id}", delete(delete_route::<RR, SR>))
|
||||
.route("/routes:check", post(check_route::<RR, SR>))
|
||||
.route("/routes:match", post(match_route::<RR, SR>))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -67,6 +81,10 @@ pub struct CreateRouteRequest {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CheckRouteRequest {
|
||||
/// Required: which app's route table this hypothetical route would
|
||||
/// join. Conflict checks are strictly intra-app (cross-app route
|
||||
/// errors would leak tenant info — see blueprint §11.5).
|
||||
pub app_id: AppId,
|
||||
pub host_kind: HostKind,
|
||||
#[serde(default)]
|
||||
pub host: String,
|
||||
@@ -84,6 +102,9 @@ pub struct CheckRouteResponse {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MatchRouteRequest {
|
||||
/// Which app's route table to dispatch against. The dashboard's
|
||||
/// route-preview tester always knows the current app context.
|
||||
pub app_id: AppId,
|
||||
pub url: String,
|
||||
#[serde(default = "default_method")]
|
||||
pub method: String,
|
||||
@@ -111,15 +132,28 @@ pub struct MatchedRoute {
|
||||
// Handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_routes<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(script_id): Path<ScriptId>,
|
||||
) -> 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?))
|
||||
}
|
||||
|
||||
async fn create_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(script_id): Path<ScriptId>,
|
||||
Json(input): Json<CreateRouteRequest>,
|
||||
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
|
||||
@@ -130,8 +164,28 @@ async fn create_route<RR: RouteRepository>(
|
||||
input.host_param_name.as_deref(),
|
||||
)?;
|
||||
|
||||
// Within-kind conflict check against existing routes.
|
||||
let existing = state.routes.list_all().await?;
|
||||
// Look up the script's owning app — every route inherits it.
|
||||
let script = state
|
||||
.scripts
|
||||
.get(script_id)
|
||||
.await?
|
||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||
let app_id = script.app_id;
|
||||
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(
|
||||
&existing,
|
||||
input.host_kind,
|
||||
@@ -149,6 +203,7 @@ async fn create_route<RR: RouteRepository>(
|
||||
let created = state
|
||||
.routes
|
||||
.create(NewRoute {
|
||||
app_id,
|
||||
script_id,
|
||||
host_kind: input.host_kind,
|
||||
host: input.host,
|
||||
@@ -162,23 +217,47 @@ async fn create_route<RR: RouteRepository>(
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn delete_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(route_id): Path<Uuid>,
|
||||
) -> 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?;
|
||||
refresh_table(&state).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn check_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<CheckRouteRequest>,
|
||||
) -> 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)?;
|
||||
pattern::parse_host(input.host_kind, &input.host, None)?;
|
||||
|
||||
let existing = state.routes.list_all().await?;
|
||||
let existing = state.routes.list_for_app(input.app_id).await?;
|
||||
let conflict = first_conflict(
|
||||
&existing,
|
||||
input.host_kind,
|
||||
@@ -201,16 +280,25 @@ async fn check_route<RR: RouteRepository>(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn match_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<MatchRouteRequest>,
|
||||
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppRead(input.app_id),
|
||||
)
|
||||
.await?;
|
||||
let parsed = url::Url::parse(&input.url)
|
||||
.map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?;
|
||||
let host = parsed.host_str().unwrap_or("").to_string();
|
||||
let path = parsed.path().to_string();
|
||||
|
||||
let result = state.table.match_request(&host, &input.method, &path);
|
||||
let result = state
|
||||
.table
|
||||
.match_request_for_app(input.app_id, &host, &input.method, &path);
|
||||
Ok(Json(MatchRouteResponse {
|
||||
matched: result.map(|r| MatchedRoute {
|
||||
route_id: r.matched.route_id,
|
||||
@@ -263,12 +351,12 @@ fn first_conflict(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn refresh_table<RR: RouteRepository>(
|
||||
state: &RouteAdminState<RR>,
|
||||
async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
|
||||
state: &RouteAdminState<RR, SR>,
|
||||
) -> Result<(), RouteApiError> {
|
||||
let rows = state.routes.list_all().await?;
|
||||
let compiled = compile_routes(&rows)?;
|
||||
state.table.replace(compiled);
|
||||
state.table.replace_all(compiled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -277,6 +365,7 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
|
||||
.map(|r| {
|
||||
Ok(CompiledRoute {
|
||||
route_id: r.id,
|
||||
app_id: r.app_id,
|
||||
script_id: r.script_id,
|
||||
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
|
||||
path: pattern::parse_path(r.path_kind, &r.path)?,
|
||||
@@ -286,6 +375,79 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Validate that a new route's (host_kind, host) is consistent with at
|
||||
/// least one of the parent app's domain claims. `HostKind::Any` is
|
||||
/// always permitted — it catches every host the app already owns.
|
||||
async fn validate_route_host_against_app(
|
||||
domains: &dyn AppDomainRepository,
|
||||
app_id: AppId,
|
||||
host_kind: HostKind,
|
||||
host: &str,
|
||||
) -> Result<(), RouteApiError> {
|
||||
if matches!(host_kind, HostKind::Any) {
|
||||
return Ok(());
|
||||
}
|
||||
let claims = domains.list_for_app(app_id).await?;
|
||||
if claims.is_empty() {
|
||||
return Err(RouteApiError::HostNotClaimed {
|
||||
host: host.to_string(),
|
||||
available_claims: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
let host_lower = host.to_ascii_lowercase();
|
||||
for claim in &claims {
|
||||
let claim_lower = claim.pattern.to_ascii_lowercase();
|
||||
match (host_kind, claim.shape) {
|
||||
// Strict route under exact claim: must match exactly.
|
||||
(HostKind::Strict, picloud_shared::DomainShape::Exact) => {
|
||||
if host_lower == claim_lower {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Strict route under wildcard/parameterized: must end with
|
||||
// ".<suffix>" where the claim's suffix is the part after
|
||||
// `*.` or `{...}.`.
|
||||
(
|
||||
HostKind::Strict,
|
||||
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
|
||||
) => {
|
||||
let suffix = claim_lower
|
||||
.split_once('.')
|
||||
.map(|(_, s)| s.to_string())
|
||||
.unwrap_or_default();
|
||||
let needle = format!(".{suffix}");
|
||||
if !suffix.is_empty() && host_lower.ends_with(&needle) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Wildcard route: must match a wildcard or parameterized
|
||||
// claim with identical suffix.
|
||||
(
|
||||
HostKind::Wildcard,
|
||||
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
|
||||
) => {
|
||||
let claim_suffix = claim_lower
|
||||
.split_once('.')
|
||||
.map(|(_, s)| s.to_string())
|
||||
.unwrap_or_default();
|
||||
if claim_suffix == host_lower {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Wildcard route under exact claim: not allowed (would
|
||||
// shadow other apps' subdomains the operator didn't claim).
|
||||
(HostKind::Wildcard, picloud_shared::DomainShape::Exact) => {}
|
||||
(HostKind::Any, _) => unreachable!("handled above"),
|
||||
}
|
||||
}
|
||||
|
||||
Err(RouteApiError::HostNotClaimed {
|
||||
host: host.to_string(),
|
||||
available_claims: claims.into_iter().map(|c| c.pattern).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -304,10 +466,37 @@ pub enum RouteApiError {
|
||||
#[error("bad request: {0}")]
|
||||
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}")]
|
||||
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 {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
@@ -326,10 +515,34 @@ impl IntoResponse for RouteApiError {
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
||||
Self::ScriptNotFound(_)
|
||||
| Self::RouteNotFound(_)
|
||||
| Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
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(_)) => (
|
||||
StatusCode::CONFLICT,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! CRUD over the `routes` table.
|
||||
//!
|
||||
//! The orchestrator's `RouteTable` is repopulated from this repo after
|
||||
//! every write — see the route_admin module for the binding.
|
||||
//! The orchestrator's `AppRouteTables` is repopulated from this repo
|
||||
//! after every write — see the route_admin module for the binding.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
|
||||
use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::repo::ScriptRepositoryError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewRoute {
|
||||
pub app_id: AppId,
|
||||
pub script_id: ScriptId,
|
||||
pub host_kind: HostKind,
|
||||
pub host: String,
|
||||
@@ -24,12 +25,25 @@ pub struct NewRoute {
|
||||
#[async_trait]
|
||||
pub trait RouteRepository: Send + Sync {
|
||||
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(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError>;
|
||||
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError>;
|
||||
/// Count routes whose host_kind/host pair matches a pattern in
|
||||
/// `app_id`. Used by the domain-claim delete guard.
|
||||
async fn count_for_app_host(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
host_kind: HostKind,
|
||||
host: &str,
|
||||
) -> Result<i64, ScriptRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresRouteRepository {
|
||||
@@ -47,7 +61,7 @@ impl PostgresRouteRepository {
|
||||
impl RouteRepository for PostgresRouteRepository {
|
||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, script_id, host_kind, host, host_param_name, \
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, created_at \
|
||||
FROM routes ORDER BY created_at",
|
||||
)
|
||||
@@ -56,12 +70,36 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
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(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, script_id, host_kind, host, host_param_name, \
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, created_at \
|
||||
FROM routes WHERE script_id = $1 ORDER BY created_at",
|
||||
)
|
||||
@@ -74,12 +112,13 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
|
||||
let res = sqlx::query_as::<_, RouteRow>(
|
||||
"INSERT INTO routes ( \
|
||||
script_id, host_kind, host, host_param_name, \
|
||||
app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, script_id, host_kind, host, host_param_name, \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
|
||||
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, created_at",
|
||||
)
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(input.script_id.into_inner())
|
||||
.bind(host_kind_str(input.host_kind))
|
||||
.bind(&input.host)
|
||||
@@ -112,6 +151,24 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count_for_app_host(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
host_kind: HostKind,
|
||||
host: &str,
|
||||
) -> Result<i64, ScriptRepositoryError> {
|
||||
let count: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM routes \
|
||||
WHERE app_id = $1 AND host_kind = $2 AND host = $3",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(host_kind_str(host_kind))
|
||||
.bind(host)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count.0)
|
||||
}
|
||||
}
|
||||
|
||||
const fn host_kind_str(k: HostKind) -> &'static str {
|
||||
@@ -133,6 +190,7 @@ const fn path_kind_str(k: PathKind) -> &'static str {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RouteRow {
|
||||
id: Uuid,
|
||||
app_id: Uuid,
|
||||
script_id: Uuid,
|
||||
host_kind: String,
|
||||
host: String,
|
||||
@@ -147,6 +205,7 @@ impl From<RouteRow> for Route {
|
||||
fn from(r: RouteRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
app_id: r.app_id.into(),
|
||||
script_id: r.script_id.into(),
|
||||
host_kind: match r.host_kind.as_str() {
|
||||
"strict" => HostKind::Strict,
|
||||
|
||||
@@ -3,6 +3,64 @@
|
||||
|
||||
## 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
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
script_id: uuid NOT NULL
|
||||
@@ -16,6 +74,7 @@ table: execution_logs
|
||||
duration_ms: integer NOT NULL default=0
|
||||
status: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
app_id: uuid NOT NULL
|
||||
|
||||
table: routes
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
@@ -27,6 +86,7 @@ table: routes
|
||||
path: text NOT NULL
|
||||
method: text NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
app_id: uuid NOT NULL
|
||||
|
||||
table: scripts
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
@@ -39,42 +99,119 @@ table: scripts
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
sandbox: jsonb NOT NULL default='{}'::jsonb
|
||||
app_id: uuid NOT NULL
|
||||
|
||||
## indexes
|
||||
|
||||
indexes on admin_sessions:
|
||||
admin_sessions_expiry_idx: public.admin_sessions USING btree (expires_at)
|
||||
admin_sessions_pkey: public.admin_sessions USING btree (token_hash)
|
||||
admin_sessions_user_idx: public.admin_sessions USING btree (user_id)
|
||||
|
||||
indexes on admin_users:
|
||||
admin_users_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:
|
||||
execution_logs_app_id_created_at_idx: public.execution_logs USING btree (app_id, created_at DESC)
|
||||
execution_logs_pkey: public.execution_logs USING btree (id)
|
||||
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
|
||||
|
||||
indexes on routes:
|
||||
routes_app_id_idx: public.routes USING btree (app_id)
|
||||
routes_lookup_idx: public.routes USING btree (host_kind, host)
|
||||
routes_pkey: public.routes USING btree (id)
|
||||
routes_script_id_idx: public.routes USING btree (script_id)
|
||||
routes_unique_binding_idx: public.routes USING btree (host_kind, host, path_kind, path, COALESCE(method, ''::text))
|
||||
routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text))
|
||||
|
||||
indexes on scripts:
|
||||
scripts_name_uidx: public.scripts USING btree (lower(name))
|
||||
scripts_app_id_idx: public.scripts USING btree (app_id)
|
||||
scripts_name_uidx: public.scripts USING btree (app_id, lower(name))
|
||||
scripts_pkey: public.scripts USING btree (id)
|
||||
|
||||
## constraints
|
||||
|
||||
constraints on admin_sessions:
|
||||
[FOREIGN KEY] admin_sessions_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] admin_sessions_pkey: PRIMARY KEY (token_hash)
|
||||
|
||||
constraints on admin_users:
|
||||
[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:
|
||||
[CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text])))
|
||||
[FOREIGN KEY] execution_logs_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on routes:
|
||||
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
||||
[CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
|
||||
[FOREIGN KEY] routes_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on scripts:
|
||||
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
|
||||
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
|
||||
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
|
||||
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
|
||||
|
||||
## applied migrations
|
||||
0001: init
|
||||
0002: sandbox
|
||||
0003: routes
|
||||
0004: admin auth
|
||||
0005: apps
|
||||
0006: users authz
|
||||
|
||||
@@ -17,22 +17,26 @@ use axum::{
|
||||
use chrono::Utc;
|
||||
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
|
||||
use picloud_shared::{
|
||||
ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
|
||||
AppId, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
|
||||
};
|
||||
use serde_json::Value as Json_;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::client::ExecutorClient;
|
||||
use crate::resolver::{ResolverError, ScriptResolver};
|
||||
use crate::routing::RouteTable;
|
||||
use crate::routing::{AppDomainTable, RouteTable};
|
||||
|
||||
/// State shared by data-plane handlers.
|
||||
pub struct DataPlaneState<E, R> {
|
||||
pub executor: Arc<E>,
|
||||
pub resolver: Arc<R>,
|
||||
pub log_sink: Arc<dyn ExecutionLogSink>,
|
||||
/// Routing table for user-defined paths. Shared with the manager
|
||||
/// (admin router writes; this side reads).
|
||||
/// Host → app_id resolver. Run before `routes` to filter to the
|
||||
/// owning app's slice. Shared with the manager (writes invalidate
|
||||
/// the cache by replacing the table).
|
||||
pub app_domains: Arc<AppDomainTable>,
|
||||
/// Routing table for user-defined paths, partitioned per app.
|
||||
/// Shared with the manager (admin router writes; this side reads).
|
||||
pub routes: Arc<RouteTable>,
|
||||
}
|
||||
|
||||
@@ -42,6 +46,7 @@ impl<E, R> Clone for DataPlaneState<E, R> {
|
||||
executor: self.executor.clone(),
|
||||
resolver: self.resolver.clone(),
|
||||
log_sink: self.log_sink.clone(),
|
||||
app_domains: self.app_domains.clone(),
|
||||
routes: self.routes.clone(),
|
||||
}
|
||||
}
|
||||
@@ -109,6 +114,7 @@ where
|
||||
// audit-visible platform — but a sink failure must not mask the
|
||||
// user-facing result, so we only log a warning if it fails.
|
||||
let log = build_execution_log(
|
||||
script.app_id,
|
||||
id,
|
||||
request_id,
|
||||
request_path,
|
||||
@@ -145,7 +151,23 @@ where
|
||||
.to_string();
|
||||
let headers = request.headers().clone();
|
||||
|
||||
let Some(matched) = state.routes.match_request(&host, &method, &path) else {
|
||||
// Two-phase dispatch (blueprint §11.5): first resolve Host → app_id,
|
||||
// then run the existing matcher on that app's slice. No app claims
|
||||
// this host → flat 404; the path doesn't get the chance to fire.
|
||||
let Some(app_id) = state.app_domains.resolve_app(&host) else {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": format!("no app claims host {host:?}")
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
};
|
||||
|
||||
let Some(matched) = state
|
||||
.routes
|
||||
.match_request_for_app(app_id, &host, &method, &path)
|
||||
else {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
@@ -191,6 +213,7 @@ where
|
||||
let finished = Utc::now();
|
||||
|
||||
let log = build_execution_log(
|
||||
script.app_id,
|
||||
matched.matched.script_id,
|
||||
request_id,
|
||||
request_path,
|
||||
@@ -292,6 +315,7 @@ fn exec_response_to_http(resp: ExecResponse) -> Response {
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_execution_log(
|
||||
app_id: AppId,
|
||||
script_id: ScriptId,
|
||||
request_id: RequestId,
|
||||
request_path: String,
|
||||
@@ -336,6 +360,7 @@ fn build_execution_log(
|
||||
|
||||
ExecutionLog {
|
||||
id: Uuid::new_v4(),
|
||||
app_id,
|
||||
script_id,
|
||||
request_id,
|
||||
request_path,
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
/// A single route ready for matching.
|
||||
/// A single route ready for matching. `app_id` is carried so the
|
||||
/// caller (the orchestrator's `AppRouteTables`) can partition the
|
||||
/// table; the matcher itself doesn't read it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompiledRoute {
|
||||
pub route_id: uuid::Uuid,
|
||||
pub app_id: picloud_shared::AppId,
|
||||
pub script_id: picloud_shared::ScriptId,
|
||||
pub host: HostPattern,
|
||||
pub path: PathPattern,
|
||||
@@ -298,12 +301,13 @@ fn match_param(segs: &[PathSegment], request_path: &str) -> Option<BTreeMap<Stri
|
||||
mod tests {
|
||||
use super::super::pattern::parse_path;
|
||||
use super::*;
|
||||
use picloud_shared::{PathKind, ScriptId};
|
||||
use picloud_shared::{AppId, PathKind, ScriptId};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn route(host: HostPattern, path_kind: PathKind, raw: &str) -> CompiledRoute {
|
||||
CompiledRoute {
|
||||
route_id: Uuid::new_v4(),
|
||||
app_id: AppId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
host,
|
||||
path: parse_path(path_kind, raw).unwrap(),
|
||||
|
||||
@@ -17,12 +17,16 @@
|
||||
//! * **Host dispatch** — `strict > wildcard > any`; longest matching
|
||||
//! wildcard suffix breaks ties between wildcards.
|
||||
|
||||
pub mod app_domains;
|
||||
pub mod conflict;
|
||||
pub mod matcher;
|
||||
pub mod pattern;
|
||||
pub mod table;
|
||||
|
||||
pub use app_domains::{AppDomainTable, CompiledAppDomain};
|
||||
pub use conflict::{conflicts, ConflictReason};
|
||||
pub use matcher::{MatchResult, Matched};
|
||||
pub use pattern::{HostPattern, ParseError, PathPattern, PathSegment};
|
||||
pub use pattern::{
|
||||
parse_app_domain, HostPattern, ParseError, ParsedAppDomain, PathPattern, PathSegment,
|
||||
};
|
||||
pub use table::RouteTable;
|
||||
|
||||
@@ -251,6 +251,106 @@ pub fn parse_host(
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// App-domain patterns
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
use picloud_shared::DomainShape;
|
||||
|
||||
/// Result of parsing a user-supplied app domain claim. Carries the
|
||||
/// host pattern (used at request time), the shape (used at write time
|
||||
/// for collision checks), and the normalized shape_key.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParsedAppDomain {
|
||||
pub pattern: HostPattern,
|
||||
pub shape: DomainShape,
|
||||
/// Collision key: `"exact:<host>"` for exact; `"wildcard:<suffix>"`
|
||||
/// for both wildcard AND parameterized — they share a shape per
|
||||
/// blueprint §11.5 ("`{tenant}` has the same shape as `*` for this
|
||||
/// check").
|
||||
pub shape_key: String,
|
||||
/// Captured binding name for parameterized claims, e.g., `Some("tenant")`
|
||||
/// for `{tenant}.example.com`. Currently informational; the binding
|
||||
/// is surfaced into request context in a future iteration.
|
||||
pub binding: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a user-supplied app domain claim. Accepts:
|
||||
/// * `app.example.com` — exact host
|
||||
/// * `*.example.com` — wildcard suffix
|
||||
/// * `{tenant}.example.com` — parameterized; same shape as wildcard
|
||||
///
|
||||
/// Distinct from `parse_host` (which is for route host fields): the
|
||||
/// route parser still rejects `{...}` syntax — see
|
||||
/// `ParseError::ReservedHostBraceSyntax`.
|
||||
pub fn parse_app_domain(raw: &str) -> Result<ParsedAppDomain, ParseError> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ParseError::EmptyHost);
|
||||
}
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
|
||||
// Wildcard: starts with "*."
|
||||
if let Some(suffix) = lowered.strip_prefix("*.") {
|
||||
if suffix.is_empty() {
|
||||
return Err(ParseError::EmptyWildcardSuffix);
|
||||
}
|
||||
return Ok(ParsedAppDomain {
|
||||
pattern: HostPattern::Wildcard {
|
||||
suffix: suffix.to_string(),
|
||||
capture: None,
|
||||
},
|
||||
shape: DomainShape::Wildcard,
|
||||
shape_key: format!("wildcard:{suffix}"),
|
||||
binding: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Parameterized: starts with "{name}." where `name` is an ident.
|
||||
if let Some(stripped) = lowered.strip_prefix('{') {
|
||||
let (binding, rest) = stripped
|
||||
.split_once('}')
|
||||
.ok_or(ParseError::ReservedHostBraceSyntax)?;
|
||||
if binding.is_empty()
|
||||
|| !binding
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
|| !binding.chars().next().unwrap().is_ascii_alphabetic()
|
||||
{
|
||||
return Err(ParseError::InvalidParamName(binding.to_string()));
|
||||
}
|
||||
let suffix = rest
|
||||
.strip_prefix('.')
|
||||
.ok_or(ParseError::ReservedHostBraceSyntax)?;
|
||||
if suffix.is_empty() || suffix.contains('{') || suffix.contains('}') {
|
||||
return Err(ParseError::ReservedHostBraceSyntax);
|
||||
}
|
||||
return Ok(ParsedAppDomain {
|
||||
pattern: HostPattern::Wildcard {
|
||||
suffix: suffix.to_string(),
|
||||
capture: Some(binding.to_string()),
|
||||
},
|
||||
shape: DomainShape::Parameterized,
|
||||
// Same shape_key as the equivalent wildcard — parameter
|
||||
// name is a binding, not a discriminator.
|
||||
shape_key: format!("wildcard:{suffix}"),
|
||||
binding: Some(binding.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// Anything else: exact host. Reject braces anywhere in the body
|
||||
// (they'd be a malformed parameterized form).
|
||||
if lowered.contains('{') || lowered.contains('}') {
|
||||
return Err(ParseError::ReservedHostBraceSyntax);
|
||||
}
|
||||
Ok(ParsedAppDomain {
|
||||
pattern: HostPattern::Strict(lowered.clone()),
|
||||
shape: DomainShape::Exact,
|
||||
shape_key: format!("exact:{lowered}"),
|
||||
binding: None,
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -393,6 +493,49 @@ mod tests {
|
||||
assert_eq!(e, ParseError::ReservedHostBraceSyntax);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_app_domain_exact() {
|
||||
let d = parse_app_domain("App.Example.COM").unwrap();
|
||||
assert_eq!(d.shape, DomainShape::Exact);
|
||||
assert_eq!(d.shape_key, "exact:app.example.com");
|
||||
assert_eq!(d.pattern, HostPattern::Strict("app.example.com".into()));
|
||||
assert!(d.binding.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_app_domain_wildcard_and_parameterized_share_shape_key() {
|
||||
let w = parse_app_domain("*.example.com").unwrap();
|
||||
let p = parse_app_domain("{tenant}.example.com").unwrap();
|
||||
assert_eq!(w.shape, DomainShape::Wildcard);
|
||||
assert_eq!(p.shape, DomainShape::Parameterized);
|
||||
// Same shape_key — they collide at claim time (blueprint §11.5).
|
||||
assert_eq!(w.shape_key, "wildcard:example.com");
|
||||
assert_eq!(p.shape_key, "wildcard:example.com");
|
||||
assert_eq!(p.binding.as_deref(), Some("tenant"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_app_domain_rejects_garbage() {
|
||||
assert!(matches!(parse_app_domain(""), Err(ParseError::EmptyHost)));
|
||||
assert!(matches!(
|
||||
parse_app_domain("*."),
|
||||
Err(ParseError::EmptyWildcardSuffix)
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_app_domain("{}.example.com"),
|
||||
Err(ParseError::InvalidParamName(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_app_domain("{1tenant}.example.com"),
|
||||
Err(ParseError::InvalidParamName(_))
|
||||
));
|
||||
// Mid-host braces — disallowed.
|
||||
assert!(matches!(
|
||||
parse_app_domain("foo.{tenant}.example.com"),
|
||||
Err(ParseError::ReservedHostBraceSyntax)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_literal_count_works() {
|
||||
let exact = parse_path(PathKind::Exact, "/foo/users").unwrap();
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
//! In-memory snapshot of compiled routes, shared by manager (writes)
|
||||
//! and orchestrator (reads).
|
||||
//! In-memory snapshot of compiled routes, partitioned by `app_id`.
|
||||
//!
|
||||
//! Holds an `arc-swap`-style lock-free hand-off so the dispatcher can
|
||||
//! read without contending against the writer; in MVP-single-process
|
||||
//! we just use `RwLock` and accept the cheap contention.
|
||||
//! The orchestrator looks up the app's slice by id after `AppDomainTable`
|
||||
//! has resolved Host → app_id, then runs the existing matcher on that
|
||||
//! slice. The matcher is unchanged; this type is just a per-app bucket.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use picloud_shared::AppId;
|
||||
|
||||
use super::matcher::{r#match, CompiledRoute, MatchResult};
|
||||
|
||||
/// Per-app compiled-route tables. Single MVP-mode writer (the manager,
|
||||
/// via `replace_all`); contention against readers is minimal so a plain
|
||||
/// `RwLock` is fine.
|
||||
#[derive(Default)]
|
||||
pub struct RouteTable {
|
||||
inner: RwLock<Vec<CompiledRoute>>,
|
||||
inner: RwLock<HashMap<AppId, Vec<CompiledRoute>>>,
|
||||
}
|
||||
|
||||
impl RouteTable {
|
||||
@@ -20,24 +25,54 @@ impl RouteTable {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Replace the whole table atomically. The manager calls this after
|
||||
/// each successful route CRUD operation (by re-reading from DB).
|
||||
pub fn replace(&self, routes: Vec<CompiledRoute>) {
|
||||
/// Replace every per-app slice atomically. The manager calls this
|
||||
/// after each successful route CRUD operation; in cluster mode the
|
||||
/// orchestrator's HTTP-fed receiver will too.
|
||||
pub fn replace_all(&self, routes: Vec<CompiledRoute>) {
|
||||
let mut by_app: HashMap<AppId, Vec<CompiledRoute>> = HashMap::new();
|
||||
for r in routes {
|
||||
by_app.entry(r.app_id).or_default().push(r);
|
||||
}
|
||||
let mut guard = self.inner.write().expect("route table poisoned");
|
||||
*guard = routes;
|
||||
*guard = by_app;
|
||||
}
|
||||
|
||||
/// Dispatch a request to a matching route, or `None`.
|
||||
/// Dispatch a request to a matching route within `app_id`, or
|
||||
/// `None`. Returns `None` when the app has no routes at all.
|
||||
#[must_use]
|
||||
pub fn match_request(&self, host: &str, method: &str, path: &str) -> Option<MatchResult> {
|
||||
pub fn match_request_for_app(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
host: &str,
|
||||
method: &str,
|
||||
path: &str,
|
||||
) -> Option<MatchResult> {
|
||||
let guard = self.inner.read().expect("route table poisoned");
|
||||
r#match(guard.iter(), host, method, path)
|
||||
let slice = guard.get(&app_id)?;
|
||||
r#match(slice.iter(), host, method, path)
|
||||
}
|
||||
|
||||
/// Returns a clone of the currently compiled routes; intended for
|
||||
/// the dashboard's "list routes" admin endpoint.
|
||||
/// Returns a clone of the currently compiled routes for `app_id`;
|
||||
/// intended for admin endpoints like "list this app's routes".
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> Vec<CompiledRoute> {
|
||||
self.inner.read().expect("route table poisoned").clone()
|
||||
pub fn snapshot_for_app(&self, app_id: AppId) -> Vec<CompiledRoute> {
|
||||
self.inner
|
||||
.read()
|
||||
.expect("route table poisoned")
|
||||
.get(&app_id)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// All compiled routes across all apps. Used by tests and the
|
||||
/// global admin "every route on this install" view.
|
||||
#[must_use]
|
||||
pub fn snapshot_all(&self) -> Vec<CompiledRoute> {
|
||||
self.inner
|
||||
.read()
|
||||
.expect("route table poisoned")
|
||||
.values()
|
||||
.flat_map(|v| v.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,3 +39,5 @@ figment.workspace = true
|
||||
axum-test = "17"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
@@ -6,14 +6,21 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::middleware::from_fn_with_state;
|
||||
use axum::{routing::get, Json, Router};
|
||||
use picloud_executor_core::{Engine, Limits};
|
||||
use picloud_manager_core::{
|
||||
admin_router, compile_routes, migrations, route_admin_router, AdminState,
|
||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
||||
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
||||
admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
|
||||
auth_router, compile_routes, migrations, require_authenticated, route_admin_router,
|
||||
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::{
|
||||
data_plane_router, user_routes_router, DataPlaneState, LocalExecutorClient,
|
||||
};
|
||||
@@ -24,6 +31,40 @@ use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
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
|
||||
/// 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
|
||||
/// falls through to the user-route table — user scripts can bind to
|
||||
/// 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 script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
||||
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
|
||||
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
|
||||
let route_repo = Arc::new(PostgresRouteRepository::new(pool));
|
||||
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
||||
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
||||
let domains_repo: Arc<dyn AppDomainRepository> =
|
||||
Arc::new(PostgresAppDomainRepository::new(pool.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.
|
||||
let route_table = Arc::new(RouteTable::new());
|
||||
let initial = route_repo.list_all().await?;
|
||||
let compiled = compile_routes(&initial)
|
||||
.map_err(|e| anyhow::anyhow!("failed to compile stored routes: {e}"))?;
|
||||
route_table.replace(compiled);
|
||||
route_table.replace_all(compiled);
|
||||
|
||||
// Same shape for app domains (Host → app_id cache).
|
||||
let app_domain_table = Arc::new(AppDomainTable::new());
|
||||
let initial_domains = domains_repo.list_all().await?;
|
||||
let compiled_domains: Vec<_> = initial_domains
|
||||
.iter()
|
||||
.filter_map(|d| {
|
||||
picloud_orchestrator_core::routing::parse_app_domain(&d.pattern)
|
||||
.ok()
|
||||
.map(|p| picloud_orchestrator_core::routing::CompiledAppDomain {
|
||||
app_id: d.app_id,
|
||||
pattern: p.pattern,
|
||||
shape_key: p.shape_key,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
app_domain_table.replace(compiled_domains);
|
||||
|
||||
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
|
||||
script_repo.clone(),
|
||||
@@ -52,25 +129,80 @@ pub async fn build_app(pool: PgPool) -> anyhow::Result<Router> {
|
||||
let executor = Arc::new(LocalExecutorClient::new(engine.clone()));
|
||||
|
||||
let admin = AdminState {
|
||||
repo: Arc::new(PostgresScriptRepoHandle(script_repo)),
|
||||
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
|
||||
logs: log_repo,
|
||||
apps: apps_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
validator: engine as Arc<dyn ScriptValidator>,
|
||||
sandbox_ceiling: SandboxCeiling::from_env(),
|
||||
};
|
||||
let route_admin = RouteAdminState {
|
||||
routes: route_repo,
|
||||
routes: route_repo.clone(),
|
||||
scripts: Arc::new(PostgresScriptRepoHandle(script_repo)),
|
||||
domains: domains_repo.clone(),
|
||||
table: route_table.clone(),
|
||||
authz: authz.clone(),
|
||||
};
|
||||
let data_plane = DataPlaneState {
|
||||
executor,
|
||||
resolver,
|
||||
log_sink,
|
||||
app_domains: app_domain_table.clone(),
|
||||
routes: route_table,
|
||||
};
|
||||
let apps_state = AppsState {
|
||||
apps: apps_repo,
|
||||
domains: domains_repo,
|
||||
routes: route_repo,
|
||||
domain_table: app_domain_table,
|
||||
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()
|
||||
.nest("/admin", admin_router(admin))
|
||||
.nest("/admin", route_admin_router(route_admin))
|
||||
.nest("/admin", auth_router(auth_state))
|
||||
.nest("/admin", guarded_admin)
|
||||
.merge(data_plane_router(data_plane.clone()));
|
||||
|
||||
Ok(Router::new()
|
||||
@@ -138,6 +270,18 @@ impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
|
||||
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||
self.0.list().await
|
||||
}
|
||||
async fn list_for_app(
|
||||
&self,
|
||||
app_id: picloud_shared::AppId,
|
||||
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||
self.0.list_for_app(app_id).await
|
||||
}
|
||||
async fn 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(
|
||||
&self,
|
||||
input: picloud_manager_core::NewScript,
|
||||
|
||||
@@ -1,17 +1,38 @@
|
||||
//! PiCloud all-in-one binary — see `lib.rs` for the actual app
|
||||
//! 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::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use picloud::{build_app, init_db};
|
||||
use picloud_manager_core::migrations;
|
||||
use picloud::{build_app, init_db, AuthDeps};
|
||||
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;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
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")
|
||||
.unwrap_or_else(|_| "0.0.0.0:8080".into())
|
||||
.parse()?;
|
||||
@@ -22,7 +43,33 @@ async fn main() -> anyhow::Result<()> {
|
||||
migrations::run(&pool).await?;
|
||||
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?;
|
||||
tracing::info!(%addr, "picloud all-in-one listening");
|
||||
@@ -33,6 +80,140 @@ async fn main() -> anyhow::Result<()> {
|
||||
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() {
|
||||
tracing_subscriber::fmt()
|
||||
.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 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 {
|
||||
let app = picloud::build_app(pool).await.expect("build_app");
|
||||
TestServer::new(app).expect("TestServer should build")
|
||||
let (server, _app_id) = server_with_app(pool).await;
|
||||
server
|
||||
}
|
||||
|
||||
/// Like `server`, but also returns the default app's id — needed by
|
||||
/// any test that creates scripts (every script now requires `app_id`).
|
||||
async fn server_with_app(pool: PgPool) -> (TestServer, String) {
|
||||
use picloud_manager_core::auth::hash_password;
|
||||
use picloud_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");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
// ============================================================================
|
||||
@@ -41,30 +162,37 @@ async fn healthz_responds_ok(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn create_script_returns_201_with_full_record(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let r = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "echo",
|
||||
"description": "test",
|
||||
"source": "#{ statusCode: 200, body: 42 }",
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
r.assert_status(axum::http::StatusCode::CREATED);
|
||||
let body: Value = r.json();
|
||||
assert_eq!(body["name"], "echo");
|
||||
assert_eq!(body["version"], 1);
|
||||
assert_eq!(body["timeout_seconds"], 30);
|
||||
assert_eq!(body["app_id"], app_id);
|
||||
assert!(body["id"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
|
||||
let r = server(pool)
|
||||
.await
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let r = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({ "name": "broken", "source": "@@@ not rhai @@@" }))
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({ "name": "broken", "source": "@@@ not rhai @@@" }),
|
||||
))
|
||||
.await;
|
||||
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body: Value = r.json();
|
||||
@@ -74,14 +202,14 @@ async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn duplicate_name_returns_409(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
s.post("/api/v1/admin/scripts")
|
||||
.json(&json!({ "name": "dup", "source": "42" }))
|
||||
.json(&with_app(&app_id, json!({ "name": "dup", "source": "42" })))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
let r = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({ "name": "dup", "source": "43" }))
|
||||
.json(&with_app(&app_id, json!({ "name": "dup", "source": "43" })))
|
||||
.await;
|
||||
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||
}
|
||||
@@ -89,10 +217,10 @@ async fn duplicate_name_returns_409(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn list_returns_all_scripts(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
for name in ["alpha", "bravo", "charlie"] {
|
||||
s.post("/api/v1/admin/scripts")
|
||||
.json(&json!({ "name": name, "source": "1" }))
|
||||
.json(&with_app(&app_id, json!({ "name": name, "source": "1" })))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
}
|
||||
@@ -107,10 +235,10 @@ async fn list_returns_all_scripts(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn update_bumps_version_and_persists_changes(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({ "name": "u", "source": "1" }))
|
||||
.json(&with_app(&app_id, json!({ "name": "u", "source": "1" })))
|
||||
.await
|
||||
.json();
|
||||
let id = created["id"].as_str().unwrap();
|
||||
@@ -129,10 +257,10 @@ async fn update_bumps_version_and_persists_changes(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn update_with_invalid_source_returns_422(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({ "name": "u", "source": "1" }))
|
||||
.json(&with_app(&app_id, json!({ "name": "u", "source": "1" })))
|
||||
.await
|
||||
.json();
|
||||
let id = created["id"].as_str().unwrap();
|
||||
@@ -147,10 +275,10 @@ async fn update_with_invalid_source_returns_422(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn delete_then_get_returns_404(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({ "name": "d", "source": "1" }))
|
||||
.json(&with_app(&app_id, json!({ "name": "d", "source": "1" })))
|
||||
.await
|
||||
.json();
|
||||
let id = created["id"].as_str().unwrap();
|
||||
@@ -181,13 +309,16 @@ async fn get_nonexistent_returns_404(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn execute_echoes_body_back(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "echo",
|
||||
"source": "#{ statusCode: 200, body: ctx.request.body }",
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.json();
|
||||
let id = created["id"].as_str().unwrap();
|
||||
@@ -204,13 +335,16 @@ async fn execute_echoes_body_back(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn execute_passes_through_status_and_headers(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "header-test",
|
||||
"source": "#{ statusCode: 201, headers: #{ \"x-tag\": \"on\" }, body: 1 }",
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.json();
|
||||
let id = created["id"].as_str().unwrap();
|
||||
@@ -237,13 +371,16 @@ async fn execute_nonexistent_returns_404(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn execution_logs_capture_invocations(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "logger",
|
||||
"source": "log::info(\"called\", #{ marker: 7 }); #{ statusCode: 200, body: \"done\" }",
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.json();
|
||||
let id = created["id"].as_str().unwrap();
|
||||
@@ -294,10 +431,13 @@ async fn execution_logs_capture_invocations(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({ "name": "no-sandbox", "source": "1" }))
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({ "name": "no-sandbox", "source": "1" }),
|
||||
))
|
||||
.await
|
||||
.json();
|
||||
assert_eq!(created["sandbox"], json!({}));
|
||||
@@ -306,14 +446,17 @@ async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "tight",
|
||||
"source": "1",
|
||||
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.json();
|
||||
assert_eq!(
|
||||
@@ -333,14 +476,17 @@ async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
|
||||
// Default conservative ceiling caps max_operations at 10_000_000.
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let r = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "too-loose",
|
||||
"source": "1",
|
||||
"sandbox": { "max_operations": 100_000_000 }
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body: Value = r.json();
|
||||
@@ -350,14 +496,17 @@ async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn sandbox_unknown_field_returns_422(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let r = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "typo",
|
||||
"source": "1",
|
||||
"sandbox": { "max_operashuns": 500 }
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
// serde's deny_unknown_fields causes axum to reject with 422 or
|
||||
// 400 depending on extractor; the routing is irrelevant here, just
|
||||
@@ -371,15 +520,18 @@ async fn sandbox_unknown_field_returns_422(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
// Tight max_operations on a loop the default would happily run.
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "tight-exec",
|
||||
"source": "let n = 0; for i in 0..10000 { n += 1; } n",
|
||||
"sandbox": { "max_operations": 500 }
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.json();
|
||||
let id = created["id"].as_str().unwrap();
|
||||
@@ -396,14 +548,17 @@ async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn update_replaces_sandbox_wholesale(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "patch-target",
|
||||
"source": "1",
|
||||
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.json();
|
||||
let id = created["id"].as_str().unwrap();
|
||||
@@ -429,10 +584,10 @@ async fn update_replaces_sandbox_wholesale(pool: PgPool) {
|
||||
// Custom routing
|
||||
// ============================================================================
|
||||
|
||||
async fn create_basic_script(s: &TestServer, name: &str, source: &str) -> String {
|
||||
async fn create_basic_script(s: &TestServer, app_id: &str, name: &str, source: &str) -> String {
|
||||
let v: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({ "name": name, "source": source }))
|
||||
.json(&with_app(app_id, json!({ "name": name, "source": source })))
|
||||
.await
|
||||
.json();
|
||||
v["id"].as_str().unwrap().to_string()
|
||||
@@ -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"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_exact_dispatches_to_script(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id = create_basic_script(
|
||||
&s,
|
||||
&app_id,
|
||||
"greet",
|
||||
"#{ statusCode: 200, body: #{ msg: \"hi\", path: ctx.request.path } }",
|
||||
)
|
||||
@@ -457,7 +613,7 @@ async fn route_exact_dispatches_to_script(pool: PgPool) {
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
let r = s.get("/greet").await;
|
||||
let r = s.get("/greet").add_header("host", "localhost").await;
|
||||
r.assert_status_ok();
|
||||
let body: Value = r.json();
|
||||
assert_eq!(body["msg"], "hi");
|
||||
@@ -467,9 +623,10 @@ async fn route_exact_dispatches_to_script(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_param_captures_path_vars(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id = create_basic_script(
|
||||
&s,
|
||||
&app_id,
|
||||
"greet-name",
|
||||
"#{ statusCode: 200, body: #{ name: ctx.request.params.name } }",
|
||||
)
|
||||
@@ -483,7 +640,7 @@ async fn route_param_captures_path_vars(pool: PgPool) {
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
let r = s.get("/greet/alice").await;
|
||||
let r = s.get("/greet/alice").add_header("host", "localhost").await;
|
||||
r.assert_status_ok();
|
||||
let body: Value = r.json();
|
||||
assert_eq!(body["name"], "alice");
|
||||
@@ -492,9 +649,10 @@ async fn route_param_captures_path_vars(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_prefix_captures_rest(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id = create_basic_script(
|
||||
&s,
|
||||
&app_id,
|
||||
"echo-prefix",
|
||||
"#{ statusCode: 200, body: #{ rest: ctx.request.rest } }",
|
||||
)
|
||||
@@ -508,19 +666,28 @@ async fn route_prefix_captures_rest(pool: PgPool) {
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
let r = s.get("/echo/foo/bar").await;
|
||||
let r = s.get("/echo/foo/bar").add_header("host", "localhost").await;
|
||||
r.assert_status_ok();
|
||||
let body: Value = r.json();
|
||||
assert_eq!(body["rest"], "foo/bar");
|
||||
|
||||
s.get("/echo").await.assert_status_not_found();
|
||||
s.get("/echo")
|
||||
.add_header("host", "localhost")
|
||||
.await
|
||||
.assert_status_not_found();
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_query_string_exposed_to_script(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let id = create_basic_script(&s, "qs", "#{ statusCode: 200, body: ctx.request.query }").await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id = create_basic_script(
|
||||
&s,
|
||||
&app_id,
|
||||
"qs",
|
||||
"#{ statusCode: 200, body: ctx.request.query }",
|
||||
)
|
||||
.await;
|
||||
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||
.json(&json!({
|
||||
"host_kind": "any",
|
||||
@@ -530,7 +697,7 @@ async fn route_query_string_exposed_to_script(pool: PgPool) {
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
let r = s.get("/qs?a=1&b=two").await;
|
||||
let r = s.get("/qs?a=1&b=two").add_header("host", "localhost").await;
|
||||
r.assert_status_ok();
|
||||
let body: Value = r.json();
|
||||
assert_eq!(body, json!({ "a": "1", "b": "two" }));
|
||||
@@ -539,8 +706,8 @@ async fn route_query_string_exposed_to_script(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_invalid_pattern_returns_422(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let id = create_basic_script(&s, "x", "1").await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id = create_basic_script(&s, &app_id, "x", "1").await;
|
||||
let r = s
|
||||
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||
.json(&json!({
|
||||
@@ -555,8 +722,8 @@ async fn route_invalid_pattern_returns_422(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_conflict_returns_409(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let id = create_basic_script(&s, "x", "1").await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id = create_basic_script(&s, &app_id, "x", "1").await;
|
||||
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||
.json(&json!({
|
||||
"host_kind": "any",
|
||||
@@ -582,8 +749,8 @@ async fn route_conflict_returns_409(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_reserved_path_returns_422(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let id = create_basic_script(&s, "x", "1").await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id = create_basic_script(&s, &app_id, "x", "1").await;
|
||||
let r = s
|
||||
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||
.json(&json!({
|
||||
@@ -598,8 +765,8 @@ async fn route_reserved_path_returns_422(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_match_preview_endpoint(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let id = create_basic_script(&s, "g", "1").await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id = create_basic_script(&s, &app_id, "g", "1").await;
|
||||
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||
.json(&json!({
|
||||
"host_kind": "any",
|
||||
@@ -611,7 +778,11 @@ async fn route_match_preview_endpoint(pool: PgPool) {
|
||||
|
||||
let r = s
|
||||
.post("/api/v1/admin/routes:match")
|
||||
.json(&json!({ "url": "http://localhost:8000/greet/alice", "method": "GET" }))
|
||||
.json(&json!({
|
||||
"app_id": app_id,
|
||||
"url": "http://localhost:8000/greet/alice",
|
||||
"method": "GET"
|
||||
}))
|
||||
.await;
|
||||
r.assert_status_ok();
|
||||
let body: Value = r.json();
|
||||
@@ -622,8 +793,8 @@ async fn route_match_preview_endpoint(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_delete_removes_dispatch(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let id = create_basic_script(&s, "g", "#{ statusCode: 200, body: 1 }").await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id = create_basic_script(&s, &app_id, "g", "#{ statusCode: 200, body: 1 }").await;
|
||||
let created: Value = s
|
||||
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||
.json(&json!({
|
||||
@@ -635,27 +806,35 @@ async fn route_delete_removes_dispatch(pool: PgPool) {
|
||||
.json();
|
||||
let route_id = created["id"].as_str().unwrap();
|
||||
|
||||
s.get("/g").await.assert_status_ok();
|
||||
s.get("/g")
|
||||
.add_header("host", "localhost")
|
||||
.await
|
||||
.assert_status_ok();
|
||||
|
||||
s.delete(&format!("/api/v1/admin/routes/{route_id}"))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||||
|
||||
s.get("/g").await.assert_status_not_found();
|
||||
s.get("/g")
|
||||
.add_header("host", "localhost")
|
||||
.await
|
||||
.assert_status_not_found();
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id_p = create_basic_script(
|
||||
&s,
|
||||
&app_id,
|
||||
"by-param",
|
||||
"#{ statusCode: 200, body: #{ tag: \"param\" } }",
|
||||
)
|
||||
.await;
|
||||
let id_pr = create_basic_script(
|
||||
&s,
|
||||
&app_id,
|
||||
"by-prefix",
|
||||
"#{ statusCode: 200, body: #{ tag: \"prefix\" } }",
|
||||
)
|
||||
@@ -678,12 +857,12 @@ async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
// Single segment under /foo/ — both match; param wins by spec.
|
||||
let r = s.get("/foo/x").await;
|
||||
let r = s.get("/foo/x").add_header("host", "localhost").await;
|
||||
let body: Value = r.json();
|
||||
assert_eq!(body["tag"], "param");
|
||||
|
||||
// Two segments — only prefix matches.
|
||||
let r2 = s.get("/foo/x/y").await;
|
||||
let r2 = s.get("/foo/x/y").add_header("host", "localhost").await;
|
||||
let body2: Value = r2.json();
|
||||
assert_eq!(body2["tag"], "prefix");
|
||||
}
|
||||
@@ -692,7 +871,7 @@ async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn root_returns_404_when_no_route(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let r = s.get("/").await;
|
||||
let r = s.get("/").add_header("host", "localhost").await;
|
||||
r.assert_status_not_found();
|
||||
}
|
||||
|
||||
@@ -705,22 +884,325 @@ async fn version_includes_public_base_url(pool: PgPool) {
|
||||
let v: Value = r.json();
|
||||
assert!(v["public_base_url"].is_string());
|
||||
assert_eq!(v["api"], 1);
|
||||
assert_eq!(v["schema"], 3);
|
||||
assert_eq!(v["schema"], 6);
|
||||
assert_eq!(v["sdk"], "1.1");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// App scoping (Phase 3b)
|
||||
// ============================================================================
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn default_app_is_seeded_by_migration(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let r = s.get("/api/v1/admin/apps").await;
|
||||
r.assert_status_ok();
|
||||
let apps: Vec<Value> = r.json();
|
||||
let default = apps
|
||||
.iter()
|
||||
.find(|a| a["slug"] == "default")
|
||||
.expect("default app must exist");
|
||||
assert_eq!(default["name"], "Default");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn cross_app_isolation_at_dispatch(pool: PgPool) {
|
||||
let (s, default_id) = server_with_app(pool).await;
|
||||
|
||||
// Two apps each create a script with the same name (per-app
|
||||
// uniqueness — would have collided pre-3b).
|
||||
let app_b: Value = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "tenant-b", "name": "Tenant B" }))
|
||||
.await
|
||||
.json();
|
||||
let b_id = app_b["id"].as_str().unwrap();
|
||||
s.post(&format!("/api/v1/admin/apps/{b_id}/domains"))
|
||||
.json(&json!({ "pattern": "b.localhost" }))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
let id_default: String = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&with_app(
|
||||
&default_id,
|
||||
json!({
|
||||
"name": "echo",
|
||||
"source": "#{ statusCode: 200, body: #{ from: \"default\" } }"
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.json::<Value>()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let id_b: String = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&with_app(
|
||||
b_id,
|
||||
json!({
|
||||
"name": "echo",
|
||||
"source": "#{ statusCode: 200, body: #{ from: \"b\" } }"
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.json::<Value>()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
s.post(&format!("/api/v1/admin/scripts/{id_default}/routes"))
|
||||
.json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/echo" }))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
s.post(&format!("/api/v1/admin/scripts/{id_b}/routes"))
|
||||
.json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/echo" }))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
// Same path, different host — routes land in different apps.
|
||||
let from_default: Value = s.get("/echo").add_header("host", "localhost").await.json();
|
||||
assert_eq!(from_default["from"], "default");
|
||||
|
||||
let from_b: Value = s
|
||||
.get("/echo")
|
||||
.add_header("host", "b.localhost")
|
||||
.await
|
||||
.json();
|
||||
assert_eq!(from_b["from"], "b");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn unknown_host_returns_404(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let r = s.get("/whatever").add_header("host", "nope.invalid").await;
|
||||
r.assert_status_not_found();
|
||||
let body: Value = r.json();
|
||||
assert!(body["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("no app claims host"));
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn execute_by_id_works_without_host_claim(pool: PgPool) {
|
||||
// The /api/v1/execute/{id} bypass is the implicit __internal__
|
||||
// claim of every app — it MUST keep working for an app with zero
|
||||
// public domain claims.
|
||||
let (s, _) = server_with_app(pool).await;
|
||||
let app: Value = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "internal-only", "name": "Internal Only" }))
|
||||
.await
|
||||
.json();
|
||||
let app_id = app["id"].as_str().unwrap();
|
||||
let script: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&with_app(
|
||||
app_id,
|
||||
json!({ "name": "x", "source": "#{ statusCode: 200, body: \"ok\" }" }),
|
||||
))
|
||||
.await
|
||||
.json();
|
||||
let id = script["id"].as_str().unwrap();
|
||||
let r = s
|
||||
.post(&format!("/api/v1/execute/{id}"))
|
||||
.json(&json!({}))
|
||||
.await;
|
||||
r.assert_status_ok();
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn duplicate_slug_creates_a_409(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
s.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "alpha", "name": "First" }))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
let r = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "alpha", "name": "Second" }))
|
||||
.await;
|
||||
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn reserved_slug_rejected(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
for bad in ["new", "api", "admin", "login"] {
|
||||
let r = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": bad, "name": "x" }))
|
||||
.await;
|
||||
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn slug_rename_keeps_old_as_redirect(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let app: Value = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "old-slug", "name": "x" }))
|
||||
.await
|
||||
.json();
|
||||
let id = app["id"].as_str().unwrap();
|
||||
s.patch(&format!("/api/v1/admin/apps/{id}"))
|
||||
.json(&json!({ "slug": "new-slug" }))
|
||||
.await
|
||||
.assert_status_ok();
|
||||
let resp: Value = s.get("/api/v1/admin/apps/old-slug").await.json();
|
||||
// The old slug resolves via history and surfaces `redirect_to`.
|
||||
assert_eq!(resp["redirect_to"], "new-slug");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn claiming_historical_slug_needs_force_takeover(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
// Set up a history row.
|
||||
let first: Value = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "soon-retired", "name": "x" }))
|
||||
.await
|
||||
.json();
|
||||
s.patch(&format!(
|
||||
"/api/v1/admin/apps/{}",
|
||||
first["id"].as_str().unwrap()
|
||||
))
|
||||
.json(&json!({ "slug": "kept" }))
|
||||
.await
|
||||
.assert_status_ok();
|
||||
|
||||
// Plain create against the retired slug → 409.
|
||||
let r = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "soon-retired", "name": "y" }))
|
||||
.await;
|
||||
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||
|
||||
// With force_takeover → 201.
|
||||
let r = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "soon-retired", "name": "y", "force_takeover": true }))
|
||||
.await;
|
||||
r.assert_status(axum::http::StatusCode::CREATED);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn shape_key_collision_rejected(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let a: Value = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "a", "name": "A" }))
|
||||
.await
|
||||
.json();
|
||||
let b: Value = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "b", "name": "B" }))
|
||||
.await
|
||||
.json();
|
||||
s.post(&format!(
|
||||
"/api/v1/admin/apps/{}/domains",
|
||||
a["id"].as_str().unwrap()
|
||||
))
|
||||
.json(&json!({ "pattern": "*.example.com" }))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
// Parameterized form should collide with wildcard form.
|
||||
let r = s
|
||||
.post(&format!(
|
||||
"/api/v1/admin/apps/{}/domains",
|
||||
b["id"].as_str().unwrap()
|
||||
))
|
||||
.json(&json!({ "pattern": "{tenant}.example.com" }))
|
||||
.await;
|
||||
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn delete_app_with_scripts_returns_409(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let app: Value = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "with-scripts", "name": "x" }))
|
||||
.await
|
||||
.json();
|
||||
let id = app["id"].as_str().unwrap();
|
||||
s.post("/api/v1/admin/scripts")
|
||||
.json(&with_app(id, json!({ "name": "s", "source": "1" })))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
let r = s.delete(&format!("/api/v1/admin/apps/{id}")).await;
|
||||
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn list_scripts_filtered_by_app(pool: PgPool) {
|
||||
let (s, default_id) = server_with_app(pool).await;
|
||||
let other: Value = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "filter-target", "name": "x" }))
|
||||
.await
|
||||
.json();
|
||||
let other_id = other["id"].as_str().unwrap();
|
||||
|
||||
s.post("/api/v1/admin/scripts")
|
||||
.json(&with_app(
|
||||
&default_id,
|
||||
json!({ "name": "in-default", "source": "1" }),
|
||||
))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
s.post("/api/v1/admin/scripts")
|
||||
.json(&with_app(
|
||||
other_id,
|
||||
json!({ "name": "in-other", "source": "1" }),
|
||||
))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
// Filter by id.
|
||||
let filtered: Vec<Value> = s
|
||||
.get(&format!("/api/v1/admin/scripts?app={other_id}"))
|
||||
.await
|
||||
.json();
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0]["name"], "in-other");
|
||||
|
||||
// Filter by slug.
|
||||
let filtered_by_slug: Vec<Value> = s
|
||||
.get("/api/v1/admin/scripts?app=filter-target")
|
||||
.await
|
||||
.json();
|
||||
assert_eq!(filtered_by_slug.len(), 1);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn execution_errors_are_still_logged(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "boom",
|
||||
"source": "1 / 0",
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.json();
|
||||
let id = created["id"].as_str().unwrap();
|
||||
|
||||
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}")]
|
||||
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 uuid::Uuid;
|
||||
|
||||
use crate::{RequestId, ScriptId};
|
||||
use crate::{AppId, RequestId, ScriptId};
|
||||
|
||||
/// One row in the `execution_logs` table. Same shape flows through the
|
||||
/// `ExecutionLogSink` trait and the `GET /scripts/{id}/logs` response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExecutionLog {
|
||||
pub id: Uuid,
|
||||
/// Owning app at the time of execution. Materialized at write time
|
||||
/// so a future "move script to another app" doesn't retag history.
|
||||
pub app_id: AppId,
|
||||
pub script_id: ScriptId,
|
||||
pub request_id: RequestId,
|
||||
|
||||
|
||||
@@ -50,3 +50,6 @@ macro_rules! id_type {
|
||||
id_type!(ScriptId);
|
||||
id_type!(ExecutionId);
|
||||
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
|
||||
//! entity, error roots, transport DTOs).
|
||||
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod error;
|
||||
pub mod execution_log;
|
||||
pub mod ids;
|
||||
@@ -14,9 +16,11 @@ pub mod script;
|
||||
pub mod validator;
|
||||
pub mod version;
|
||||
|
||||
pub use app::{App, AppDomain, DomainShape};
|
||||
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
|
||||
pub use error::Error;
|
||||
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 route::{HostKind, PathKind, Route};
|
||||
pub use sandbox::ScriptSandbox;
|
||||
|
||||
@@ -7,7 +7,7 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ScriptId;
|
||||
use crate::{AppId, ScriptId};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -40,6 +40,10 @@ pub enum PathKind {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Route {
|
||||
pub id: Uuid,
|
||||
/// Owning app. Always equals `scripts.app_id` for the bound script.
|
||||
/// Carried on the route row so the orchestrator can partition the
|
||||
/// route table without joining back to scripts on every refresh.
|
||||
pub app_id: AppId,
|
||||
pub script_id: ScriptId,
|
||||
|
||||
pub host_kind: HostKind,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{ScriptId, ScriptSandbox};
|
||||
use crate::{AppId, ScriptId, ScriptSandbox};
|
||||
|
||||
/// A user-uploaded Rhai script and its execution configuration.
|
||||
///
|
||||
@@ -11,6 +11,10 @@ use crate::{ScriptId, ScriptSandbox};
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Script {
|
||||
pub id: ScriptId,
|
||||
/// Owning app. Set on create, immutable thereafter — a "move to
|
||||
/// another app" is a copy+delete, not an in-place edit (snapshot
|
||||
/// semantics — see blueprint §11.5).
|
||||
pub app_id: AppId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub version: i32,
|
||||
|
||||
@@ -2,3 +2,9 @@
|
||||
build
|
||||
node_modules
|
||||
package-lock.json
|
||||
|
||||
# Playwright generated artifacts
|
||||
playwright-report
|
||||
test-results
|
||||
tests/e2e/.auth
|
||||
tests/e2e/.results
|
||||
|
||||
699
dashboard/package-lock.json
generated
699
dashboard/package-lock.json
generated
@@ -1,14 +1,26 @@
|
||||
{
|
||||
"name": "picloud-dashboard",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"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": {
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.17.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
@@ -23,7 +35,99 @@
|
||||
"svelte-check": "^4.1.4",
|
||||
"typescript": "^5.7.3",
|
||||
"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": {
|
||||
@@ -741,6 +845,63 @@
|
||||
"@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/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
@@ -1131,7 +1292,6 @@
|
||||
"integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||
@@ -1174,7 +1334,6 @@
|
||||
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
|
||||
"debug": "^4.4.1",
|
||||
@@ -1209,6 +1368,17 @@
|
||||
"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": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
@@ -1216,6 +1386,13 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -1236,7 +1413,6 @@
|
||||
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -1293,7 +1469,6 @@
|
||||
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.59.4",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
@@ -1401,7 +1576,6 @@
|
||||
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
@@ -1532,13 +1706,127 @@
|
||||
"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": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1606,6 +1894,16 @@
|
||||
"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": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@@ -1634,6 +1932,16 @@
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -1644,6 +1952,23 @@
|
||||
"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": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -1661,6 +1986,16 @@
|
||||
"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": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
@@ -1687,6 +2022,21 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -1724,6 +2074,12 @@
|
||||
"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": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -1770,6 +2126,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": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -1794,6 +2160,13 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -1855,7 +2228,6 @@
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -2082,6 +2454,16 @@
|
||||
"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": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
@@ -2092,6 +2474,16 @@
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -2310,6 +2702,13 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
@@ -2425,6 +2824,13 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -2584,6 +2990,23 @@
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2597,7 +3020,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2605,6 +3027,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
@@ -2625,7 +3094,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -2759,7 +3227,6 @@
|
||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -2923,6 +3390,13 @@
|
||||
"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": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||
@@ -2948,6 +3422,20 @@
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
@@ -2961,6 +3449,25 @@
|
||||
"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": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@@ -2980,7 +3487,6 @@
|
||||
"integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -3058,6 +3564,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": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
@@ -3075,6 +3595,36 @@
|
||||
"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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
@@ -3117,7 +3667,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -3180,7 +3729,6 @@
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -3250,6 +3798,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": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
|
||||
@@ -3270,6 +3841,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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -3286,6 +3936,23 @@
|
||||
"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": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picloud-dashboard",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -10,14 +10,19 @@
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint ."
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:install": "playwright install --with-deps chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@types/node": "^22.10.5",
|
||||
"@sveltejs/kit": "^2.17.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@types/node": "^22.10.5",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
@@ -28,9 +33,21 @@
|
||||
"svelte-check": "^4.1.4",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^6.0.7"
|
||||
"vite": "^6.0.7",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"overrides": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
51
dashboard/playwright.config.ts
Normal file
51
dashboard/playwright.config.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const DASHBOARD_PORT = Number(process.env.PICLOUD_DASHBOARD_PORT ?? 5173);
|
||||
// baseURL is the origin only — the SvelteKit dashboard is mounted at
|
||||
// `/admin` (svelte.config.js paths.base), so tests use full paths like
|
||||
// `/admin/login` rather than relying on baseURL path resolution.
|
||||
const DASHBOARD_BASE = process.env.E2E_BASE_URL ?? `http://localhost:${DASHBOARD_PORT}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
outputDir: './tests/e2e/.results',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
// Local: 1 retry to absorb dev-server warmup flakiness. CI: 2.
|
||||
retries: process.env.CI ? 2 : 1,
|
||||
// Cap at 4 workers locally to keep the shared Vite dev server
|
||||
// from getting stampeded during cold-start compiles.
|
||||
workers: process.env.CI ? 2 : 4,
|
||||
reporter: process.env.CI ? [['html'], ['github']] : 'html',
|
||||
globalSetup: './tests/e2e/global-setup.ts',
|
||||
expect: { timeout: 5_000 },
|
||||
use: {
|
||||
baseURL: DASHBOARD_BASE,
|
||||
actionTimeout: 10_000,
|
||||
navigationTimeout: 30_000,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure'
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: path.join(__dirname, 'tests/e2e/.auth/admin.json')
|
||||
}
|
||||
}
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: `http://localhost:${DASHBOARD_PORT}/admin/`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
timeout: 60_000
|
||||
}
|
||||
});
|
||||
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
|
||||
// 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 {
|
||||
max_operations?: number;
|
||||
max_string_size?: number;
|
||||
@@ -16,6 +23,7 @@ export interface ScriptSandbox {
|
||||
|
||||
export interface Script {
|
||||
id: string;
|
||||
app_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
version: number;
|
||||
@@ -27,11 +35,71 @@ export interface Script {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface App {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type 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 PathKind = 'exact' | 'prefix' | 'param';
|
||||
|
||||
export interface Route {
|
||||
id: string;
|
||||
app_id: string;
|
||||
script_id: string;
|
||||
host_kind: HostKind;
|
||||
host: string;
|
||||
@@ -101,6 +169,7 @@ export interface ExecutionLog {
|
||||
}
|
||||
|
||||
export interface CreateScriptInput {
|
||||
app_id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
source: string;
|
||||
@@ -134,12 +203,26 @@ export class ApiError extends Error {
|
||||
}
|
||||
|
||||
async function adminRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) }
|
||||
});
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json',
|
||||
...((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 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) {
|
||||
const message =
|
||||
(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 = {
|
||||
health: () => fetch('/healthz').then((r) => r.text()),
|
||||
|
||||
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: {
|
||||
listForScript: (scriptId: string) =>
|
||||
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
|
||||
@@ -173,20 +405,23 @@ export const api = {
|
||||
}),
|
||||
remove: (routeId: string) =>
|
||||
adminRequest<null>(`/api/v1/admin/routes/${routeId}`, { method: 'DELETE' }),
|
||||
check: (input: RouteInput) =>
|
||||
check: (appId: string, input: RouteInput) =>
|
||||
adminRequest<CheckRouteResponse>('/api/v1/admin/routes:check', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
body: JSON.stringify({ ...input, app_id: appId })
|
||||
}),
|
||||
match: (url: string, method = 'GET') =>
|
||||
match: (appId: string, url: string, method = 'GET') =>
|
||||
adminRequest<MatchRouteResponse>('/api/v1/admin/routes:match', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url, method })
|
||||
body: JSON.stringify({ app_id: appId, url, method })
|
||||
})
|
||||
},
|
||||
|
||||
scripts: {
|
||||
list: () => adminRequest<Script[]>('/api/v1/admin/scripts'),
|
||||
list: (opts: { app?: string } = {}) => {
|
||||
const qs = opts.app ? `?app=${encodeURIComponent(opts.app)}` : '';
|
||||
return adminRequest<Script[]>(`/api/v1/admin/scripts${qs}`);
|
||||
},
|
||||
get: (id: string) => adminRequest<Script>(`/api/v1/admin/scripts/${id}`),
|
||||
create: (input: CreateScriptInput) =>
|
||||
adminRequest<Script>('/api/v1/admin/scripts', {
|
||||
@@ -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 (
|
||||
id: string,
|
||||
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
|
||||
* 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';
|
||||
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">
|
||||
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 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>
|
||||
|
||||
<div class="shell">
|
||||
<header>
|
||||
<a href={base + '/'} class="brand">PiCloud</a>
|
||||
<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>
|
||||
<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>
|
||||
<main>
|
||||
{#if booting}
|
||||
<p class="boot">Loading…</p>
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -45,6 +101,11 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
@@ -55,6 +116,50 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
@@ -63,4 +168,8 @@
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.boot {
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,249 +1,20 @@
|
||||
<script lang="ts">
|
||||
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}';
|
||||
|
||||
let scripts = $state<Script[] | null>(null);
|
||||
let listError = $state<string | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
let showCreate = $state(false);
|
||||
let createName = $state('');
|
||||
let createDescription = $state('');
|
||||
let createSource = $state(SAMPLE_SOURCE);
|
||||
let creating = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
listError = null;
|
||||
try {
|
||||
scripts = await api.scripts.list();
|
||||
} catch (e) {
|
||||
listError = e instanceof Error ? e.message : String(e);
|
||||
scripts = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreate(event: Event) {
|
||||
event.preventDefault();
|
||||
creating = true;
|
||||
createError = null;
|
||||
try {
|
||||
await api.scripts.create({
|
||||
name: createName.trim(),
|
||||
description: createDescription.trim() || null,
|
||||
source: createSource
|
||||
});
|
||||
showCreate = false;
|
||||
createName = '';
|
||||
createDescription = '';
|
||||
createSource = SAMPLE_SOURCE;
|
||||
await load();
|
||||
} catch (e) {
|
||||
createError = e instanceof Error ? e.message : String(e);
|
||||
if (e instanceof ApiError && e.status === 422) {
|
||||
createError = `Syntax error: ${createError}`;
|
||||
}
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void load();
|
||||
// Dashboard entry: always lands on the apps list now (multi-app
|
||||
// scoping makes "scripts at root" no longer meaningful — every
|
||||
// script lives inside an app).
|
||||
onMount(() => {
|
||||
void goto(`${base}/apps`, { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<header class="page-header">
|
||||
<h1>Scripts</h1>
|
||||
<button type="button" onclick={() => (showCreate = !showCreate)}>
|
||||
{showCreate ? 'Cancel' : 'New script'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if showCreate}
|
||||
<form class="create-form" onsubmit={submitCreate}>
|
||||
<div class="row">
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input bind:value={createName} required minlength="1" placeholder="echo" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<input bind:value={createDescription} placeholder="optional" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="full">
|
||||
<span>Source (Rhai)</span>
|
||||
<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>
|
||||
<p class="muted">Redirecting…</p>
|
||||
|
||||
<style>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #38bdf8;
|
||||
color: #0b1220;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.error {
|
||||
border: 1px solid #b91c1c;
|
||||
background: #450a0a;
|
||||
color: #fecaca;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.create-form {
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.create-form .row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.create-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.create-form label.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.create-form input,
|
||||
.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>
|
||||
|
||||
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 {
|
||||
api,
|
||||
ApiError,
|
||||
type AppDomain,
|
||||
type ExecutionLog,
|
||||
type Route,
|
||||
type RouteInput,
|
||||
@@ -12,7 +13,26 @@
|
||||
type VersionInfo
|
||||
} from '$lib/api';
|
||||
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.
|
||||
let id = $derived(page.params.id ?? '');
|
||||
@@ -25,6 +45,9 @@
|
||||
let scriptLoading = $state(true);
|
||||
let info = $state<VersionInfo | null>(null);
|
||||
|
||||
let appSlug = $state<string | null>(null);
|
||||
let appDomains = $state<AppDomain[]>([]);
|
||||
|
||||
async function loadScript() {
|
||||
scriptLoading = true;
|
||||
scriptError = null;
|
||||
@@ -35,6 +58,23 @@
|
||||
editableDescription = script.description ?? '';
|
||||
editableTimeout = script.timeout_seconds;
|
||||
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) {
|
||||
scriptError = e instanceof Error ? e.message : String(e);
|
||||
script = null;
|
||||
@@ -55,6 +95,17 @@
|
||||
let editableSource = $state('');
|
||||
let savingSource = $state(false);
|
||||
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() {
|
||||
if (!script) return;
|
||||
@@ -72,7 +123,28 @@
|
||||
|
||||
let testBody = $state('{}');
|
||||
let testHeaders = $state('{}');
|
||||
let testBodyFormatError = $state<string | null>(null);
|
||||
let testHeadersFormatError = $state<string | null>(null);
|
||||
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<{
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
@@ -120,12 +192,14 @@
|
||||
let routesLoading = $state(true);
|
||||
|
||||
let showAddRoute = $state(false);
|
||||
let newRoutePath = $state('');
|
||||
let newRoutePath = $state('/');
|
||||
let newRoutePathKind = $state<'exact' | 'prefix' | 'param'>('exact');
|
||||
let newRouteHost = $state('');
|
||||
let newRouteHostKind = $state<'any' | 'strict' | 'wildcard'>('any');
|
||||
// Host input is free-form; the kind is derived from what the user
|
||||
// typed (see `parsedHost` below). Default `*` = Any, matching the
|
||||
// canonical display form for an unrestricted host.
|
||||
let newRouteHost = $state('*');
|
||||
let newRouteMethod = $state('');
|
||||
let routeKindAutoUpdate = $state(true);
|
||||
let pathKindAutoUpdate = $state(true);
|
||||
let creatingRoute = $state(false);
|
||||
let createRouteError = $state<string | null>(null);
|
||||
|
||||
@@ -133,17 +207,17 @@
|
||||
let previewMethod = $state('GET');
|
||||
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(() => {
|
||||
if (routeKindAutoUpdate) {
|
||||
if (pathKindAutoUpdate) {
|
||||
newRoutePathKind = guessPathKind(newRoutePath);
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (routeKindAutoUpdate) {
|
||||
newRouteHostKind = guessHostKind(newRouteHost);
|
||||
}
|
||||
});
|
||||
|
||||
let parsedHost = $derived(parseHostInput(newRouteHost));
|
||||
let hostCheck = $derived(checkHostAgainstClaims(parsedHost, appDomains));
|
||||
let hostDatalistId = 'route-host-suggestions';
|
||||
let suggestions = $derived(hostSuggestions(appDomains));
|
||||
|
||||
let pathKindWarning = $derived(
|
||||
newRoutePath.trim() ? pathKindMismatchWarning(newRoutePath, newRoutePathKind) : null
|
||||
@@ -167,18 +241,18 @@
|
||||
createRouteError = null;
|
||||
try {
|
||||
const input: RouteInput = {
|
||||
host_kind: newRouteHostKind,
|
||||
host: newRouteHostKind === 'any' ? '' : newRouteHost.trim(),
|
||||
host_kind: parsedHost.kind,
|
||||
host: parsedHost.host,
|
||||
path_kind: newRoutePathKind,
|
||||
path: newRoutePath.trim(),
|
||||
method: newRouteMethod.trim() || null
|
||||
};
|
||||
await api.routes.create(id, input);
|
||||
showAddRoute = false;
|
||||
newRoutePath = '';
|
||||
newRouteHost = '';
|
||||
newRoutePath = '/';
|
||||
newRouteHost = '*';
|
||||
newRouteMethod = '';
|
||||
routeKindAutoUpdate = true;
|
||||
pathKindAutoUpdate = true;
|
||||
await loadRoutes();
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
@@ -206,8 +280,9 @@
|
||||
|
||||
async function runPreview() {
|
||||
previewResult = null;
|
||||
if (!script) return;
|
||||
try {
|
||||
const r = await api.routes.match(previewUrl, previewMethod);
|
||||
const r = await api.routes.match(script.app_id, previewUrl, previewMethod);
|
||||
if (r.matched) {
|
||||
const ours = r.matched.script_id === id;
|
||||
const tag = ours ? '✓ matches THIS script' : '⚠ matches a DIFFERENT script';
|
||||
@@ -323,6 +398,13 @@
|
||||
{:else if script}
|
||||
<header class="page-header">
|
||||
<div>
|
||||
{#if appSlug}
|
||||
<div class="breadcrumb">
|
||||
<a href="{base}/apps">Apps</a> /
|
||||
<a href="{base}/apps/{appSlug}">{appSlug}</a> / Scripts /
|
||||
<code>{script.name}</code>
|
||||
</div>
|
||||
{/if}
|
||||
<h1>{script.name}</h1>
|
||||
<p class="muted">
|
||||
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
|
||||
@@ -351,8 +433,16 @@
|
||||
{#if tab === 'edit'}
|
||||
<div class="grid">
|
||||
<section class="card">
|
||||
<header class="editor-header">
|
||||
<h2>Source</h2>
|
||||
<textarea bind:value={editableSource} rows="14" spellcheck="false"></textarea>
|
||||
<button type="button" class="ghost small" onclick={formatRhaiSource}>
|
||||
Format
|
||||
</button>
|
||||
</header>
|
||||
<CodeEditor bind:value={editableSource} language="rhai" minHeight="22rem" />
|
||||
{#if rhaiFormatError}
|
||||
<div class="error inline">{rhaiFormatError}</div>
|
||||
{/if}
|
||||
{#if saveSourceError}
|
||||
<div class="error inline">{saveSourceError}</div>
|
||||
{/if}
|
||||
@@ -369,14 +459,30 @@
|
||||
|
||||
<section class="card">
|
||||
<h2>Test invoke</h2>
|
||||
<label>
|
||||
<div class="json-block">
|
||||
<header class="json-header">
|
||||
<span>Request body (JSON)</span>
|
||||
<textarea bind:value={testBody} rows="5" spellcheck="false"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<button type="button" class="ghost small" onclick={formatTestBody}>
|
||||
Format
|
||||
</button>
|
||||
</header>
|
||||
<CodeEditor bind:value={testBody} language="json" minHeight="9rem" />
|
||||
{#if testBodyFormatError}
|
||||
<div class="error inline">{testBodyFormatError}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="json-block">
|
||||
<header class="json-header">
|
||||
<span>Headers (JSON object)</span>
|
||||
<textarea bind:value={testHeaders} rows="3" spellcheck="false"></textarea>
|
||||
</label>
|
||||
<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">
|
||||
<button type="button" onclick={invoke} disabled={testInProgress}>
|
||||
{testInProgress ? 'Running…' : 'Send'}
|
||||
@@ -415,7 +521,7 @@
|
||||
<span>Path</span>
|
||||
<input
|
||||
bind:value={newRoutePath}
|
||||
oninput={() => (routeKindAutoUpdate = true)}
|
||||
oninput={() => (pathKindAutoUpdate = true)}
|
||||
placeholder="/greet, /greet/:name, /webhooks/*"
|
||||
required
|
||||
autocomplete="off"
|
||||
@@ -426,7 +532,7 @@
|
||||
<span>Path kind</span>
|
||||
<select
|
||||
bind:value={newRoutePathKind}
|
||||
onchange={() => (routeKindAutoUpdate = false)}
|
||||
onchange={() => (pathKindAutoUpdate = false)}
|
||||
>
|
||||
<option value="exact">exact</option>
|
||||
<option value="param">param</option>
|
||||
@@ -445,31 +551,43 @@
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>
|
||||
<span>Host kind</span>
|
||||
<select
|
||||
bind:value={newRouteHostKind}
|
||||
onchange={() => (routeKindAutoUpdate = false)}
|
||||
>
|
||||
<option value="any">ANY</option>
|
||||
<option value="strict">strict</option>
|
||||
<option value="wildcard">wildcard</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class:disabled={newRouteHostKind === 'any'}>
|
||||
<span>Host</span>
|
||||
<label class="full">
|
||||
<span class="host-label">
|
||||
Host
|
||||
<span class="kind-chip kind-{parsedHost.kind}">
|
||||
{parsedHost.kind}
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
bind:value={newRouteHost}
|
||||
oninput={() => (routeKindAutoUpdate = true)}
|
||||
disabled={newRouteHostKind === 'any'}
|
||||
placeholder={newRouteHostKind === 'wildcard'
|
||||
? '*.example.com'
|
||||
: 'sub.example.com'}
|
||||
placeholder="* · app.example.com · *.example.com"
|
||||
list={hostDatalistId}
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<datalist id={hostDatalistId}>
|
||||
{#each suggestions as s (s)}
|
||||
<option value={s}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
<small class="muted">
|
||||
<code>*</code> = any host claimed by this app ·
|
||||
<code>*.foo.com</code> = wildcard · <code>foo.com</code> =
|
||||
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}
|
||||
<div class="warning inline">{pathKindWarning}</div>
|
||||
{/if}
|
||||
@@ -687,6 +805,23 @@
|
||||
align-items: flex-start;
|
||||
margin: 1rem 0 1.5rem;
|
||||
}
|
||||
.breadcrumb {
|
||||
font-size: 0.875rem;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.breadcrumb a {
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
}
|
||||
.breadcrumb a:hover {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.breadcrumb code {
|
||||
background: #1e293b;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
@@ -727,6 +862,32 @@
|
||||
color: #94a3b8;
|
||||
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 {
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
@@ -798,7 +959,6 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
textarea,
|
||||
input,
|
||||
select {
|
||||
background: #0b1220;
|
||||
@@ -811,12 +971,6 @@
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
textarea {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
resize: vertical;
|
||||
}
|
||||
input:disabled,
|
||||
select:disabled {
|
||||
opacity: 0.5;
|
||||
@@ -828,9 +982,6 @@
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
label.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
label.full {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -947,6 +1098,31 @@
|
||||
background: #581c87;
|
||||
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 {
|
||||
color: #fbbf24;
|
||||
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>
|
||||
75
dashboard/tests/e2e/README.md
Normal file
75
dashboard/tests/e2e/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Dashboard E2E tests
|
||||
|
||||
Browser-driven tests for the PiCloud dashboard, powered by [Playwright].
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The tests drive a real dashboard against a real backend. Bring up both
|
||||
before running:
|
||||
|
||||
```sh
|
||||
# 1. Postgres
|
||||
docker compose up -d postgres
|
||||
|
||||
# 2. Backend (port 18080 matches dashboard/vite.config.ts dev proxy)
|
||||
PICLOUD_BIND=127.0.0.1:18080 \
|
||||
PICLOUD_ADMIN_USERNAME=admin \
|
||||
PICLOUD_ADMIN_PASSWORD=admin \
|
||||
DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \
|
||||
cargo run -p picloud
|
||||
|
||||
# 3. Browser binaries (one-time, ~200 MB)
|
||||
cd dashboard && npm run test:e2e:install
|
||||
```
|
||||
|
||||
The Vite dev server is started automatically by Playwright's `webServer`
|
||||
config — you do not need to run `npm run dev` yourself.
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
cd dashboard
|
||||
npm run test:e2e # headless, full suite
|
||||
npm run test:e2e:ui # interactive UI runner
|
||||
npx playwright test smoke # run a single spec
|
||||
npx playwright show-report
|
||||
```
|
||||
|
||||
## Env vars
|
||||
|
||||
| Var | Default | Notes |
|
||||
| ------------------------ | ------------------------ | ----------------------------------------------------------------- |
|
||||
| `E2E_BASE_URL` | `http://localhost:5173` | Origin tests navigate against (dashboard is mounted at `/admin`). |
|
||||
| `E2E_API_BASE` | `http://127.0.0.1:18080` | Backend used by globalSetup health probe + admin login. |
|
||||
| `E2E_DASHBOARD_ORIGIN` | `http://localhost:5173` | Used to seed `localStorage` during globalSetup. |
|
||||
| `E2E_ADMIN_USERNAME` | `admin` | Bootstrap admin to log in as. |
|
||||
| `E2E_ADMIN_PASSWORD` | `admin` | Match `PICLOUD_ADMIN_PASSWORD` above. |
|
||||
| `PICLOUD_DASHBOARD_PORT` | `5173` | Dev server port — picked up by both Vite and Playwright. |
|
||||
|
||||
## How isolation works
|
||||
|
||||
Tests share one backend + one Postgres. To avoid cross-test interference:
|
||||
|
||||
- A shared bootstrap admin session is captured once in
|
||||
`tests/e2e/.auth/admin.json` (gitignored) and reused by every test via
|
||||
`storageState`.
|
||||
- Each test creates resources with a unique slug / username produced by
|
||||
`fixtures/ids.ts` (`e2e-<prefix>-w<worker>-<random>`).
|
||||
- Each test registers cleanup via `fixtures/cleanup.ts` and tears down
|
||||
in `afterEach`. Cleanup is best-effort: a missing resource doesn't
|
||||
fail the suite, so a test can pre-delete and still register the entry.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
tests/e2e/
|
||||
global-setup.ts # health probe + admin login + storageState seed
|
||||
smoke.spec.ts # A.5 smoke
|
||||
fixtures/
|
||||
auth.ts # UI login/logout helpers (for login-flow specs)
|
||||
api.ts # bearer-token-backed APIRequestContext
|
||||
ids.ts # unique slug/username generators (test-fixture)
|
||||
cleanup.ts # afterEach resource teardown
|
||||
```
|
||||
|
||||
[Playwright]: https://playwright.dev
|
||||
226
dashboard/tests/e2e/apps/apps.spec.ts
Normal file
226
dashboard/tests/e2e/apps/apps.spec.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { test } from '../fixtures/ids';
|
||||
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||
import { adminApi } from '../fixtures/api';
|
||||
|
||||
// Phase B2 — Apps Lifecycle. Create, view, edit, delete, plus the
|
||||
// historical-slug takeover flow and adversarial inputs.
|
||||
|
||||
const cleanup = new CleanupRegistry();
|
||||
test.afterEach(async () => {
|
||||
await cleanup.run();
|
||||
});
|
||||
|
||||
function failOnDialog(page: Page): void {
|
||||
page.on('dialog', async (dialog) => {
|
||||
await dialog.dismiss();
|
||||
throw new Error(`Unexpected browser dialog fired: ${dialog.type()} — "${dialog.message()}"`);
|
||||
});
|
||||
}
|
||||
|
||||
async function openCreateForm(page: Page): Promise<void> {
|
||||
await page.goto('/admin/apps');
|
||||
await page.getByRole('button', { name: 'New app' }).click();
|
||||
}
|
||||
|
||||
async function createApp(
|
||||
page: Page,
|
||||
opts: { name: string; slug: string; description?: string }
|
||||
): Promise<void> {
|
||||
await openCreateForm(page);
|
||||
await page.getByLabel('Name').fill(opts.name);
|
||||
// Clear the auto-derived slug and type the test-controlled one so
|
||||
// we know exactly which slug we'll register for cleanup.
|
||||
const slugInput = page.getByLabel('Slug');
|
||||
await slugInput.fill('');
|
||||
await slugInput.fill(opts.slug);
|
||||
if (opts.description !== undefined) {
|
||||
await page.getByLabel('Description').fill(opts.description);
|
||||
}
|
||||
await page.getByRole('button', { name: 'Create app' }).click();
|
||||
}
|
||||
|
||||
test.describe('B2 apps lifecycle', () => {
|
||||
test('create app: slug auto-derives from name, app appears in list', async ({
|
||||
page,
|
||||
uniqueSlug
|
||||
}) => {
|
||||
const slug = uniqueSlug('lifecycle');
|
||||
const displayName = slug.replace(/-/g, ' ');
|
||||
|
||||
await openCreateForm(page);
|
||||
await page.getByLabel('Name').fill(displayName);
|
||||
// Slug auto-derives — the input value is set, no extra typing.
|
||||
const slugInput = page.getByLabel('Slug');
|
||||
await expect(slugInput).toHaveValue(slug);
|
||||
await page.getByRole('button', { name: 'Create app' }).click();
|
||||
cleanup.app(slug);
|
||||
|
||||
await expect(page.getByRole('link', { name: new RegExp(displayName) })).toBeVisible();
|
||||
});
|
||||
|
||||
test('edit name + description in settings persists across reload', async ({
|
||||
page,
|
||||
uniqueSlug
|
||||
}) => {
|
||||
const slug = uniqueSlug('edit');
|
||||
await createApp(page, { name: slug, slug });
|
||||
cleanup.app(slug);
|
||||
|
||||
await page.getByRole('link', { name: new RegExp(slug) }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/admin/apps/${slug}$`));
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
|
||||
const newName = `${slug} renamed`;
|
||||
const newDesc = 'updated description';
|
||||
await page.getByLabel('Name').fill(newName);
|
||||
await page.getByLabel('Description').fill(newDesc);
|
||||
await page.getByRole('button', { name: 'Save changes' }).click();
|
||||
// Wait for the network round-trip to settle — the busy label
|
||||
// flips back to "Save changes" when done.
|
||||
await expect(page.getByRole('button', { name: 'Save changes' })).toBeEnabled();
|
||||
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
await expect(page.getByLabel('Name')).toHaveValue(newName);
|
||||
await expect(page.getByLabel('Description')).toHaveValue(newDesc);
|
||||
});
|
||||
|
||||
test('delete: wrong phrase keeps button disabled, right phrase removes app', async ({
|
||||
page,
|
||||
uniqueSlug
|
||||
}) => {
|
||||
const slug = uniqueSlug('delete');
|
||||
await createApp(page, { name: slug, slug });
|
||||
cleanup.app(slug); // belt-and-braces; cleanup is best-effort
|
||||
|
||||
await page.getByRole('link', { name: new RegExp(slug) }).click();
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
await page.getByRole('button', { name: 'Delete app' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
const phraseInput = dialog.getByRole('textbox');
|
||||
const confirmBtn = dialog.getByRole('button', { name: 'Delete app' });
|
||||
await expect(confirmBtn).toBeDisabled();
|
||||
|
||||
await phraseInput.fill('wrong-phrase');
|
||||
await expect(confirmBtn).toBeDisabled();
|
||||
|
||||
await phraseInput.fill(slug);
|
||||
await expect(confirmBtn).toBeEnabled();
|
||||
await confirmBtn.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/admin\/apps$/);
|
||||
await expect(page.getByRole('link', { name: new RegExp(slug) })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('historical slug warning surfaces; force-takeover succeeds', async ({
|
||||
page,
|
||||
uniqueSlug
|
||||
}) => {
|
||||
const origSlug = uniqueSlug('hist');
|
||||
const renamedSlug = `${origSlug}-r`;
|
||||
|
||||
// Historical-redirect rows are created on RENAME, not on
|
||||
// delete. So: create app, rename it, original slug now lives
|
||||
// in app_slug_history.
|
||||
const api = await adminApi();
|
||||
try {
|
||||
const created = await api.post('/api/v1/admin/apps', {
|
||||
data: { slug: origSlug, name: origSlug }
|
||||
});
|
||||
expect(created.ok()).toBe(true);
|
||||
const renamed = await api.patch(
|
||||
`/api/v1/admin/apps/${encodeURIComponent(origSlug)}`,
|
||||
{ data: { slug: renamedSlug } }
|
||||
);
|
||||
expect(renamed.ok()).toBe(true);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
cleanup.app(renamedSlug); // the renamed app still exists
|
||||
|
||||
await openCreateForm(page);
|
||||
await page.getByLabel('Name').fill(origSlug);
|
||||
await page.getByLabel('Slug').fill('');
|
||||
await page.getByLabel('Slug').fill(origSlug);
|
||||
await page.getByRole('button', { name: 'Create app' }).click();
|
||||
|
||||
await expect(page.locator('.warning')).toBeVisible();
|
||||
await expect(page.locator('.warning')).toContainText(/previously redirected/i);
|
||||
await page.getByRole('button', { name: /claim slug anyway/i }).click();
|
||||
cleanup.app(origSlug); // the takeover created a new app
|
||||
|
||||
await expect(page.getByRole('link', { name: new RegExp(origSlug) })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('B2 apps adversarial', () => {
|
||||
test('slug with uppercase + spaces is normalized in-place', async ({ page, uniqueSlug }) => {
|
||||
const base = uniqueSlug('norm');
|
||||
await openCreateForm(page);
|
||||
await page.getByLabel('Name').fill(base);
|
||||
const slugInput = page.getByLabel('Slug');
|
||||
await slugInput.fill('');
|
||||
// Simulate the user typing/pasting an invalid slug. The
|
||||
// oninput handler runs slugify() and rewrites the input value.
|
||||
await slugInput.fill(` Hello WORLD ${base}!`);
|
||||
await expect(slugInput).toHaveValue(`hello-world-${base}`);
|
||||
});
|
||||
|
||||
test('xss in name and description renders as text everywhere', async ({ page, uniqueSlug }) => {
|
||||
failOnDialog(page);
|
||||
const slug = uniqueSlug('xss');
|
||||
const payload = '<img src=x onerror=alert(1)><script>window.__xss=true;</script>';
|
||||
|
||||
await createApp(page, { name: payload, slug, description: payload });
|
||||
cleanup.app(slug);
|
||||
|
||||
// List page — the link's accessible name contains the literal
|
||||
// payload text, not the parsed HTML.
|
||||
await expect(page.getByRole('link', { name: new RegExp('img src=x') })).toBeVisible();
|
||||
|
||||
// Detail page — open it; payload renders in the breadcrumb /
|
||||
// header as text only.
|
||||
await page.goto(`/admin/apps/${slug}`);
|
||||
const xssRan = await page.evaluate(
|
||||
() => (window as unknown as { __xss?: boolean }).__xss === true
|
||||
);
|
||||
expect(xssRan).toBe(false);
|
||||
expect(await page.locator('script:has-text("__xss")').count()).toBe(0);
|
||||
});
|
||||
|
||||
test('very long name does not crash the dashboard', async ({ page, uniqueSlug }) => {
|
||||
// The backend currently has no name length cap; the dashboard
|
||||
// just needs to keep rendering when handed an unusually long
|
||||
// value. Guards against layout / locator regressions when a
|
||||
// future test or user creates an oversized app.
|
||||
const slug = uniqueSlug('long');
|
||||
const longName = 'A'.repeat(10_000);
|
||||
|
||||
await openCreateForm(page);
|
||||
await page.getByLabel('Name').fill(longName);
|
||||
await page.getByLabel('Slug').fill('');
|
||||
await page.getByLabel('Slug').fill(slug);
|
||||
await page.getByRole('button', { name: 'Create app' }).click();
|
||||
|
||||
const errorVisible = await page
|
||||
.locator('.create-form .error')
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (errorVisible) {
|
||||
// Server rejected — fine, no cleanup needed.
|
||||
await expect(page.getByRole('link', { name: new RegExp(slug) })).toHaveCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Server accepted — confirm the dashboard still renders and is
|
||||
// navigable. Detail page must load too.
|
||||
cleanup.app(slug);
|
||||
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
|
||||
await page.goto(`/admin/apps/${slug}`);
|
||||
await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
118
dashboard/tests/e2e/auth/auth.spec.ts
Normal file
118
dashboard/tests/e2e/auth/auth.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { loginAsAdmin, logout } from '../fixtures/auth';
|
||||
|
||||
// Phase B1 — Auth & Navigation. Every interaction with the login form
|
||||
// and the layout-level redirects, plus the obvious adversarial inputs.
|
||||
|
||||
const VALID_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||
const VALID_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
|
||||
|
||||
function failOnDialog(page: Page): void {
|
||||
page.on('dialog', async (dialog) => {
|
||||
await dialog.dismiss();
|
||||
throw new Error(`Unexpected browser dialog fired: ${dialog.type()} — "${dialog.message()}"`);
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('B1 auth — unauthenticated', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('valid credentials land on the apps list', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await expect(page.getByRole('heading', { name: 'Apps', level: 1 })).toBeVisible();
|
||||
});
|
||||
|
||||
test('wrong password shows an inline error and stays on /login', async ({ page }) => {
|
||||
await page.goto('/admin/login');
|
||||
await page.getByLabel('Username').fill(VALID_USERNAME);
|
||||
await page.getByLabel('Password').fill('definitely-not-the-password');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
const error = page.locator('.error');
|
||||
await expect(error).toBeVisible();
|
||||
await expect(error).not.toHaveText('');
|
||||
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||
// localStorage must remain empty — a failed login should not
|
||||
// leak a session token.
|
||||
const token = await page.evaluate(() => localStorage.getItem('picloud.admin.token'));
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
test('empty submit is blocked by the browser and does not navigate', async ({ page }) => {
|
||||
await page.goto('/admin/login');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
// HTML5 validation prevents submission; URL is unchanged and the
|
||||
// username input is reported invalid.
|
||||
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||
const usernameInvalid = await page
|
||||
.getByLabel('Username')
|
||||
.evaluate((el: HTMLInputElement) => !el.validity.valid);
|
||||
expect(usernameInvalid).toBe(true);
|
||||
await expect(page.locator('.error')).toBeHidden();
|
||||
});
|
||||
|
||||
test('visiting an authed route redirects to /login', async ({ page }) => {
|
||||
await page.goto('/admin/apps');
|
||||
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||
await expect(page.getByLabel('Username')).toBeVisible();
|
||||
});
|
||||
|
||||
test('password field is type=password (no plaintext echo)', async ({ page }) => {
|
||||
await page.goto('/admin/login');
|
||||
await expect(page.getByLabel('Password')).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
test('xss payload in username is escaped and does not execute', async ({ page }) => {
|
||||
failOnDialog(page);
|
||||
const payload = '<script>window.__xss = true;</script><img src=x onerror=alert(1)>';
|
||||
|
||||
await page.goto('/admin/login');
|
||||
await page.getByLabel('Username').fill(payload);
|
||||
await page.getByLabel('Password').fill('whatever');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
// Whatever the API does with that input, the page must remain
|
||||
// safe: no script tag injected into the DOM, no global side
|
||||
// effect, and a visible error (since the credentials don't
|
||||
// match any user).
|
||||
await expect(page.locator('.error')).toBeVisible();
|
||||
const xssRan = await page.evaluate(
|
||||
() => (window as unknown as { __xss?: boolean }).__xss === true
|
||||
);
|
||||
expect(xssRan).toBe(false);
|
||||
const injectedScript = await page.locator('script:has-text("__xss")').count();
|
||||
expect(injectedScript).toBe(0);
|
||||
// The form must still be functional after the rejected attempt.
|
||||
await page.getByLabel('Username').fill('');
|
||||
await page.getByLabel('Username').fill(VALID_USERNAME);
|
||||
await page.getByLabel('Password').fill('');
|
||||
await page.getByLabel('Password').fill(VALID_PASSWORD);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await expect(page).toHaveURL(/\/admin\/apps$/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('B1 auth — authenticated', () => {
|
||||
test('visiting /login while signed in bounces to /apps', async ({ page }) => {
|
||||
await page.goto('/admin/login');
|
||||
await expect(page).toHaveURL(/\/admin\/apps$/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('B1 auth — logout', () => {
|
||||
// Logout must NOT use the shared storageState token, or it would
|
||||
// invalidate the session every other test relies on. Each run
|
||||
// here logs in fresh so its session is disposable.
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('logout clears the session and lands on /login', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await expect(page.getByRole('heading', { name: 'Apps', level: 1 })).toBeVisible();
|
||||
await logout(page);
|
||||
const token = await page.evaluate(() => localStorage.getItem('picloud.admin.token'));
|
||||
expect(token).toBeNull();
|
||||
// And the authed area is now gated again.
|
||||
await page.goto('/admin/apps');
|
||||
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||
});
|
||||
});
|
||||
47
dashboard/tests/e2e/fixtures/api.ts
Normal file
47
dashboard/tests/e2e/fixtures/api.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { request, type APIRequestContext } from '@playwright/test';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
||||
const STATE_PATH = path.join(__dirname, '..', '.auth', 'admin.json');
|
||||
|
||||
interface StoredState {
|
||||
origins: Array<{
|
||||
origin: string;
|
||||
localStorage: Array<{ name: string; value: string }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
let cachedToken: string | null = null;
|
||||
|
||||
async function readAdminToken(): Promise<string> {
|
||||
if (cachedToken) return cachedToken;
|
||||
const raw = await fs.readFile(STATE_PATH, 'utf8');
|
||||
const state = JSON.parse(raw) as StoredState;
|
||||
for (const origin of state.origins) {
|
||||
const entry = origin.localStorage.find((e) => e.name === 'picloud.admin.token');
|
||||
if (entry) {
|
||||
cachedToken = entry.value;
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
throw new Error(`No picloud.admin.token in ${STATE_PATH} — did globalSetup run?`);
|
||||
}
|
||||
|
||||
// Thin wrapper around Playwright's request context that injects the
|
||||
// admin bearer token from the shared storageState. Use this for
|
||||
// setup/teardown shortcuts when the *test itself* is about something
|
||||
// else (e.g., a script-editor test that just needs an app to exist).
|
||||
export async function adminApi(): Promise<APIRequestContext> {
|
||||
const token = await readAdminToken();
|
||||
return request.newContext({
|
||||
baseURL: API_BASE,
|
||||
extraHTTPHeaders: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
21
dashboard/tests/e2e/fixtures/auth.ts
Normal file
21
dashboard/tests/e2e/fixtures/auth.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
|
||||
|
||||
// Drive the login form like a real user. globalSetup already saves a
|
||||
// storageState for the shared admin, so most tests don't need this —
|
||||
// it's reserved for specs that explicitly cover the login UI.
|
||||
export async function loginAsAdmin(page: Page): Promise<void> {
|
||||
await page.goto('/admin/login');
|
||||
await page.getByLabel('Username').fill(ADMIN_USERNAME);
|
||||
await page.getByLabel('Password').fill(ADMIN_PASSWORD);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await expect(page).toHaveURL(/\/admin\/apps$/);
|
||||
}
|
||||
|
||||
export async function logout(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: /logout/i }).click();
|
||||
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||
}
|
||||
48
dashboard/tests/e2e/fixtures/cleanup.ts
Normal file
48
dashboard/tests/e2e/fixtures/cleanup.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { adminApi } from './api';
|
||||
|
||||
// Resources to delete after a test, in LIFO order. Tests register
|
||||
// their creations and the registry tears everything down in
|
||||
// `cleanupRegistered` — typically called from `test.afterEach`.
|
||||
|
||||
type Cleanup = (api: APIRequestContext) => Promise<void>;
|
||||
|
||||
export class CleanupRegistry {
|
||||
private items: Cleanup[] = [];
|
||||
|
||||
app(slugOrId: string): void {
|
||||
this.items.push(async (api) => {
|
||||
await api.delete(`/api/v1/admin/apps/${encodeURIComponent(slugOrId)}?force=true`);
|
||||
});
|
||||
}
|
||||
|
||||
adminUser(userId: string): void {
|
||||
this.items.push(async (api) => {
|
||||
await api.delete(`/api/v1/admin/admins/${userId}`);
|
||||
});
|
||||
}
|
||||
|
||||
apiKey(keyId: string): void {
|
||||
this.items.push(async (api) => {
|
||||
await api.delete(`/api/v1/admin/api-keys/${keyId}`);
|
||||
});
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
if (this.items.length === 0) return;
|
||||
const api = await adminApi();
|
||||
try {
|
||||
for (const item of this.items.reverse()) {
|
||||
try {
|
||||
await item(api);
|
||||
} catch {
|
||||
// Best-effort cleanup — a missing resource (already
|
||||
// deleted by the test) shouldn't fail the suite.
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await api.dispose();
|
||||
this.items = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
42
dashboard/tests/e2e/fixtures/ids.ts
Normal file
42
dashboard/tests/e2e/fixtures/ids.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/* eslint-disable no-empty-pattern -- Playwright fixtures require an
|
||||
object-pattern first arg; these fixtures don't depend on any other
|
||||
fixture so the pattern is intentionally empty. */
|
||||
import { test as base } from '@playwright/test';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
// Tests share a single backend/Postgres. To avoid collisions we tag
|
||||
// every resource the test creates with a short random suffix plus the
|
||||
// Playwright worker index. This way two workers running the same spec
|
||||
// in parallel never fight over the same slug or username.
|
||||
|
||||
export function shortId(): string {
|
||||
return randomBytes(3).toString('hex');
|
||||
}
|
||||
|
||||
export function uniqueSlug(prefix: string, workerIndex: number): string {
|
||||
const cleaned = prefix
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
return `e2e-${cleaned}-w${workerIndex}-${shortId()}`;
|
||||
}
|
||||
|
||||
export function uniqueUsername(prefix: string, workerIndex: number): string {
|
||||
// Username regex is [a-z0-9._-]{2,32}. Mirror the slug format.
|
||||
const cleaned = prefix.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||
return `e2e${cleaned}w${workerIndex}${shortId()}`.slice(0, 32);
|
||||
}
|
||||
|
||||
export const test = base.extend<{
|
||||
uniqueSlug: (prefix: string) => string;
|
||||
uniqueUsername: (prefix: string) => string;
|
||||
}>({
|
||||
uniqueSlug: async ({}, use, testInfo) => {
|
||||
await use((prefix) => uniqueSlug(prefix, testInfo.workerIndex));
|
||||
},
|
||||
uniqueUsername: async ({}, use, testInfo) => {
|
||||
await use((prefix) => uniqueUsername(prefix, testInfo.workerIndex));
|
||||
}
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
94
dashboard/tests/e2e/global-setup.ts
Normal file
94
dashboard/tests/e2e/global-setup.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { chromium, request } from '@playwright/test';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
||||
const DASHBOARD_PORT = Number(process.env.PICLOUD_DASHBOARD_PORT ?? 5173);
|
||||
const DASHBOARD_ORIGIN = process.env.E2E_DASHBOARD_ORIGIN ?? `http://localhost:${DASHBOARD_PORT}`;
|
||||
const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
|
||||
|
||||
const AUTH_DIR = path.join(__dirname, '.auth');
|
||||
const ADMIN_STATE_PATH = path.join(AUTH_DIR, 'admin.json');
|
||||
|
||||
export default async function globalSetup(): Promise<void> {
|
||||
await assertBackendUp();
|
||||
await fs.mkdir(AUTH_DIR, { recursive: true });
|
||||
const token = await loginAsAdmin();
|
||||
await persistAdminStorageState(token);
|
||||
}
|
||||
|
||||
async function assertBackendUp(): Promise<void> {
|
||||
const probe = await request.newContext();
|
||||
try {
|
||||
const res = await probe.get(`${API_BASE}/healthz`, { timeout: 5_000 });
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`backend /healthz returned ${res.status()} — is \`cargo run -p picloud\` listening on ${API_BASE}?`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Could not reach backend at ${API_BASE}/healthz. ` +
|
||||
`Bring it up before running E2E tests:\n\n` +
|
||||
` docker compose up -d postgres\n` +
|
||||
` PICLOUD_BIND=127.0.0.1:18080 \\\n` +
|
||||
` PICLOUD_ADMIN_USERNAME=${ADMIN_USERNAME} \\\n` +
|
||||
` PICLOUD_ADMIN_PASSWORD=${ADMIN_PASSWORD} \\\n` +
|
||||
` DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \\\n` +
|
||||
` cargo run -p picloud\n\n` +
|
||||
`Underlying error: ${(err as Error).message}`
|
||||
);
|
||||
} finally {
|
||||
await probe.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function loginAsAdmin(): Promise<string> {
|
||||
const ctx = await request.newContext();
|
||||
try {
|
||||
const res = await ctx.post(`${API_BASE}/api/v1/admin/auth/login`, {
|
||||
data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD },
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
if (!res.ok()) {
|
||||
const body = await res.text();
|
||||
throw new Error(
|
||||
`Admin login failed (${res.status()}): ${body}. ` +
|
||||
`Verify PICLOUD_ADMIN_USERNAME / PICLOUD_ADMIN_PASSWORD match the seeded bootstrap admin.`
|
||||
);
|
||||
}
|
||||
const payload = (await res.json()) as { token?: string };
|
||||
if (!payload.token) {
|
||||
throw new Error('Admin login response missing token field');
|
||||
}
|
||||
return payload.token;
|
||||
} finally {
|
||||
await ctx.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// The dashboard reads its session from localStorage under the key
|
||||
// `picloud.admin.token` (see src/lib/auth.ts). We can't write to
|
||||
// localStorage without a browser context, so launch a throwaway one,
|
||||
// seed the value, then save storageState for every test to reuse.
|
||||
async function persistAdminStorageState(token: string): Promise<void> {
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${DASHBOARD_ORIGIN}/admin/login`);
|
||||
await page.evaluate(
|
||||
([key, value]) => {
|
||||
localStorage.setItem(key, value);
|
||||
},
|
||||
['picloud.admin.token', token]
|
||||
);
|
||||
await context.storageState({ path: ADMIN_STATE_PATH });
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
155
dashboard/tests/e2e/integration/integration.spec.ts
Normal file
155
dashboard/tests/e2e/integration/integration.spec.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { expect, request, type Page } from '@playwright/test';
|
||||
import { test } from '../fixtures/ids';
|
||||
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||
import { adminApi } from '../fixtures/api';
|
||||
|
||||
// Full-stack integration scenarios. Unlike the per-page B1–B8 specs,
|
||||
// these drive a complete user journey across multiple pages and then
|
||||
// verify the data plane / API surface behaves the way the dashboard
|
||||
// promised it would.
|
||||
|
||||
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
||||
|
||||
const cleanup = new CleanupRegistry();
|
||||
test.afterEach(async () => {
|
||||
await cleanup.run();
|
||||
});
|
||||
|
||||
async function fillCodeMirror(page: Page, locator: string, text: string): Promise<void> {
|
||||
const cm = page.locator(locator).first();
|
||||
await cm.click();
|
||||
await page.keyboard.press('ControlOrMeta+A');
|
||||
await page.keyboard.press('Delete');
|
||||
await page.keyboard.type(text);
|
||||
}
|
||||
|
||||
test('end-to-end: app + domain + script + route via dashboard → invoke via public URL', async ({
|
||||
page,
|
||||
uniqueSlug
|
||||
}) => {
|
||||
const slug = uniqueSlug('public');
|
||||
const domain = `${slug}.local`;
|
||||
const routePath = `/${slug}/hello`;
|
||||
const scriptName = `${slug}-hello`;
|
||||
const scriptSource = `return #{ statusCode: 200, body: #{ source: "public", slug: "${slug}" } };`;
|
||||
|
||||
// 1. Create the app from the apps list.
|
||||
await page.goto('/admin/apps');
|
||||
await page.getByRole('button', { name: 'New app' }).click();
|
||||
await page.getByLabel('Name').fill(slug);
|
||||
const slugInput = page.getByLabel('Slug');
|
||||
await slugInput.fill('');
|
||||
await slugInput.fill(slug);
|
||||
await page.getByRole('button', { name: 'Create app' }).click();
|
||||
cleanup.app(slug);
|
||||
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
|
||||
|
||||
// 2. Open the app and claim the domain on the Domains tab.
|
||||
await page.getByRole('link', { name: new RegExp(slug) }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/admin/apps/${slug}$`));
|
||||
await page.getByRole('button', { name: /^Domains \(\d+\)$/ }).click();
|
||||
const domainForm = page.locator('form.create-form.inline');
|
||||
await domainForm.getByPlaceholder(/app\.example\.com/).fill(domain);
|
||||
await domainForm.getByRole('button', { name: /^Add domain$/ }).click();
|
||||
await expect(page.locator('.domain-row')).toContainText(domain);
|
||||
|
||||
// 3. Create the script on the Scripts tab.
|
||||
await page.getByRole('button', { name: /^Scripts \(\d+\)$/ }).click();
|
||||
await page.getByRole('button', { name: /^New script$/ }).click();
|
||||
await page.getByLabel('Name').fill(scriptName);
|
||||
await fillCodeMirror(page, '.cm-content', scriptSource);
|
||||
await page.getByRole('button', { name: /^Create script$/ }).click();
|
||||
|
||||
// 4. Open the script and bind a route on the Routing tab.
|
||||
await page.getByRole('link', { name: new RegExp(scriptName) }).click();
|
||||
await page.getByRole('button', { name: 'Routing' }).click();
|
||||
await page.getByRole('button', { name: '+ Add route' }).click();
|
||||
const routeForm = page.locator('form.route-form');
|
||||
await routeForm.getByLabel('Path', { exact: true }).fill(routePath);
|
||||
await routeForm.getByLabel('Method').selectOption('GET');
|
||||
await routeForm.getByLabel(/^Host/).fill(domain);
|
||||
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||
await expect(page.locator('.route-list')).toContainText(routePath);
|
||||
|
||||
// 5. Invoke via the public URL, with the Host header pointing at
|
||||
// the claimed domain. The dev backend listens on 127.0.0.1; the
|
||||
// orchestrator resolves the app from Host, then the route.
|
||||
const publicCtx = await request.newContext({ baseURL: API_BASE });
|
||||
try {
|
||||
const res = await publicCtx.get(routePath, { headers: { host: domain } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { source: string; slug: string };
|
||||
expect(body.source).toBe('public');
|
||||
expect(body.slug).toBe(slug);
|
||||
} finally {
|
||||
await publicCtx.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('api key minted via dashboard works as a CLI bearer, then revoke disables it', async ({
|
||||
page
|
||||
}) => {
|
||||
const name = `e2e-cli-${Date.now()}`;
|
||||
|
||||
// 1. Mint the key from /profile and capture the revealed token.
|
||||
await page.goto('/admin/profile');
|
||||
await page.getByRole('button', { name: /\+ Mint API key/ }).click();
|
||||
const mintForm = page.locator('form.mint');
|
||||
await mintForm.getByPlaceholder('e.g. ci-deploy').fill(name);
|
||||
// script:read is enough to read the scripts list — that's our
|
||||
// "CLI verb" below.
|
||||
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
|
||||
await page.getByRole('button', { name: /^Mint key$/ }).click();
|
||||
|
||||
const reveal = page.locator('.reveal');
|
||||
await expect(reveal).toBeVisible();
|
||||
const rawToken = (await reveal.locator('code.token').textContent())?.trim();
|
||||
expect(rawToken).toBeTruthy();
|
||||
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
|
||||
await reveal.getByRole('button', { name: /^Done$/ }).click();
|
||||
|
||||
// 2. Act like a CLI: call the API directly with Bearer <token>.
|
||||
const cli = await request.newContext({
|
||||
baseURL: API_BASE,
|
||||
extraHTTPHeaders: { authorization: `Bearer ${rawToken}` }
|
||||
});
|
||||
try {
|
||||
const ok = await cli.get('/api/v1/admin/scripts');
|
||||
expect(ok.status()).toBe(200);
|
||||
const body = (await ok.json()) as unknown;
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
|
||||
// Sanity: a route the scope doesn't cover must reject.
|
||||
// `script:read` cannot list instance admins (that's
|
||||
// instance:admin territory).
|
||||
const denied = await cli.get('/api/v1/admin/admins');
|
||||
expect(denied.status()).toBe(403);
|
||||
|
||||
// 3. Revoke via the dashboard.
|
||||
await page.reload();
|
||||
const revokeBtn = page.getByRole('button', { name: `Revoke ${name}` });
|
||||
await expect(revokeBtn).toBeVisible();
|
||||
await revokeBtn.click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: /^Revoke$/ }).click();
|
||||
await expect(revokeBtn).toHaveCount(0);
|
||||
|
||||
// 4. Same CLI call must now fail auth.
|
||||
const afterRevoke = await cli.get('/api/v1/admin/scripts');
|
||||
expect(afterRevoke.status()).toBe(401);
|
||||
} finally {
|
||||
await cli.dispose();
|
||||
}
|
||||
|
||||
// Belt-and-braces cleanup: if the UI revoke missed, drop via API.
|
||||
const api = await adminApi();
|
||||
try {
|
||||
const list = await api.get('/api/v1/admin/api-keys');
|
||||
if (list.ok()) {
|
||||
const all = (await list.json()) as Array<{ id: string; name: string }>;
|
||||
const k = all.find((x) => x.name === name);
|
||||
if (k) cleanup.apiKey(k.id);
|
||||
}
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
});
|
||||
199
dashboard/tests/e2e/members/members.spec.ts
Normal file
199
dashboard/tests/e2e/members/members.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { expect, type Browser, type Page } from '@playwright/test';
|
||||
import { test } from '../fixtures/ids';
|
||||
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||
import { adminApi } from '../fixtures/api';
|
||||
|
||||
// Phase B5 — App Members. Setup creates one or two extra admin
|
||||
// users via the API; tests drive the Members tab through the
|
||||
// dashboard like a real app admin would.
|
||||
|
||||
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
||||
|
||||
const cleanup = new CleanupRegistry();
|
||||
test.afterEach(async () => {
|
||||
await cleanup.run();
|
||||
});
|
||||
|
||||
async function createApp(slug: string): Promise<string> {
|
||||
const api = await adminApi();
|
||||
try {
|
||||
const res = await api.post('/api/v1/admin/apps', { data: { slug, name: slug } });
|
||||
expect(res.ok()).toBe(true);
|
||||
return ((await res.json()) as { id: string }).id;
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function createMemberUser(username: string): Promise<string> {
|
||||
const api = await adminApi();
|
||||
try {
|
||||
const res = await api.post('/api/v1/admin/admins', {
|
||||
data: { username, password: 'e2e-member-pw', instance_role: 'member' }
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
return ((await res.json()) as { id: string }).id;
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function loginAsUserToken(username: string, password: string): Promise<string> {
|
||||
const probe = await (await import('@playwright/test')).request.newContext({
|
||||
baseURL: API_BASE
|
||||
});
|
||||
try {
|
||||
const res = await probe.post('/api/v1/admin/auth/login', {
|
||||
data: { username, password },
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
return ((await res.json()) as { token: string }).token;
|
||||
} finally {
|
||||
await probe.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function pageWithUserToken(browser: Browser, token: string): Promise<Page> {
|
||||
const ctx = await browser.newContext({ storageState: undefined });
|
||||
const page = await ctx.newPage();
|
||||
// Seed localStorage on the right origin, then navigate normally.
|
||||
await page.goto('/admin/login');
|
||||
await page.evaluate(
|
||||
([key, value]) => {
|
||||
localStorage.setItem(key, value);
|
||||
},
|
||||
['picloud.admin.token', token]
|
||||
);
|
||||
return page;
|
||||
}
|
||||
|
||||
test.describe('B5 app members', () => {
|
||||
test('invite a member-role user, then remove them', async ({ page, uniqueSlug, uniqueUsername }) => {
|
||||
const slug = uniqueSlug('mem');
|
||||
const username = uniqueUsername('inv');
|
||||
await createApp(slug);
|
||||
const userId = await createMemberUser(username);
|
||||
cleanup.app(slug);
|
||||
cleanup.adminUser(userId);
|
||||
|
||||
await page.goto(`/admin/apps/${slug}`);
|
||||
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
|
||||
|
||||
// Invite. Both selects sit in `form.create-form`; locate them
|
||||
// by position to avoid getByLabel ambiguity (the Svelte
|
||||
// markup nests both labels in a flex row, which makes their
|
||||
// accessible names overlap).
|
||||
const form = page.locator('form.create-form');
|
||||
await form.locator('select').nth(0).selectOption({ label: username });
|
||||
await form.locator('select').nth(1).selectOption('editor');
|
||||
await page.getByRole('button', { name: /^Add member$/ }).click();
|
||||
await expect(page.locator('.member-row')).toContainText(username);
|
||||
|
||||
// Remove via action menu + confirm modal.
|
||||
await page.getByRole('button', { name: new RegExp(`Member actions for ${username}`) }).click();
|
||||
await page.getByRole('menuitem', { name: /^Remove from app$/ }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /^Remove member$/ }).click();
|
||||
await expect(page.locator('.member-row')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('role change via action menu updates the role chip', async ({
|
||||
page,
|
||||
uniqueSlug,
|
||||
uniqueUsername
|
||||
}) => {
|
||||
const slug = uniqueSlug('mem');
|
||||
const username = uniqueUsername('role');
|
||||
await createApp(slug);
|
||||
const userId = await createMemberUser(username);
|
||||
cleanup.app(slug);
|
||||
cleanup.adminUser(userId);
|
||||
|
||||
// Seed the membership via API to skip the invite UI.
|
||||
const api = await adminApi();
|
||||
try {
|
||||
const res = await api.post(`/api/v1/admin/apps/${slug}/members`, {
|
||||
data: { user_id: userId, role: 'viewer' }
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
|
||||
await page.goto(`/admin/apps/${slug}`);
|
||||
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
|
||||
await page.getByRole('button', { name: new RegExp(`Member actions for ${username}`) }).click();
|
||||
await page.getByRole('menuitem', { name: /^Make editor$/ }).click();
|
||||
|
||||
const row = page.locator('.member-row', { hasText: username });
|
||||
await expect(row).toContainText(/editor/i);
|
||||
});
|
||||
|
||||
test('non-app-admin viewers do not see the Members tab', async ({
|
||||
browser,
|
||||
uniqueSlug,
|
||||
uniqueUsername
|
||||
}) => {
|
||||
const slug = uniqueSlug('mem');
|
||||
const username = uniqueUsername('viewer');
|
||||
const password = 'e2e-member-pw';
|
||||
await createApp(slug);
|
||||
const userId = await createMemberUser(username);
|
||||
cleanup.app(slug);
|
||||
cleanup.adminUser(userId);
|
||||
|
||||
// Grant viewer membership (not app_admin) so the user can see
|
||||
// the app at all.
|
||||
const api = await adminApi();
|
||||
try {
|
||||
const res = await api.post(`/api/v1/admin/apps/${slug}/members`, {
|
||||
data: { user_id: userId, role: 'viewer' }
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
|
||||
const token = await loginAsUserToken(username, password);
|
||||
const viewerPage = await pageWithUserToken(browser, token);
|
||||
try {
|
||||
await viewerPage.goto(`/admin/apps/${slug}`);
|
||||
// Scripts tab loads — that's what a viewer sees.
|
||||
await expect(
|
||||
viewerPage.getByRole('button', { name: /^Scripts \(\d+\)$/ })
|
||||
).toBeVisible();
|
||||
// Members tab button is absent for non-app-admins.
|
||||
await expect(
|
||||
viewerPage.getByRole('button', { name: /^Members \(\d+\)$/ })
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
await viewerPage.context().close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('B5 app members adversarial', () => {
|
||||
test('role dropdown exposes only the documented values', async ({
|
||||
page,
|
||||
uniqueSlug,
|
||||
uniqueUsername
|
||||
}) => {
|
||||
const slug = uniqueSlug('mem');
|
||||
const username = uniqueUsername('rolelist');
|
||||
await createApp(slug);
|
||||
const userId = await createMemberUser(username);
|
||||
cleanup.app(slug);
|
||||
cleanup.adminUser(userId);
|
||||
|
||||
await page.goto(`/admin/apps/${slug}`);
|
||||
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
|
||||
const form = page.locator('form.create-form');
|
||||
const roleSelect = form.locator('select').nth(1);
|
||||
const optionValues = await roleSelect.evaluate((el: HTMLSelectElement) =>
|
||||
Array.from(el.options).map((o) => o.value)
|
||||
);
|
||||
expect(optionValues.sort()).toEqual(['app_admin', 'editor', 'viewer']);
|
||||
});
|
||||
});
|
||||
150
dashboard/tests/e2e/profile/profile.spec.ts
Normal file
150
dashboard/tests/e2e/profile/profile.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { test } from '../fixtures/ids';
|
||||
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||
import { adminApi } from '../fixtures/api';
|
||||
|
||||
// Phase B7 — Profile + API Keys (/admin/profile). Covers the
|
||||
// mint/reveal/revoke flow, the app-binding mutual-exclusion guard,
|
||||
// and adversarial inputs.
|
||||
|
||||
const cleanup = new CleanupRegistry();
|
||||
test.afterEach(async () => {
|
||||
await cleanup.run();
|
||||
});
|
||||
|
||||
async function createApp(slug: string): Promise<string> {
|
||||
const api = await adminApi();
|
||||
try {
|
||||
const res = await api.post('/api/v1/admin/apps', { data: { slug, name: slug } });
|
||||
expect(res.ok()).toBe(true);
|
||||
return ((await res.json()) as { id: string }).id;
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function openMintForm(page: Page): Promise<void> {
|
||||
await page.goto('/admin/profile');
|
||||
await page.getByRole('button', { name: /\+ Mint API key/ }).click();
|
||||
}
|
||||
|
||||
async function registerKeyCleanupByName(name: string): Promise<void> {
|
||||
const api = await adminApi();
|
||||
try {
|
||||
const res = await api.get('/api/v1/admin/api-keys');
|
||||
const all = (await res.json()) as Array<{ id: string; name: string }>;
|
||||
const k = all.find((x) => x.name === name);
|
||||
if (k) cleanup.apiKey(k.id);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('B7 profile + API keys', () => {
|
||||
test('mint instance-wide key: reveal → ack → key appears in list', async ({ page }) => {
|
||||
const name = `e2e-mint-${Date.now()}`;
|
||||
await openMintForm(page);
|
||||
await page.locator('form.mint').getByPlaceholder('e.g. ci-deploy').fill(name);
|
||||
// Pick a non-instance scope so we don't need to worry about
|
||||
// mutual exclusion here. The scope-chip is a <label> wrapping
|
||||
// the checkbox — clicking the label toggles it.
|
||||
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
|
||||
await page.getByRole('button', { name: /^Mint key$/ }).click();
|
||||
|
||||
const reveal = page.locator('.reveal');
|
||||
await expect(reveal).toBeVisible();
|
||||
await expect(reveal.locator('code.token')).toContainText(/\S{16,}/);
|
||||
await expect(reveal.getByRole('button', { name: /^Done$/ })).toBeDisabled();
|
||||
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
|
||||
await reveal.getByRole('button', { name: /^Done$/ }).click();
|
||||
|
||||
await registerKeyCleanupByName(name);
|
||||
await expect(page.getByText(name)).toBeVisible();
|
||||
});
|
||||
|
||||
test('binding to an app disables instance scopes', async ({ page, uniqueSlug }) => {
|
||||
const slug = uniqueSlug('keyapp');
|
||||
const appId = await createApp(slug);
|
||||
cleanup.app(slug);
|
||||
|
||||
await openMintForm(page);
|
||||
|
||||
// Default binding is Instance-wide — instance scopes are
|
||||
// enabled.
|
||||
const instChip = page.locator('label.scope-chip', { hasText: 'instance:admin' });
|
||||
await expect(instChip).not.toHaveClass(/disabled/);
|
||||
|
||||
// Switch binding to the app. The chip becomes disabled.
|
||||
await page.getByLabel(/Binding/i).selectOption(appId);
|
||||
await expect(instChip).toHaveClass(/disabled/);
|
||||
});
|
||||
|
||||
test('revoke key removes it from the list', async ({ page }) => {
|
||||
const name = `e2e-revoke-${Date.now()}`;
|
||||
// Seed a key via API so the test focuses on the revoke UI.
|
||||
const api = await adminApi();
|
||||
try {
|
||||
const res = await api.post('/api/v1/admin/api-keys', {
|
||||
data: { name, scopes: ['script:read'] }
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = (await res.json()) as { id: string };
|
||||
cleanup.apiKey(body.id);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
|
||||
await page.goto('/admin/profile');
|
||||
const revokeBtn = page.getByRole('button', { name: `Revoke ${name}` });
|
||||
await expect(revokeBtn).toBeVisible();
|
||||
await revokeBtn.click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('button', { name: /^Revoke$/ }).click();
|
||||
// Assert the row's revoke button is gone (the flash banner
|
||||
// also mentions the name, so a plain getByText would still
|
||||
// match — anchor on the row-scoped button instead).
|
||||
await expect(revokeBtn).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('denied=users banner shows when arriving from the users redirect', async ({ page }) => {
|
||||
await page.goto('/admin/profile?denied=users');
|
||||
await expect(page.getByText(/don.?t have access to the Users page/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('B7 profile adversarial', () => {
|
||||
test('empty name keeps the mint button disabled', async ({ page }) => {
|
||||
await openMintForm(page);
|
||||
// Trying to click would HTML5-validate; instead verify the
|
||||
// button is disabled while name is empty.
|
||||
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
|
||||
await expect(page.getByRole('button', { name: /^Mint key$/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('copy-token button copies the full token, not a truncated form', async ({
|
||||
page,
|
||||
context
|
||||
}) => {
|
||||
// Permission must be granted explicitly; chromium will throw
|
||||
// otherwise when calling navigator.clipboard.readText().
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
const name = `e2e-copy-${Date.now()}`;
|
||||
await openMintForm(page);
|
||||
await page.locator('form.mint').getByPlaceholder('e.g. ci-deploy').fill(name);
|
||||
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
|
||||
await page.getByRole('button', { name: /^Mint key$/ }).click();
|
||||
|
||||
const reveal = page.locator('.reveal');
|
||||
const tokenInDom = await reveal.locator('code.token').textContent();
|
||||
expect(tokenInDom).toBeTruthy();
|
||||
await reveal.getByRole('button', { name: /^Copy$/ }).click();
|
||||
const copied = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(copied).toBe(tokenInDom);
|
||||
|
||||
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
|
||||
await reveal.getByRole('button', { name: /^Done$/ }).click();
|
||||
await registerKeyCleanupByName(name);
|
||||
});
|
||||
});
|
||||
189
dashboard/tests/e2e/routing/routing.spec.ts
Normal file
189
dashboard/tests/e2e/routing/routing.spec.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { test } from '../fixtures/ids';
|
||||
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||
import { adminApi } from '../fixtures/api';
|
||||
|
||||
// Phase B4 — Routing tab in the script editor. Add / remove / match
|
||||
// preview + validation paths (host check, path-kind mismatch, reserved
|
||||
// prefix, duplicate conflict, adversarial paths).
|
||||
|
||||
const HELLO_RHAI = `return #{ statusCode: 200, body: #{ ok: true } };`;
|
||||
|
||||
const cleanup = new CleanupRegistry();
|
||||
test.afterEach(async () => {
|
||||
await cleanup.run();
|
||||
});
|
||||
|
||||
async function makeAppWithScript(slug: string): Promise<{ appId: string; scriptId: string }> {
|
||||
const api = await adminApi();
|
||||
try {
|
||||
const appRes = await api.post('/api/v1/admin/apps', {
|
||||
data: { slug, name: slug }
|
||||
});
|
||||
expect(appRes.ok()).toBe(true);
|
||||
const appBody = (await appRes.json()) as { id: string };
|
||||
|
||||
const scriptRes = await api.post('/api/v1/admin/scripts', {
|
||||
data: { app_id: appBody.id, name: 'route-target', source: HELLO_RHAI }
|
||||
});
|
||||
expect(scriptRes.ok()).toBe(true);
|
||||
const scriptBody = (await scriptRes.json()) as { id: string };
|
||||
|
||||
return { appId: appBody.id, scriptId: scriptBody.id };
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function gotoRoutingTab(page: Page, scriptId: string): Promise<void> {
|
||||
await page.goto(`/admin/scripts/${scriptId}`);
|
||||
await page.getByRole('button', { name: 'Routing' }).click();
|
||||
}
|
||||
|
||||
async function addRoute(
|
||||
page: Page,
|
||||
opts: { path: string; pathKind?: 'exact' | 'param' | 'prefix'; method?: string; host?: string }
|
||||
): Promise<void> {
|
||||
await page.getByRole('button', { name: '+ Add route' }).click();
|
||||
const form = page.locator('form.route-form');
|
||||
await form.getByLabel('Path', { exact: true }).fill(opts.path);
|
||||
if (opts.pathKind) {
|
||||
await form.getByLabel('Path kind').selectOption(opts.pathKind);
|
||||
}
|
||||
if (opts.method !== undefined) {
|
||||
await form.getByLabel('Method').selectOption(opts.method);
|
||||
}
|
||||
if (opts.host !== undefined) {
|
||||
await form.getByLabel(/^Host/).fill(opts.host);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('B4 routing', () => {
|
||||
test('add route appears in list and matches in the preview', async ({ page, uniqueSlug }) => {
|
||||
const slug = uniqueSlug('addr');
|
||||
const { scriptId } = await makeAppWithScript(slug);
|
||||
cleanup.app(slug);
|
||||
|
||||
await gotoRoutingTab(page, scriptId);
|
||||
await addRoute(page, { path: '/greet', method: 'GET' });
|
||||
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||
|
||||
await expect(page.locator('.route-list')).toContainText('/greet');
|
||||
|
||||
// Match preview confirms the route resolves.
|
||||
await page.getByLabel('URL').fill('http://localhost/greet');
|
||||
await page.locator('.actions').getByRole('button', { name: 'Match' }).click();
|
||||
await expect(page.locator('pre.preview')).toContainText('script_id');
|
||||
});
|
||||
|
||||
test('remove route updates the list', async ({ page, uniqueSlug }) => {
|
||||
const slug = uniqueSlug('remr');
|
||||
const { scriptId } = await makeAppWithScript(slug);
|
||||
cleanup.app(slug);
|
||||
|
||||
await gotoRoutingTab(page, scriptId);
|
||||
await addRoute(page, { path: '/transient', method: 'GET' });
|
||||
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||
await expect(page.locator('.route-list')).toContainText('/transient');
|
||||
|
||||
// removeRoute() uses window.confirm — accept it.
|
||||
page.once('dialog', (d) => void d.accept());
|
||||
await page.locator('.route-list').getByRole('button', { name: 'remove' }).click();
|
||||
await expect(page.locator('.route-list')).toHaveCount(0);
|
||||
await expect(page.getByText(/no routes yet/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('duplicate route surfaces a 409 conflict error inline', async ({ page, uniqueSlug }) => {
|
||||
const slug = uniqueSlug('dupr');
|
||||
const { scriptId } = await makeAppWithScript(slug);
|
||||
cleanup.app(slug);
|
||||
|
||||
await gotoRoutingTab(page, scriptId);
|
||||
await addRoute(page, { path: '/twice', method: 'GET' });
|
||||
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||
await expect(page.locator('.route-list')).toContainText('/twice');
|
||||
|
||||
// Same path + method again — must conflict.
|
||||
await addRoute(page, { path: '/twice', method: 'GET' });
|
||||
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||
await expect(page.locator('.route-form .error.inline')).toBeVisible();
|
||||
});
|
||||
|
||||
test('path-kind mismatch warns inline when /:name is set to exact', async ({
|
||||
page,
|
||||
uniqueSlug
|
||||
}) => {
|
||||
const slug = uniqueSlug('mism');
|
||||
const { scriptId } = await makeAppWithScript(slug);
|
||||
cleanup.app(slug);
|
||||
|
||||
await gotoRoutingTab(page, scriptId);
|
||||
await page.getByRole('button', { name: '+ Add route' }).click();
|
||||
await page.getByLabel('Path', { exact: true }).fill('/users/:id');
|
||||
// Override to a wrong kind — auto-detect would have picked
|
||||
// `param`; selecting `exact` should fire the warning.
|
||||
await page.getByLabel('Path kind').selectOption('exact');
|
||||
await expect(page.locator('.route-form .warning.inline')).toBeVisible();
|
||||
});
|
||||
|
||||
test('host validation warns when the host is not a claimed domain', async ({
|
||||
page,
|
||||
uniqueSlug
|
||||
}) => {
|
||||
const slug = uniqueSlug('unclaim');
|
||||
const { scriptId } = await makeAppWithScript(slug);
|
||||
cleanup.app(slug);
|
||||
|
||||
await gotoRoutingTab(page, scriptId);
|
||||
await page.getByRole('button', { name: '+ Add route' }).click();
|
||||
await page.getByLabel('Path', { exact: true }).fill('/x');
|
||||
await page.getByLabel(/^Host/).fill('example.test-not-claimed.local');
|
||||
// One of the inline warnings is the unclaimed-host explainer.
|
||||
await expect(page.locator('.route-form .warning.inline').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('B4 routing adversarial', () => {
|
||||
test('reserved prefix /api/ is rejected with a visible error', async ({ page, uniqueSlug }) => {
|
||||
const slug = uniqueSlug('reserv');
|
||||
const { scriptId } = await makeAppWithScript(slug);
|
||||
cleanup.app(slug);
|
||||
|
||||
await gotoRoutingTab(page, scriptId);
|
||||
await addRoute(page, { path: '/api/v9/oops', method: 'GET' });
|
||||
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||
await expect(page.locator('.route-form .error.inline')).toBeVisible();
|
||||
await expect(page.locator('.route-form .error.inline')).toContainText(
|
||||
/reserved|api|prefix/i
|
||||
);
|
||||
// Empty-state copy renders when no routes exist; the path
|
||||
// itself must not appear anywhere on the routing tab.
|
||||
await expect(page.getByText(/no routes yet/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('xss payload in path stored or rejected — never executes on render', async ({
|
||||
page,
|
||||
uniqueSlug
|
||||
}) => {
|
||||
page.on('dialog', async (d) => {
|
||||
await d.dismiss();
|
||||
throw new Error(`Unexpected dialog: ${d.message()}`);
|
||||
});
|
||||
const slug = uniqueSlug('pxss');
|
||||
const { scriptId } = await makeAppWithScript(slug);
|
||||
cleanup.app(slug);
|
||||
|
||||
await gotoRoutingTab(page, scriptId);
|
||||
await addRoute(page, {
|
||||
path: '/<script>alert(1)</script>',
|
||||
method: 'GET'
|
||||
});
|
||||
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||
|
||||
// Either accepted (rendered as text in the list) or rejected
|
||||
// (error inline). Both fine — what's NOT fine is an alert
|
||||
// dialog or an injected <script> tag in the list.
|
||||
const xssScripts = await page.locator('.route-list script:has-text("alert")').count();
|
||||
expect(xssScripts).toBe(0);
|
||||
});
|
||||
});
|
||||
203
dashboard/tests/e2e/scripts/scripts.spec.ts
Normal file
203
dashboard/tests/e2e/scripts/scripts.spec.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { test } from '../fixtures/ids';
|
||||
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||
import { adminApi } from '../fixtures/api';
|
||||
|
||||
// Phase B3 — Scripts CRUD + Editor. The script editor lives at
|
||||
// /admin/scripts/{id}. Setup uses the API to create the app (and
|
||||
// sometimes a baseline script) so each test can focus on the editor
|
||||
// flow it actually covers.
|
||||
|
||||
const HELLO_RHAI = `return #{ statusCode: 200, body: #{ ok: true } };`;
|
||||
|
||||
const cleanup = new CleanupRegistry();
|
||||
test.afterEach(async () => {
|
||||
await cleanup.run();
|
||||
});
|
||||
|
||||
async function createAppViaApi(slug: string): Promise<string> {
|
||||
const api = await adminApi();
|
||||
try {
|
||||
const res = await api.post('/api/v1/admin/apps', {
|
||||
data: { slug, name: slug }
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = (await res.json()) as { id: string };
|
||||
return body.id;
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function createScriptViaApi(
|
||||
appId: string,
|
||||
name: string,
|
||||
source = HELLO_RHAI
|
||||
): Promise<string> {
|
||||
const api = await adminApi();
|
||||
try {
|
||||
const res = await api.post('/api/v1/admin/scripts', {
|
||||
data: { app_id: appId, name, source }
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = (await res.json()) as { id: string };
|
||||
return body.id;
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function fillCodeMirror(page: Page, locator: string, text: string): Promise<void> {
|
||||
const cm = page.locator(locator).first();
|
||||
await cm.click();
|
||||
await page.keyboard.press('ControlOrMeta+A');
|
||||
await page.keyboard.press('Delete');
|
||||
await page.keyboard.type(text);
|
||||
}
|
||||
|
||||
test.describe('B3 scripts CRUD', () => {
|
||||
test('create script via UI navigates to scripts list with the new entry', async ({
|
||||
page,
|
||||
uniqueSlug
|
||||
}) => {
|
||||
const slug = uniqueSlug('cscr');
|
||||
await createAppViaApi(slug);
|
||||
cleanup.app(slug);
|
||||
|
||||
await page.goto(`/admin/apps/${slug}`);
|
||||
await page.getByRole('button', { name: /^New script$/ }).click();
|
||||
await page.getByLabel('Name').fill('echo');
|
||||
// The CodeMirror editor starts empty in create mode; type a
|
||||
// minimal valid script.
|
||||
await fillCodeMirror(page, '.cm-content', HELLO_RHAI);
|
||||
await page.getByRole('button', { name: 'Create script' }).click();
|
||||
|
||||
await expect(page.getByRole('link', { name: /echo/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('edit + save Rhai source persists across reload', async ({ page, uniqueSlug }) => {
|
||||
const slug = uniqueSlug('edit');
|
||||
const appId = await createAppViaApi(slug);
|
||||
const scriptId = await createScriptViaApi(appId, 'edit-target');
|
||||
cleanup.app(slug);
|
||||
|
||||
await page.goto(`/admin/scripts/${scriptId}`);
|
||||
await expect(page.locator('.cm-content').first()).toContainText('statusCode');
|
||||
|
||||
const updated = `// edited by e2e\nreturn #{ statusCode: 201, body: #{ edited: true } };`;
|
||||
await fillCodeMirror(page, '.cm-content', updated);
|
||||
await page.getByRole('button', { name: /^Save$/ }).click();
|
||||
// Save button becomes disabled once the buffer matches the
|
||||
// just-saved source — that's our settle signal.
|
||||
await expect(page.getByRole('button', { name: /^Save$/ })).toBeDisabled();
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator('.cm-content').first()).toContainText('edited by e2e');
|
||||
});
|
||||
|
||||
test('invalid Rhai source: Format shows a parse error', async ({ page, uniqueSlug }) => {
|
||||
const slug = uniqueSlug('invrhai');
|
||||
const appId = await createAppViaApi(slug);
|
||||
const scriptId = await createScriptViaApi(appId, 'bad-syntax');
|
||||
cleanup.app(slug);
|
||||
|
||||
await page.goto(`/admin/scripts/${scriptId}`);
|
||||
await fillCodeMirror(page, '.cm-content', 'this is not rhai @@@ {{{');
|
||||
await page
|
||||
.locator('.editor-header')
|
||||
.getByRole('button', { name: 'Format' })
|
||||
.click();
|
||||
|
||||
await expect(page.locator('.error.inline').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('B3 test-invoke', () => {
|
||||
test('valid JSON body returns status + body in the result panel', async ({
|
||||
page,
|
||||
uniqueSlug
|
||||
}) => {
|
||||
const slug = uniqueSlug('inv-ok');
|
||||
const appId = await createAppViaApi(slug);
|
||||
const scriptId = await createScriptViaApi(appId, 'invoke-ok');
|
||||
cleanup.app(slug);
|
||||
|
||||
await page.goto(`/admin/scripts/${scriptId}`);
|
||||
// Body editor is the second .cm-content (source is first).
|
||||
const bodyEditor = page.locator('.cm-content').nth(1);
|
||||
await bodyEditor.click();
|
||||
await page.keyboard.press('ControlOrMeta+A');
|
||||
await page.keyboard.press('Delete');
|
||||
await page.keyboard.type('{"hello":"world"}');
|
||||
|
||||
await page.getByRole('button', { name: /^Send$/ }).click();
|
||||
await expect(page.locator('.status')).toContainText('HTTP 200');
|
||||
await expect(page.locator('.result pre')).toContainText('ok');
|
||||
});
|
||||
|
||||
test('malformed JSON body: Format surfaces the parse error', async ({ page, uniqueSlug }) => {
|
||||
const slug = uniqueSlug('inv-bad');
|
||||
const appId = await createAppViaApi(slug);
|
||||
const scriptId = await createScriptViaApi(appId, 'invoke-bad');
|
||||
cleanup.app(slug);
|
||||
|
||||
await page.goto(`/admin/scripts/${scriptId}`);
|
||||
const bodyEditor = page.locator('.cm-content').nth(1);
|
||||
await bodyEditor.click();
|
||||
await page.keyboard.press('ControlOrMeta+A');
|
||||
await page.keyboard.press('Delete');
|
||||
await page.keyboard.type('{not valid json,');
|
||||
|
||||
// The Format button for the request body sits inside the
|
||||
// Test-invoke card next to the body editor.
|
||||
await page
|
||||
.locator('.json-block')
|
||||
.first()
|
||||
.getByRole('button', { name: 'Format' })
|
||||
.click();
|
||||
await expect(page.locator('.error.inline').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('B3 settings', () => {
|
||||
test('timeout input rejects zero and non-positive values', async ({ page, uniqueSlug }) => {
|
||||
const slug = uniqueSlug('settz');
|
||||
const appId = await createAppViaApi(slug);
|
||||
const scriptId = await createScriptViaApi(appId, 'settings-target');
|
||||
cleanup.app(slug);
|
||||
|
||||
await page.goto(`/admin/scripts/${scriptId}`);
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
const timeout = page.getByLabel(/Timeout/);
|
||||
await timeout.fill('0');
|
||||
const invalid = await timeout.evaluate((el: HTMLInputElement) => !el.validity.valid);
|
||||
expect(invalid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('B3 adversarial', () => {
|
||||
test('infinite loop script hits the sandbox timeout', async ({ page, uniqueSlug }) => {
|
||||
const slug = uniqueSlug('loop');
|
||||
const appId = await createAppViaApi(slug);
|
||||
const scriptId = await createScriptViaApi(
|
||||
appId,
|
||||
'inf-loop',
|
||||
'loop { let x = 1; }'
|
||||
);
|
||||
cleanup.app(slug);
|
||||
|
||||
await page.goto(`/admin/scripts/${scriptId}`);
|
||||
await page.getByRole('button', { name: /^Send$/ }).click();
|
||||
|
||||
// Either the status renders with a 5xx code, or an error
|
||||
// banner shows up. Either way, the page recovers.
|
||||
await Promise.race([
|
||||
expect(page.locator('.status')).toBeVisible({ timeout: 30_000 }),
|
||||
expect(page.locator('.error.inline').last()).toBeVisible({ timeout: 30_000 })
|
||||
]);
|
||||
|
||||
// The dashboard must remain interactive after the timeout.
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
await expect(page.getByLabel(/Timeout/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
81
dashboard/tests/e2e/security/security.spec.ts
Normal file
81
dashboard/tests/e2e/security/security.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// Phase B8 — Cross-cutting security. Things that aren't tied to a
|
||||
// single page: session handling, secret leakage, error states for
|
||||
// missing resources, and a sanity check that no XSS sink fires
|
||||
// anywhere in the dashboard's main authed routes.
|
||||
|
||||
const VALID_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||
const VALID_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
|
||||
|
||||
test.describe('B8 cross-cutting security', () => {
|
||||
test('expired/stale token: any authed call redirects to /login', async ({ page }) => {
|
||||
// Replace the storageState token with an obvious garbage
|
||||
// value; the fetch wrapper treats 401 as "go to /login".
|
||||
await page.goto('/admin/login');
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('picloud.admin.token', 'expired-or-bogus-token');
|
||||
});
|
||||
await page.goto('/admin/apps');
|
||||
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||
});
|
||||
|
||||
test('login response cookie is HttpOnly', async ({ request }) => {
|
||||
const res = await request.post('/api/v1/admin/auth/login', {
|
||||
data: { username: VALID_USERNAME, password: VALID_PASSWORD },
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const headers = res.headers();
|
||||
const setCookie = headers['set-cookie'];
|
||||
// Backend may or may not set a cookie (the dashboard primarily
|
||||
// uses bearer-in-localStorage). If it does, it must be
|
||||
// HttpOnly so XSS can't exfiltrate it.
|
||||
if (setCookie) {
|
||||
expect(setCookie.toLowerCase()).toContain('httponly');
|
||||
}
|
||||
});
|
||||
|
||||
test('bootstrap password is not present in the DOM after login', async ({ page }) => {
|
||||
await page.goto('/admin/apps');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body).not.toContain(VALID_PASSWORD);
|
||||
});
|
||||
|
||||
test('non-existent app slug shows a recoverable error, not a crash', async ({ page }) => {
|
||||
await page.goto('/admin/apps/does-not-exist-e2e-9999');
|
||||
// Page must render *something* and the layout must remain
|
||||
// intact (header link to Apps still works).
|
||||
await expect(page.getByRole('link', { name: 'Apps' })).toBeVisible();
|
||||
// And surface the failure to the user — either a "couldn't
|
||||
// load" message or a "back to apps" link.
|
||||
const errorOrBack = page.locator('.error, a[href$="/admin/apps"]');
|
||||
await expect(errorOrBack.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('xss probe across major surfaces never fires a dialog', async ({ page }) => {
|
||||
page.on('dialog', async (dialog) => {
|
||||
await dialog.dismiss();
|
||||
throw new Error(
|
||||
`XSS sink fired — got a ${dialog.type()} dialog: "${dialog.message()}"`
|
||||
);
|
||||
});
|
||||
|
||||
// Cover each main authed route. None should evaluate any
|
||||
// payload that earlier tests may have stored, and none should
|
||||
// inject inline <script> tags from server responses.
|
||||
for (const path of ['/admin/apps', '/admin/profile', '/admin/users']) {
|
||||
await page.goto(path);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
const inlineScripts = await page.locator('script[src=""], script:not([src])').count();
|
||||
// Svelte itself injects no inline <script> in the
|
||||
// production bundle; vite dev does, but never with
|
||||
// onerror/alert payload text in them.
|
||||
const evilInline = await page
|
||||
.locator('script:has-text("alert"), script:has-text("__xss")')
|
||||
.count();
|
||||
expect(evilInline, `evil inline script tag on ${path}`).toBe(0);
|
||||
expect(inlineScripts).toBeGreaterThanOrEqual(0); // sanity assertion, no crash
|
||||
}
|
||||
});
|
||||
});
|
||||
28
dashboard/tests/e2e/smoke.spec.ts
Normal file
28
dashboard/tests/e2e/smoke.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { loginAsAdmin } from './fixtures/auth';
|
||||
|
||||
// A1 smoke: prove globalSetup + webServer + fixtures + proxy all work.
|
||||
|
||||
test.describe('smoke', () => {
|
||||
test.describe('unauthenticated', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('root redirects to login and shows the form', async ({ page }) => {
|
||||
await page.goto('/admin/');
|
||||
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||
await expect(page.getByLabel('Username')).toBeVisible();
|
||||
await expect(page.getByLabel('Password')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('valid credentials land on the apps page', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await expect(page.getByRole('link', { name: 'Apps' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('admin storageState already lands on apps', async ({ page }) => {
|
||||
await page.goto('/admin/');
|
||||
await expect(page).toHaveURL(/\/admin\/apps$/);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user