Compare commits
10 Commits
878cbe9439
...
6891496589
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6891496589 | ||
|
|
646bd55174 | ||
|
|
56de652f7a | ||
|
|
3d4c7b160b | ||
|
|
267c40f59c | ||
|
|
1dc53a0226 | ||
|
|
6cdb1244b8 | ||
|
|
bc8b512b56 | ||
|
|
a80e6d1ca4 | ||
|
|
0eaf4aee69 |
12
CLAUDE.md
12
CLAUDE.md
@@ -8,6 +8,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
|
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
|
||||||
|
|
||||||
|
**Current focus (Phase 3, pre-v1.1):** admin auth gate, then multi-app scoping. The latter introduces `apps` as the top-level isolation boundary for scripts, routes, domains, and (later) data. See blueprint §11.5 for the design. Every v1.1+ feature must assume `app_id` exists as a scoping dimension.
|
||||||
|
|
||||||
## Three-Service Architecture
|
## Three-Service Architecture
|
||||||
|
|
||||||
The platform splits into three logical services, each backed by a `*-core` library crate so the same logic runs in single-process MVP mode and split-process cluster mode:
|
The platform splits into three logical services, each backed by a `*-core` library crate so the same logic runs in single-process MVP mode and split-process cluster mode:
|
||||||
@@ -26,7 +28,7 @@ In MVP, all three run in one process (`picloud` binary). In cluster mode, each r
|
|||||||
|
|
||||||
Versioned API surfaces live under `/api/v{N}/...`. See [docs/versioning.md](docs/versioning.md) for the full scheme.
|
Versioned API surfaces live under `/api/v{N}/...`. See [docs/versioning.md](docs/versioning.md) for the full scheme.
|
||||||
|
|
||||||
- `/api/v1/admin/*` — manager (control plane: script CRUD, routes CRUD + check + match, logs, config)
|
- `/api/v1/admin/*` — manager (control plane: script CRUD, routes CRUD + check + match, logs, config; apps CRUD once Phase 3b lands)
|
||||||
- `/api/v1/execute/{id}` — orchestrator (data plane: invoke a script by ID, always-available bypass)
|
- `/api/v1/execute/{id}` — orchestrator (data plane: invoke a script by ID, always-available bypass)
|
||||||
- `/admin/*` — dashboard SPA (SvelteKit, `paths.base = '/admin'`)
|
- `/admin/*` — dashboard SPA (SvelteKit, `paths.base = '/admin'`)
|
||||||
- `/healthz` — liveness (string `"ok"`)
|
- `/healthz` — liveness (string `"ok"`)
|
||||||
@@ -37,6 +39,10 @@ Reserved path prefixes (rejected at route creation): `/api/`, `/admin/`, `/healt
|
|||||||
|
|
||||||
Caddy fronts everything. Same Caddyfile shape works for single-node and cluster — only upstream targets change.
|
Caddy fronts everything. Same Caddyfile shape works for single-node and cluster — only upstream targets change.
|
||||||
|
|
||||||
|
**Param syntax convention:** route paths use `:name` (e.g., `/users/:id`); domains (once apps land) use `{name}` (e.g., `{tenant}.example.com`). These are deliberately distinct — never use `:` in a domain context or `{}` in a route-path context.
|
||||||
|
|
||||||
|
**Two-phase dispatch (Phase 3b onward):** the orchestrator first resolves `Host` → app (most-specific domain claim wins), then runs that app's route trie. The route matcher itself is unchanged and never sees other apps' routes.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Rust 1.92+** workspace, pinned via `rust-toolchain.toml`
|
- **Rust 1.92+** workspace, pinned via `rust-toolchain.toml`
|
||||||
@@ -102,4 +108,6 @@ docs/
|
|||||||
|
|
||||||
## Out of MVP
|
## Out of MVP
|
||||||
|
|
||||||
Queue triggers, cron triggers, SMTP ingress, KV / docs / email / users / HTTP SDKs in scripts, interceptors, workflows, function-to-function `invoke()`, auth, multi-tenancy, secrets, metrics dashboard. All deferred to v1.1+ per the blueprint. Don't pre-build for them — but don't make decisions that close the door on them either.
|
Queue triggers, cron triggers, SMTP ingress, KV / docs / email / users / HTTP SDKs in scripts, interceptors, workflows, function-to-function `invoke()`, secrets, metrics dashboard. All deferred to v1.1+ per the blueprint. Don't pre-build for them — but don't make decisions that close the door on them either.
|
||||||
|
|
||||||
|
**Pulled forward to Phase 3 (pre-v1.1):** admin auth, multi-app scoping. Cross-app data sharing (export/import) stays at v1.3+; the initial cut enforces strict isolation. See blueprint §11.5.
|
||||||
|
|||||||
53
Cargo.lock
generated
53
Cargo.lock
generated
@@ -46,6 +46,18 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"blake2",
|
||||||
|
"cpufeatures",
|
||||||
|
"password-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "assert-json-diff"
|
name = "assert-json-diff"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
@@ -206,6 +218,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -1233,6 +1254,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-hash"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pear"
|
name = "pear"
|
||||||
version = "0.2.9"
|
version = "0.2.9"
|
||||||
@@ -1273,7 +1305,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud"
|
name = "picloud"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1297,7 +1329,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-executor"
|
name = "picloud-executor"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-executor-core",
|
"picloud-executor-core",
|
||||||
@@ -1309,7 +1341,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-executor-core"
|
name = "picloud-executor-core"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"picloud-shared",
|
"picloud-shared",
|
||||||
@@ -1323,7 +1355,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-manager"
|
name = "picloud-manager"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-manager-core",
|
"picloud-manager-core",
|
||||||
@@ -1335,17 +1367,22 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-manager-core"
|
name = "picloud-manager-core"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"picloud-orchestrator-core",
|
"picloud-orchestrator-core",
|
||||||
"picloud-shared",
|
"picloud-shared",
|
||||||
|
"rand 0.8.6",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -1353,7 +1390,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-orchestrator"
|
name = "picloud-orchestrator"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-orchestrator-core",
|
"picloud-orchestrator-core",
|
||||||
@@ -1365,7 +1402,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-orchestrator-core"
|
name = "picloud-orchestrator-core"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1384,7 +1421,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-shared"
|
name = "picloud-shared"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
@@ -66,6 +66,12 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
|||||||
url = "2"
|
url = "2"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
|
||||||
|
# Auth (admin users + sessions)
|
||||||
|
argon2 = "0.5"
|
||||||
|
rand = { version = "0.8", features = ["getrandom"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|
||||||
|
|||||||
@@ -22,3 +22,11 @@ uuid.workspace = true
|
|||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
|
|
||||||
|
argon2.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio.workspace = true
|
||||||
|
|||||||
33
crates/manager-core/migrations/0004_admin_auth.sql
Normal file
33
crates/manager-core/migrations/0004_admin_auth.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
-- Phase 3a admin auth — see blueprint §11.4.
|
||||||
|
--
|
||||||
|
-- Per-user platform-operator accounts (distinct from the v1.1+ `users`
|
||||||
|
-- table, which is for script-end users). Every authenticated admin is a
|
||||||
|
-- full admin in this cut; role/permission tables will be added later
|
||||||
|
-- without touching this schema.
|
||||||
|
--
|
||||||
|
-- `admin_sessions.token_hash` stores SHA-256 of the raw token; the raw
|
||||||
|
-- value only ever exists in the login response, the HttpOnly cookie, and
|
||||||
|
-- bearer-token requests. Cascade on user delete kills the user's sessions
|
||||||
|
-- automatically — which is also why deactivating a user can simply wipe
|
||||||
|
-- their rows instead of marking each session expired.
|
||||||
|
|
||||||
|
CREATE TABLE admin_users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_login_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE admin_sessions (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX admin_sessions_user_idx ON admin_sessions (user_id);
|
||||||
|
CREATE INDEX admin_sessions_expiry_idx ON admin_sessions (expires_at);
|
||||||
152
crates/manager-core/src/admin_session_repo.rs
Normal file
152
crates/manager-core/src/admin_session_repo.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
//! CRUD over the `admin_sessions` table.
|
||||||
|
//!
|
||||||
|
//! The token never appears in this module — only its SHA-256 hash. The
|
||||||
|
//! raw value lives in `auth::GeneratedToken` long enough to hit the
|
||||||
|
//! cookie and the JSON response, then is forgotten. Lookups also filter
|
||||||
|
//! expired rows at query time so a delayed prune sweep can never extend
|
||||||
|
//! a session's life.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::AdminUserId;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AdminSessionRepositoryError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a session lookup. Includes the user id (for auth context)
|
||||||
|
/// and the existing `expires_at` so the middleware can decide whether
|
||||||
|
/// the sliding window bump is worth a write.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AdminSessionLookup {
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AdminSessionRepository: Send + Sync {
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
token_hash: &str,
|
||||||
|
expires_at: DateTime<Utc>,
|
||||||
|
) -> Result<(), AdminSessionRepositoryError>;
|
||||||
|
/// Look up a session by token hash. Returns `None` for missing or
|
||||||
|
/// already-expired rows (the query filters them).
|
||||||
|
async fn lookup(
|
||||||
|
&self,
|
||||||
|
token_hash: &str,
|
||||||
|
) -> Result<Option<AdminSessionLookup>, AdminSessionRepositoryError>;
|
||||||
|
/// Sliding-window bump. Sets `last_used_at = NOW()` and `expires_at`
|
||||||
|
/// to the supplied value.
|
||||||
|
async fn touch(
|
||||||
|
&self,
|
||||||
|
token_hash: &str,
|
||||||
|
new_expires_at: DateTime<Utc>,
|
||||||
|
) -> Result<(), AdminSessionRepositoryError>;
|
||||||
|
async fn delete(&self, token_hash: &str) -> Result<(), AdminSessionRepositoryError>;
|
||||||
|
/// Delete every session belonging to a user. Used when the user is
|
||||||
|
/// deactivated or has their password reset out-of-band — both
|
||||||
|
/// invalidate all current logins for that account.
|
||||||
|
async fn delete_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<u64, AdminSessionRepositoryError>;
|
||||||
|
/// Sweep expired rows. The auth middleware filters expired rows on
|
||||||
|
/// lookup, so this is just bounded-growth hygiene, not correctness.
|
||||||
|
async fn prune_expired(&self) -> Result<u64, AdminSessionRepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresAdminSessionRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresAdminSessionRepository {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AdminSessionRepository for PostgresAdminSessionRepository {
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
token_hash: &str,
|
||||||
|
expires_at: DateTime<Utc>,
|
||||||
|
) -> Result<(), AdminSessionRepositoryError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO admin_sessions (token_hash, user_id, expires_at) \
|
||||||
|
VALUES ($1, $2, $3)",
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.bind(expires_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn lookup(
|
||||||
|
&self,
|
||||||
|
token_hash: &str,
|
||||||
|
) -> Result<Option<AdminSessionLookup>, AdminSessionRepositoryError> {
|
||||||
|
let row: Option<(uuid::Uuid, DateTime<Utc>)> = sqlx::query_as(
|
||||||
|
"SELECT user_id, expires_at FROM admin_sessions \
|
||||||
|
WHERE token_hash = $1 AND expires_at > NOW()",
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|(uid, exp)| AdminSessionLookup {
|
||||||
|
user_id: uid.into(),
|
||||||
|
expires_at: exp,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn touch(
|
||||||
|
&self,
|
||||||
|
token_hash: &str,
|
||||||
|
new_expires_at: DateTime<Utc>,
|
||||||
|
) -> Result<(), AdminSessionRepositoryError> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE admin_sessions SET last_used_at = NOW(), expires_at = $2 \
|
||||||
|
WHERE token_hash = $1",
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(new_expires_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, token_hash: &str) -> Result<(), AdminSessionRepositoryError> {
|
||||||
|
sqlx::query("DELETE FROM admin_sessions WHERE token_hash = $1")
|
||||||
|
.bind(token_hash)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<u64, AdminSessionRepositoryError> {
|
||||||
|
let res = sqlx::query("DELETE FROM admin_sessions WHERE user_id = $1")
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prune_expired(&self) -> Result<u64, AdminSessionRepositoryError> {
|
||||||
|
let res = sqlx::query("DELETE FROM admin_sessions WHERE expires_at <= NOW()")
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected())
|
||||||
|
}
|
||||||
|
}
|
||||||
322
crates/manager-core/src/admin_user_repo.rs
Normal file
322
crates/manager-core/src/admin_user_repo.rs
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
//! CRUD over the `admin_users` table.
|
||||||
|
//!
|
||||||
|
//! Password hashes go in and come out as opaque strings — this module
|
||||||
|
//! never inspects or computes them; that's `auth.rs`'s job. The "must
|
||||||
|
//! keep at least one active admin" guard is implemented as a separate
|
||||||
|
//! count query the API layer composes around `set_active` / `delete`.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::AdminUserId;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AdminUserRepositoryError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
|
||||||
|
#[error("not found: {0}")]
|
||||||
|
NotFound(AdminUserId),
|
||||||
|
|
||||||
|
#[error("username already taken: {0}")]
|
||||||
|
DuplicateUsername(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row returned to handlers and bootstrap. Never includes the password
|
||||||
|
/// hash by accident — that lives in `AdminUserCredentials` (separate
|
||||||
|
/// fetch from `get_credentials_by_username`).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AdminUserRow {
|
||||||
|
pub id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub last_login_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Credentials fetched for the login path only. Splitting the hash off
|
||||||
|
/// from the public row makes it obvious in handler code which calls
|
||||||
|
/// touch a secret.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AdminUserCredentials {
|
||||||
|
pub id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
pub is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AdminUserRepository: Send + Sync {
|
||||||
|
async fn get(&self, id: AdminUserId) -> Result<Option<AdminUserRow>, AdminUserRepositoryError>;
|
||||||
|
async fn get_by_username(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError>;
|
||||||
|
async fn get_credentials_by_username(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError>;
|
||||||
|
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
|
async fn update_username(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
|
async fn update_password_hash(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
|
async fn set_active(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
is_active: bool,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
|
async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError>;
|
||||||
|
async fn touch_last_login(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError>;
|
||||||
|
/// Count of `is_active = true` rows. Used at bootstrap to decide
|
||||||
|
/// whether to seed the first admin.
|
||||||
|
async fn count_active(&self) -> Result<i64, AdminUserRepositoryError>;
|
||||||
|
/// Count of `is_active = true` rows excluding the given id. Used by
|
||||||
|
/// last-admin protection: "would deactivating / deleting this user
|
||||||
|
/// leave zero active admins?"
|
||||||
|
async fn count_active_excluding(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
) -> Result<i64, AdminUserRepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresAdminUserRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresAdminUserRepository {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AdminUserRepository for PostgresAdminUserRepository {
|
||||||
|
async fn get(&self, id: AdminUserId) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
||||||
|
FROM admin_users WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_username(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
||||||
|
FROM admin_users WHERE username = $1",
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_credentials_by_username(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AdminCredsRecord>(
|
||||||
|
"SELECT id, username, password_hash, is_active \
|
||||||
|
FROM admin_users WHERE username = $1",
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
||||||
|
FROM admin_users ORDER BY username",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"INSERT INTO admin_users (username, password_hash) \
|
||||||
|
VALUES ($1, $2) \
|
||||||
|
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.bind(password_hash)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(row) => Ok(row.into()),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||||
|
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
|
||||||
|
),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_username(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"UPDATE admin_users SET username = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(Some(row)) => Ok(row.into()),
|
||||||
|
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||||
|
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
|
||||||
|
),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_password_hash(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"UPDATE admin_users SET password_hash = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(password_hash)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(Into::into)
|
||||||
|
.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_active(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
is_active: bool,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"UPDATE admin_users SET is_active = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(is_active)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(Into::into)
|
||||||
|
.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||||
|
let res = sqlx::query("DELETE FROM admin_users WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(AdminUserRepositoryError::NotFound(id));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn touch_last_login(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||||
|
sqlx::query("UPDATE admin_users SET last_login_at = NOW() WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_active(&self) -> Result<i64, AdminUserRepositoryError> {
|
||||||
|
let (count,): (i64,) =
|
||||||
|
sqlx::query_as("SELECT COUNT(*)::BIGINT FROM admin_users WHERE is_active")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_active_excluding(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
) -> Result<i64, AdminUserRepositoryError> {
|
||||||
|
let (count,): (i64,) =
|
||||||
|
sqlx::query_as("SELECT COUNT(*)::BIGINT FROM admin_users WHERE is_active AND id <> $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AdminUserRecord {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
username: String,
|
||||||
|
is_active: bool,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
updated_at: DateTime<Utc>,
|
||||||
|
last_login_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AdminUserRecord> for AdminUserRow {
|
||||||
|
fn from(r: AdminUserRecord) -> Self {
|
||||||
|
Self {
|
||||||
|
id: r.id.into(),
|
||||||
|
username: r.username,
|
||||||
|
is_active: r.is_active,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
last_login_at: r.last_login_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AdminCredsRecord {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
username: String,
|
||||||
|
password_hash: String,
|
||||||
|
is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AdminCredsRecord> for AdminUserCredentials {
|
||||||
|
fn from(r: AdminCredsRecord) -> Self {
|
||||||
|
Self {
|
||||||
|
id: r.id.into(),
|
||||||
|
username: r.username,
|
||||||
|
password_hash: r.password_hash,
|
||||||
|
is_active: r.is_active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
320
crates/manager-core/src/admin_users_api.rs
Normal file
320
crates/manager-core/src/admin_users_api.rs
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
//! `/api/v1/admin/admins/*` — admin user CRUD. Guarded by
|
||||||
|
//! `require_admin`; every authenticated admin can call all of these.
|
||||||
|
//! Role/permission walls land later (see blueprint §11.4 — no
|
||||||
|
//! privilege levels in this cut).
|
||||||
|
//!
|
||||||
|
//! "Last active admin" protection lives at the service layer (not just
|
||||||
|
//! the DB) so it can produce a clean 422 with a human-readable message
|
||||||
|
//! rather than a SQL constraint violation. Deactivating a user also
|
||||||
|
//! wipes their sessions; deleting cascades through the FK.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::Router;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::AdminUserId;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::admin_session_repo::AdminSessionRepository;
|
||||||
|
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
|
||||||
|
use crate::auth::hash_password;
|
||||||
|
|
||||||
|
/// Validation knobs are tuned by NIST 800-63B-ish guidance: username is
|
||||||
|
/// a strict ASCII subset so the lookup column stays predictable, and
|
||||||
|
/// password has a minimum length but no complexity rules (complexity
|
||||||
|
/// rules push users to predictable patterns).
|
||||||
|
const USERNAME_MIN: usize = 2;
|
||||||
|
const USERNAME_MAX: usize = 32;
|
||||||
|
const PASSWORD_MIN: usize = 8;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AdminsState {
|
||||||
|
pub users: Arc<dyn AdminUserRepository>,
|
||||||
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn admins_router(state: AdminsState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/admins", get(list_admins).post(create_admin))
|
||||||
|
.route(
|
||||||
|
"/admins/{id}",
|
||||||
|
get(get_admin).patch(patch_admin).delete(delete_admin),
|
||||||
|
)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// DTOs
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AdminDto {
|
||||||
|
pub id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub last_login_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AdminUserRow> for AdminDto {
|
||||||
|
fn from(r: AdminUserRow) -> Self {
|
||||||
|
Self {
|
||||||
|
id: r.id,
|
||||||
|
username: r.username,
|
||||||
|
is_active: r.is_active,
|
||||||
|
created_at: r.created_at,
|
||||||
|
last_login_at: r.last_login_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateAdminRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
pub struct PatchAdminRequest {
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub is_active: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_admins(
|
||||||
|
State(state): State<AdminsState>,
|
||||||
|
) -> Result<Json<Vec<AdminDto>>, AdminApiError> {
|
||||||
|
let rows = state.users.list().await?;
|
||||||
|
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_admin(
|
||||||
|
State(state): State<AdminsState>,
|
||||||
|
Path(id): Path<AdminUserId>,
|
||||||
|
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||||
|
state
|
||||||
|
.users
|
||||||
|
.get(id)
|
||||||
|
.await?
|
||||||
|
.map(AdminDto::from)
|
||||||
|
.map(Json)
|
||||||
|
.ok_or(AdminApiError::NotFound(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_admin(
|
||||||
|
State(state): State<AdminsState>,
|
||||||
|
Json(input): Json<CreateAdminRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<AdminDto>), AdminApiError> {
|
||||||
|
let username = input.username.trim();
|
||||||
|
validate_username(username)?;
|
||||||
|
validate_password(&input.password)?;
|
||||||
|
let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
|
||||||
|
let row = state.users.create(username, &hash).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(row.into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_admin(
|
||||||
|
State(state): State<AdminsState>,
|
||||||
|
Path(id): Path<AdminUserId>,
|
||||||
|
Json(input): Json<PatchAdminRequest>,
|
||||||
|
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||||
|
// Verify the target exists upfront — keeps the error path uniform
|
||||||
|
// for "rename a missing user" etc.
|
||||||
|
let _ = state
|
||||||
|
.users
|
||||||
|
.get(id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AdminApiError::NotFound(id))?;
|
||||||
|
|
||||||
|
let mut latest: Option<AdminUserRow> = None;
|
||||||
|
|
||||||
|
if let Some(raw_username) = input.username.as_deref() {
|
||||||
|
let new_username = raw_username.trim();
|
||||||
|
validate_username(new_username)?;
|
||||||
|
latest = Some(state.users.update_username(id, new_username).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(new_password) = input.password.as_deref() {
|
||||||
|
validate_password(new_password)?;
|
||||||
|
let hash = hash_password(new_password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
|
||||||
|
latest = Some(state.users.update_password_hash(id, &hash).await?);
|
||||||
|
// Best practice: rotating your own password should still keep
|
||||||
|
// your session alive, so we don't wipe sessions here. (If we
|
||||||
|
// wanted "log everyone else out on password change", that'd be
|
||||||
|
// a `delete_for_user` + re-issue current session. Out of scope
|
||||||
|
// for the initial cut.)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(new_active) = input.is_active {
|
||||||
|
// Last-active-admin guard: only when transitioning to inactive.
|
||||||
|
if !new_active {
|
||||||
|
let remaining = state.users.count_active_excluding(id).await?;
|
||||||
|
if remaining == 0 {
|
||||||
|
return Err(AdminApiError::LastActiveAdmin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
latest = Some(state.users.set_active(id, new_active).await?);
|
||||||
|
// Deactivation invalidates all of the user's sessions. Cheap
|
||||||
|
// and safer than waiting for sliding-window expiry.
|
||||||
|
if !new_active {
|
||||||
|
if let Err(err) = state.sessions.delete_for_user(id).await {
|
||||||
|
tracing::error!(?err, "failed to delete sessions for deactivated admin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = match latest {
|
||||||
|
Some(r) => r,
|
||||||
|
None => state
|
||||||
|
.users
|
||||||
|
.get(id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AdminApiError::NotFound(id))?,
|
||||||
|
};
|
||||||
|
Ok(Json(row.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_admin(
|
||||||
|
State(state): State<AdminsState>,
|
||||||
|
Path(id): Path<AdminUserId>,
|
||||||
|
) -> Result<StatusCode, AdminApiError> {
|
||||||
|
let target = state
|
||||||
|
.users
|
||||||
|
.get(id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AdminApiError::NotFound(id))?;
|
||||||
|
if target.is_active {
|
||||||
|
let remaining = state.users.count_active_excluding(id).await?;
|
||||||
|
if remaining == 0 {
|
||||||
|
return Err(AdminApiError::LastActiveAdmin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.users.delete(id).await?;
|
||||||
|
// Sessions cascade via FK; no explicit delete needed.
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Validation
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn validate_username(s: &str) -> Result<(), AdminApiError> {
|
||||||
|
if s.len() < USERNAME_MIN || s.len() > USERNAME_MAX {
|
||||||
|
return Err(AdminApiError::InvalidUsername(format!(
|
||||||
|
"username must be {USERNAME_MIN}-{USERNAME_MAX} characters"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !s
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-'))
|
||||||
|
{
|
||||||
|
return Err(AdminApiError::InvalidUsername(
|
||||||
|
"username may contain only lowercase letters, digits, dot, underscore, and hyphen"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_password(s: &str) -> Result<(), AdminApiError> {
|
||||||
|
if s.chars().count() < PASSWORD_MIN {
|
||||||
|
return Err(AdminApiError::InvalidPassword(format!(
|
||||||
|
"password must be at least {PASSWORD_MIN} characters"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AdminApiError {
|
||||||
|
#[error("admin user not found: {0}")]
|
||||||
|
NotFound(AdminUserId),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
InvalidUsername(String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
InvalidPassword(String),
|
||||||
|
|
||||||
|
#[error("cannot leave the system with zero active admins")]
|
||||||
|
LastActiveAdmin,
|
||||||
|
|
||||||
|
#[error("failed to hash password: {0}")]
|
||||||
|
Hash(String),
|
||||||
|
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Repo(#[from] AdminUserRepositoryError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AdminApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, message) = match &self {
|
||||||
|
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||||
|
Self::Repo(AdminUserRepositoryError::DuplicateUsername(_)) => {
|
||||||
|
(StatusCode::CONFLICT, self.to_string())
|
||||||
|
}
|
||||||
|
Self::InvalidUsername(_) | Self::InvalidPassword(_) | Self::LastActiveAdmin => {
|
||||||
|
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||||
|
}
|
||||||
|
Self::Repo(AdminUserRepositoryError::NotFound(_)) => {
|
||||||
|
(StatusCode::NOT_FOUND, self.to_string())
|
||||||
|
}
|
||||||
|
Self::Repo(AdminUserRepositoryError::Db(e)) => {
|
||||||
|
tracing::error!(error = %e, "admin_users db error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Hash(_) => {
|
||||||
|
tracing::error!(error = %self, "password hashing failed");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(json!({ "error": message }))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn username_validation_accepts_valid() {
|
||||||
|
for u in ["ab", "alice", "user.name", "a_b-c", "00bot00"] {
|
||||||
|
assert!(validate_username(u).is_ok(), "should accept {u}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn username_validation_rejects_invalid() {
|
||||||
|
for u in ["", "a", "Alice", "user name", "user@domain", "user!"] {
|
||||||
|
assert!(validate_username(u).is_err(), "should reject {u:?}");
|
||||||
|
}
|
||||||
|
let too_long = "x".repeat(33);
|
||||||
|
assert!(validate_username(&too_long).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn password_validation_enforces_min_length() {
|
||||||
|
assert!(validate_password("1234567").is_err());
|
||||||
|
assert!(validate_password("12345678").is_ok());
|
||||||
|
assert!(validate_password("a-very-long-password-with-spaces and stuff").is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
132
crates/manager-core/src/auth.rs
Normal file
132
crates/manager-core/src/auth.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
//! Pure auth helpers: password hashing, session-token generation, and
|
||||||
|
//! token-to-hash conversion. No DB, no HTTP — repos and middleware live
|
||||||
|
//! in their own modules. Keeping this surface pure also keeps the unit
|
||||||
|
//! tests fast (no Postgres needed).
|
||||||
|
//!
|
||||||
|
//! Hash algorithm is Argon2id with the OWASP default parameters
|
||||||
|
//! (`Argon2::default()`). Tokens are 32 cryptographically random bytes
|
||||||
|
//! base64-url-encoded for the wire; their SHA-256 (hex) is what hits the
|
||||||
|
//! sessions table.
|
||||||
|
|
||||||
|
use argon2::password_hash::rand_core::OsRng as ArgonRng;
|
||||||
|
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||||
|
use argon2::Argon2;
|
||||||
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
|
use base64::Engine as _;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// Returned when the supplied password hash string isn't a valid PHC
|
||||||
|
/// Argon2id encoding. Only surfaces at bootstrap time when the operator
|
||||||
|
/// passes `PICLOUD_ADMIN_PASSWORD_HASH`.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[error("invalid Argon2id PHC hash")]
|
||||||
|
pub struct InvalidPasswordHash;
|
||||||
|
|
||||||
|
/// Hash a raw password into an Argon2id PHC-formatted string suitable
|
||||||
|
/// for `admin_users.password_hash`. The output already encodes the salt
|
||||||
|
/// and parameters; nothing else needs to be persisted alongside it.
|
||||||
|
pub fn hash_password(raw: &str) -> Result<String, argon2::password_hash::Error> {
|
||||||
|
let salt = SaltString::generate(&mut ArgonRng);
|
||||||
|
let hash = Argon2::default().hash_password(raw.as_bytes(), &salt)?;
|
||||||
|
Ok(hash.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constant-ish-time verify of a raw password against a PHC hash.
|
||||||
|
/// Returns `false` for any error (including malformed stored hash) —
|
||||||
|
/// callers should treat that case identically to "wrong password" so
|
||||||
|
/// nothing leaks about why auth failed.
|
||||||
|
#[must_use]
|
||||||
|
pub fn verify_password(stored_hash: &str, raw: &str) -> bool {
|
||||||
|
let Ok(parsed) = PasswordHash::new(stored_hash) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(raw.as_bytes(), &parsed)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that a string parses as a PHC Argon2id hash — used at
|
||||||
|
/// bootstrap to fail fast on malformed `PICLOUD_ADMIN_PASSWORD_HASH`
|
||||||
|
/// rather than write garbage into the DB and discover it at first login.
|
||||||
|
pub fn validate_password_hash(stored_hash: &str) -> Result<(), InvalidPasswordHash> {
|
||||||
|
PasswordHash::new(stored_hash).map_err(|_| InvalidPasswordHash)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newly minted session token: `raw` goes to the client (cookie + JSON
|
||||||
|
/// response), `hash` is what gets stored. Raw is unrecoverable from hash
|
||||||
|
/// even if the DB leaks.
|
||||||
|
pub struct GeneratedToken {
|
||||||
|
pub raw: String,
|
||||||
|
pub hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a fresh session token (32 random bytes base64-url-encoded).
|
||||||
|
/// Always succeeds — `OsRng::fill_bytes` panics on entropy failure
|
||||||
|
/// instead of returning, but that's a non-recoverable system condition.
|
||||||
|
#[must_use]
|
||||||
|
pub fn generate_session_token() -> GeneratedToken {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
let raw = URL_SAFE_NO_PAD.encode(bytes);
|
||||||
|
let hash = hash_token(&raw);
|
||||||
|
GeneratedToken { raw, hash }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SHA-256(raw) as lower-case hex. Stable lookup key for
|
||||||
|
/// `admin_sessions.token_hash`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn hash_token(raw: &str) -> String {
|
||||||
|
let digest = Sha256::digest(raw.as_bytes());
|
||||||
|
hex(&digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||||
|
let mut out = String::with_capacity(bytes.len() * 2);
|
||||||
|
for &b in bytes {
|
||||||
|
out.push(HEX[(b >> 4) as usize] as char);
|
||||||
|
out.push(HEX[(b & 0x0f) as usize] as char);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_verify_roundtrip() {
|
||||||
|
let h = hash_password("correct horse battery staple").unwrap();
|
||||||
|
assert!(verify_password(&h, "correct horse battery staple"));
|
||||||
|
assert!(!verify_password(&h, "wrong"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_returns_false_on_malformed_hash() {
|
||||||
|
assert!(!verify_password("not-a-phc-string", "anything"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_password_hash_accepts_phc() {
|
||||||
|
let h = hash_password("pw").unwrap();
|
||||||
|
assert!(validate_password_hash(&h).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_password_hash_rejects_garbage() {
|
||||||
|
assert!(validate_password_hash("not a hash").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_token_unique_and_hash_stable() {
|
||||||
|
let a = generate_session_token();
|
||||||
|
let b = generate_session_token();
|
||||||
|
assert_ne!(a.raw, b.raw, "tokens must be unique");
|
||||||
|
assert_ne!(a.hash, b.hash, "hashes must differ");
|
||||||
|
assert_eq!(a.hash, hash_token(&a.raw), "hash must be reproducible");
|
||||||
|
assert_eq!(a.hash.len(), 64, "sha256-hex is 64 chars");
|
||||||
|
}
|
||||||
|
}
|
||||||
233
crates/manager-core/src/auth_api.rs
Normal file
233
crates/manager-core/src/auth_api.rs
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
//! `/api/v1/admin/auth/*` — login, logout, who-am-I.
|
||||||
|
//!
|
||||||
|
//! Login mints an opaque session token, stores its SHA-256, sets the
|
||||||
|
//! `picloud_session` HttpOnly cookie, and also returns the raw token in
|
||||||
|
//! the JSON body for non-browser clients. The same token works as
|
||||||
|
//! `Authorization: Bearer …` afterward; there is no separate "API
|
||||||
|
//! token" concept yet.
|
||||||
|
//!
|
||||||
|
//! Logout deletes the session row regardless of whether the supplied
|
||||||
|
//! token matched anything (idempotent). `me` returns the row that the
|
||||||
|
//! middleware already attached to the request extensions.
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::{Extension, Request, State};
|
||||||
|
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||||
|
use axum::middleware::from_fn_with_state;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::Router;
|
||||||
|
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||||
|
use picloud_shared::AdminUserId;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::auth::{generate_session_token, hash_token, verify_password};
|
||||||
|
use crate::auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE};
|
||||||
|
|
||||||
|
pub fn auth_router(state: AuthState) -> Router {
|
||||||
|
// /login + /logout are unguarded (login is how you get in; logout
|
||||||
|
// is idempotent). /me is guarded — by definition it needs to know
|
||||||
|
// who you are, so the middleware must run first.
|
||||||
|
let guarded = Router::new()
|
||||||
|
.route("/auth/me", get(me))
|
||||||
|
.route_layer(from_fn_with_state(state.clone(), require_admin));
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.route("/auth/login", post(login))
|
||||||
|
.route("/auth/logout", post(logout))
|
||||||
|
.merge(guarded)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// DTOs
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LoginResponse {
|
||||||
|
pub user: AdminUserDto,
|
||||||
|
pub token: String,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AdminUserDto {
|
||||||
|
pub id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>) -> Response {
|
||||||
|
// Always perform a verify, even on missing/inactive users, to flatten
|
||||||
|
// timing and prevent username enumeration. The dummy hash is a real
|
||||||
|
// Argon2id PHC string for "x" — the verify will simply fail.
|
||||||
|
const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$dGltaW5nLWZsYXR0ZW4$Ux6dgPqgX1Mhg5fRgIeKZF3MWdYqJplKEz/cKLcSdks";
|
||||||
|
|
||||||
|
let creds = match state
|
||||||
|
.users
|
||||||
|
.get_credentials_by_username(&input.username)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_users credentials lookup failed");
|
||||||
|
return internal_error();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (stored_hash, user_id, username, is_active) = match creds {
|
||||||
|
Some(c) => (c.password_hash, Some(c.id), c.username, c.is_active),
|
||||||
|
None => (DUMMY_HASH.to_string(), None, String::new(), false),
|
||||||
|
};
|
||||||
|
|
||||||
|
let password_ok = verify_password(&stored_hash, &input.password);
|
||||||
|
if !password_ok || user_id.is_none() || !is_active {
|
||||||
|
return invalid_credentials();
|
||||||
|
}
|
||||||
|
let user_id = user_id.unwrap();
|
||||||
|
|
||||||
|
let token = generate_session_token();
|
||||||
|
let expires_at = Utc::now()
|
||||||
|
+ ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
|
||||||
|
|
||||||
|
if let Err(err) = state
|
||||||
|
.sessions
|
||||||
|
.create(user_id, &token.hash, expires_at)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(?err, "admin_sessions insert failed");
|
||||||
|
return internal_error();
|
||||||
|
}
|
||||||
|
if let Err(err) = state.users.touch_last_login(user_id).await {
|
||||||
|
// Non-fatal — log and continue. Login itself succeeded.
|
||||||
|
tracing::warn!(?err, "failed to touch admin last_login_at");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
HeaderValue::from_str(&build_cookie(&token.raw, state.ttl)).unwrap_or_else(|_| {
|
||||||
|
// Cookie text is ASCII-clean by construction; this branch is
|
||||||
|
// unreachable in practice but the type signature requires it.
|
||||||
|
HeaderValue::from_static("")
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
headers,
|
||||||
|
Json(LoginResponse {
|
||||||
|
user: AdminUserDto {
|
||||||
|
id: user_id,
|
||||||
|
username,
|
||||||
|
},
|
||||||
|
token: token.raw,
|
||||||
|
expires_at,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn logout(State(state): State<AuthState>, req: Request<Body>) -> Response {
|
||||||
|
// Pull token without requiring a valid session (logout is idempotent
|
||||||
|
// and we still want to clear the cookie on the client side).
|
||||||
|
let token = extract_token_for_logout(&req);
|
||||||
|
if let Some(raw) = token {
|
||||||
|
let hash = hash_token(&raw);
|
||||||
|
if let Err(err) = state.sessions.delete(&hash).await {
|
||||||
|
tracing::error!(?err, "admin_sessions delete failed");
|
||||||
|
// Still clear the cookie below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
HeaderValue::from_static("picloud_session=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0"),
|
||||||
|
);
|
||||||
|
(StatusCode::NO_CONTENT, headers).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn me(Extension(admin): Extension<AuthedAdmin>) -> Json<AdminUserDto> {
|
||||||
|
Json(AdminUserDto {
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn build_cookie(raw_token: &str, ttl: std::time::Duration) -> String {
|
||||||
|
// Secure is on by default; flip to off for HTTP-only dev with
|
||||||
|
// PICLOUD_COOKIE_SECURE=0. The header-injected bearer token works
|
||||||
|
// either way, so this is purely for browsers that prefer the cookie
|
||||||
|
// path (e.g., direct API hits without the dashboard's auth.ts).
|
||||||
|
let secure = std::env::var("PICLOUD_COOKIE_SECURE").ok().is_none_or(|v| {
|
||||||
|
!matches!(
|
||||||
|
v.to_ascii_lowercase().as_str(),
|
||||||
|
"0" | "false" | "no" | "off"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let secure_attr = if secure { "; Secure" } else { "" };
|
||||||
|
format!(
|
||||||
|
"{SESSION_COOKIE}={raw_token}; HttpOnly{secure_attr}; SameSite=Lax; Path=/; Max-Age={}",
|
||||||
|
ttl.as_secs()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_token_for_logout(req: &Request<Body>) -> Option<String> {
|
||||||
|
// Same precedence as the middleware — Authorization first, cookie
|
||||||
|
// fallback. Duplicated here because logout has to read the request
|
||||||
|
// before any middleware would run.
|
||||||
|
if let Some(value) = req.headers().get(header::AUTHORIZATION) {
|
||||||
|
if let Ok(s) = value.to_str() {
|
||||||
|
if let Some(token) = s.strip_prefix("Bearer ") {
|
||||||
|
let trimmed = token.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = req.headers().get(header::COOKIE) {
|
||||||
|
if let Ok(s) = value.to_str() {
|
||||||
|
for chunk in s.split(';') {
|
||||||
|
let chunk = chunk.trim();
|
||||||
|
if let Some(rest) = chunk.strip_prefix(&format!("{SESSION_COOKIE}=")) {
|
||||||
|
if !rest.is_empty() {
|
||||||
|
return Some(rest.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_credentials() -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(json!({ "error": "invalid credentials" })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal_error() -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": "internal error" })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
293
crates/manager-core/src/auth_bootstrap.rs
Normal file
293
crates/manager-core/src/auth_bootstrap.rs
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
//! First-run admin seeding from env vars. Idempotent: if any admin
|
||||||
|
//! already exists, this is a no-op (and a warning is logged when the
|
||||||
|
//! env vars are also set, so the operator notices the inert state).
|
||||||
|
//!
|
||||||
|
//! On a fresh install, exactly one row is inserted from:
|
||||||
|
//! - `PICLOUD_ADMIN_USERNAME` (required)
|
||||||
|
//! - `PICLOUD_ADMIN_PASSWORD_HASH` (preferred — pre-computed PHC) OR
|
||||||
|
//! - `PICLOUD_ADMIN_PASSWORD` (fallback — raw, hashed on the way in)
|
||||||
|
//!
|
||||||
|
//! After that initial seed, the env vars become inert. This is
|
||||||
|
//! deliberate: the env var is a one-time setup hatch, not a permanent
|
||||||
|
//! override (which would let anyone with systemd/compose access change
|
||||||
|
//! any admin's password without authentication). Recovery is the CLI
|
||||||
|
//! subcommand `picloud admin reset-password <username>`.
|
||||||
|
//!
|
||||||
|
//! The env-var reading is factored into `BootstrapEnv::from_process`
|
||||||
|
//! so the core logic stays pure (and testable) — the only side effect
|
||||||
|
//! in `bootstrap_first_admin` is the DB write and a tracing log.
|
||||||
|
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::admin_user_repo::AdminUserRepository;
|
||||||
|
use crate::auth::{hash_password, validate_password_hash};
|
||||||
|
|
||||||
|
pub const ENV_USERNAME: &str = "PICLOUD_ADMIN_USERNAME";
|
||||||
|
pub const ENV_PASSWORD: &str = "PICLOUD_ADMIN_PASSWORD";
|
||||||
|
pub const ENV_PASSWORD_HASH: &str = "PICLOUD_ADMIN_PASSWORD_HASH";
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum BootstrapError {
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Repo(#[from] crate::admin_user_repo::AdminUserRepositoryError),
|
||||||
|
|
||||||
|
#[error("{ENV_USERNAME} not set (required to bootstrap the first admin)")]
|
||||||
|
MissingUsername,
|
||||||
|
|
||||||
|
#[error(
|
||||||
|
"no admin password env var set; provide {ENV_PASSWORD_HASH} (preferred) or {ENV_PASSWORD}"
|
||||||
|
)]
|
||||||
|
MissingPassword,
|
||||||
|
|
||||||
|
#[error("{ENV_PASSWORD_HASH} is not a valid Argon2id PHC string")]
|
||||||
|
InvalidHash,
|
||||||
|
|
||||||
|
#[error("failed to hash password: {0}")]
|
||||||
|
HashFailure(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captured-at-call-site env values. The fields map 1:1 to the bootstrap
|
||||||
|
/// env vars. Read from the live process with `from_process`, or build
|
||||||
|
/// directly in tests to keep them free of process-env races.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct BootstrapEnv {
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub password_hash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BootstrapEnv {
|
||||||
|
/// Snapshot the bootstrap env vars from the current process.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_process() -> Self {
|
||||||
|
Self {
|
||||||
|
username: std::env::var(ENV_USERNAME).ok(),
|
||||||
|
password: std::env::var(ENV_PASSWORD).ok(),
|
||||||
|
password_hash: std::env::var(ENV_PASSWORD_HASH).ok(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn any_set(&self) -> bool {
|
||||||
|
self.username.is_some() || self.password.is_some() || self.password_hash.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the bootstrap. Reads env vars from the live process — the
|
||||||
|
/// canonical wiring for the binary.
|
||||||
|
pub async fn bootstrap_first_admin<R: AdminUserRepository + ?Sized>(
|
||||||
|
repo: &R,
|
||||||
|
) -> Result<(), BootstrapError> {
|
||||||
|
bootstrap_first_admin_with(repo, BootstrapEnv::from_process()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the bootstrap against an explicit env. Used by tests to keep
|
||||||
|
/// the bootstrap logic independent of process state.
|
||||||
|
pub async fn bootstrap_first_admin_with<R: AdminUserRepository + ?Sized>(
|
||||||
|
repo: &R,
|
||||||
|
env: BootstrapEnv,
|
||||||
|
) -> Result<(), BootstrapError> {
|
||||||
|
if repo.count_active().await? > 0 {
|
||||||
|
if env.any_set() {
|
||||||
|
warn!(
|
||||||
|
"{ENV_USERNAME}/{ENV_PASSWORD}/{ENV_PASSWORD_HASH} set but admin_users \
|
||||||
|
already populated — env values ignored. Use \
|
||||||
|
`picloud admin reset-password <user>` to change a password."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let username = env.username.ok_or(BootstrapError::MissingUsername)?;
|
||||||
|
|
||||||
|
let password_hash = match (env.password_hash, env.password) {
|
||||||
|
(Some(hash), maybe_raw) => {
|
||||||
|
if maybe_raw.is_some() {
|
||||||
|
warn!(
|
||||||
|
"both {ENV_PASSWORD_HASH} and {ENV_PASSWORD} set — \
|
||||||
|
using the pre-computed hash; raw password ignored."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validate_password_hash(&hash).map_err(|_| BootstrapError::InvalidHash)?;
|
||||||
|
hash
|
||||||
|
}
|
||||||
|
(None, Some(raw)) => {
|
||||||
|
hash_password(&raw).map_err(|e| BootstrapError::HashFailure(e.to_string()))?
|
||||||
|
}
|
||||||
|
(None, None) => return Err(BootstrapError::MissingPassword),
|
||||||
|
};
|
||||||
|
|
||||||
|
repo.create(&username, &password_hash).await?;
|
||||||
|
info!(username = %username, "bootstrapped initial admin user");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
//! These tests use an in-memory `AdminUserRepository` and the
|
||||||
|
//! `bootstrap_first_admin_with` overload so they never touch
|
||||||
|
//! process-global env vars. They can run in parallel safely.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::Utc;
|
||||||
|
use picloud_shared::AdminUserId;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::admin_user_repo::{AdminUserCredentials, AdminUserRepositoryError, AdminUserRow};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct InMemoryRepo {
|
||||||
|
rows: Mutex<Vec<AdminUserRow>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AdminUserRepository for InMemoryRepo {
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
_id: AdminUserId,
|
||||||
|
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn get_by_username(
|
||||||
|
&self,
|
||||||
|
_u: &str,
|
||||||
|
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn get_credentials_by_username(
|
||||||
|
&self,
|
||||||
|
_u: &str,
|
||||||
|
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
_password_hash: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let row = AdminUserRow {
|
||||||
|
id: AdminUserId::new(),
|
||||||
|
username: username.to_string(),
|
||||||
|
is_active: true,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
last_login_at: None,
|
||||||
|
};
|
||||||
|
self.rows.lock().unwrap().push(row.clone());
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
async fn update_username(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
_u: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn update_password_hash(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
_h: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn set_active(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
_a: bool,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn delete(&self, _i: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn touch_last_login(&self, _i: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn count_active(&self) -> Result<i64, AdminUserRepositoryError> {
|
||||||
|
Ok(i64::try_from(self.rows.lock().unwrap().len()).unwrap_or(i64::MAX))
|
||||||
|
}
|
||||||
|
async fn count_active_excluding(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
) -> Result<i64, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_db_creates_admin_from_raw_password() {
|
||||||
|
let repo = InMemoryRepo::default();
|
||||||
|
let env = BootstrapEnv {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: Some("supersecret".into()),
|
||||||
|
password_hash: None,
|
||||||
|
};
|
||||||
|
bootstrap_first_admin_with(&repo, env).await.unwrap();
|
||||||
|
assert_eq!(repo.rows.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_db_with_pre_hashed_password_succeeds() {
|
||||||
|
let repo = InMemoryRepo::default();
|
||||||
|
let prehashed = hash_password("pw").unwrap();
|
||||||
|
let env = BootstrapEnv {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: None,
|
||||||
|
password_hash: Some(prehashed),
|
||||||
|
};
|
||||||
|
bootstrap_first_admin_with(&repo, env).await.unwrap();
|
||||||
|
assert_eq!(repo.rows.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn populated_db_is_noop() {
|
||||||
|
let repo = InMemoryRepo::default();
|
||||||
|
repo.create("seeded", "x").await.unwrap();
|
||||||
|
let env = BootstrapEnv {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: Some("supersecret".into()),
|
||||||
|
password_hash: None,
|
||||||
|
};
|
||||||
|
bootstrap_first_admin_with(&repo, env).await.unwrap();
|
||||||
|
assert_eq!(repo.rows.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_username_fails() {
|
||||||
|
let repo = InMemoryRepo::default();
|
||||||
|
let env = BootstrapEnv {
|
||||||
|
username: None,
|
||||||
|
password: Some("supersecret".into()),
|
||||||
|
password_hash: None,
|
||||||
|
};
|
||||||
|
let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err();
|
||||||
|
assert!(matches!(err, BootstrapError::MissingUsername));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_password_fails() {
|
||||||
|
let repo = InMemoryRepo::default();
|
||||||
|
let env = BootstrapEnv {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: None,
|
||||||
|
password_hash: None,
|
||||||
|
};
|
||||||
|
let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err();
|
||||||
|
assert!(matches!(err, BootstrapError::MissingPassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_hash_fails() {
|
||||||
|
let repo = InMemoryRepo::default();
|
||||||
|
let env = BootstrapEnv {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: None,
|
||||||
|
password_hash: Some("not a phc hash".into()),
|
||||||
|
};
|
||||||
|
let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err();
|
||||||
|
assert!(matches!(err, BootstrapError::InvalidHash));
|
||||||
|
}
|
||||||
|
}
|
||||||
185
crates/manager-core/src/auth_middleware.rs
Normal file
185
crates/manager-core/src/auth_middleware.rs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
//! `require_admin` axum middleware: gates a router on a valid admin
|
||||||
|
//! session. Accepts the token from either the `picloud_session` cookie
|
||||||
|
//! or an `Authorization: Bearer …` header — same token system serves
|
||||||
|
//! the dashboard and CLI/CI clients.
|
||||||
|
//!
|
||||||
|
//! On success, injects `AuthedAdmin` as a request extension so handlers
|
||||||
|
//! can `Extension<AuthedAdmin>` to know who's calling. On failure,
|
||||||
|
//! returns 401 with a generic JSON body (no enumeration about whether
|
||||||
|
//! the token was wrong vs. the user was deactivated).
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::{Request, State};
|
||||||
|
use axum::http::{header, StatusCode};
|
||||||
|
use axum::middleware::Next;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use chrono::Utc;
|
||||||
|
use picloud_shared::AdminUserId;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::admin_session_repo::AdminSessionRepository;
|
||||||
|
use crate::admin_user_repo::AdminUserRepository;
|
||||||
|
use crate::auth::hash_token;
|
||||||
|
|
||||||
|
pub const SESSION_COOKIE: &str = "picloud_session";
|
||||||
|
|
||||||
|
/// Shared state for auth: the two repos plus the configured sliding
|
||||||
|
/// session TTL. Cheap to clone (`Arc` everywhere).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthState {
|
||||||
|
pub users: Arc<dyn AdminUserRepository>,
|
||||||
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||||
|
pub ttl: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request-extension type that authenticated handlers extract via
|
||||||
|
/// `Extension<AuthedAdmin>`. Available only inside guarded routers.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuthedAdmin {
|
||||||
|
pub id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Middleware function. Wire with
|
||||||
|
/// `axum::middleware::from_fn_with_state(auth_state, require_admin)`.
|
||||||
|
pub async fn require_admin(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
mut req: Request<Body>,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
let Some(token) = extract_token(&req) else {
|
||||||
|
return unauthorized();
|
||||||
|
};
|
||||||
|
let token_hash = hash_token(&token);
|
||||||
|
|
||||||
|
let lookup = match state.sessions.lookup(&token_hash).await {
|
||||||
|
Ok(Some(lookup)) => lookup,
|
||||||
|
Ok(None) => return unauthorized(),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_sessions lookup failed");
|
||||||
|
return internal_error();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve the user. A deleted user is impossible here (FK cascade
|
||||||
|
// wipes their sessions), but a deactivated user still needs to be
|
||||||
|
// rejected — and so does the edge case of a session predating the
|
||||||
|
// deactivate (we wipe their sessions on deactivate, but a race
|
||||||
|
// could land a request in flight).
|
||||||
|
let user = match state.users.get(lookup.user_id).await {
|
||||||
|
Ok(Some(u)) if u.is_active => u,
|
||||||
|
Ok(_) => return unauthorized(),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_users lookup failed");
|
||||||
|
return internal_error();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sliding window bump. Inline (not fire-and-forget) so a DB blip
|
||||||
|
// surfaces as a request error rather than silent stale sessions.
|
||||||
|
let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default();
|
||||||
|
if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await {
|
||||||
|
tracing::error!(?err, "admin_sessions touch failed");
|
||||||
|
return internal_error();
|
||||||
|
}
|
||||||
|
|
||||||
|
req.extensions_mut().insert(AuthedAdmin {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
});
|
||||||
|
next.run(req).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the bearer token out of an `Authorization` header (preferred)
|
||||||
|
/// or the `picloud_session` cookie (fallback for browser clients).
|
||||||
|
fn extract_token(req: &Request<Body>) -> Option<String> {
|
||||||
|
if let Some(value) = req.headers().get(header::AUTHORIZATION) {
|
||||||
|
if let Ok(s) = value.to_str() {
|
||||||
|
if let Some(token) = s.strip_prefix("Bearer ") {
|
||||||
|
let trimmed = token.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = req.headers().get(header::COOKIE) {
|
||||||
|
if let Ok(s) = value.to_str() {
|
||||||
|
for chunk in s.split(';') {
|
||||||
|
let chunk = chunk.trim();
|
||||||
|
if let Some(rest) = chunk.strip_prefix(&format!("{SESSION_COOKIE}=")) {
|
||||||
|
if !rest.is_empty() {
|
||||||
|
return Some(rest.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unauthorized() -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(json!({ "error": "authentication required" })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal_error() -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": "internal error" })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::http::Request;
|
||||||
|
|
||||||
|
fn req_with_header(name: &str, value: &str) -> Request<Body> {
|
||||||
|
Request::builder()
|
||||||
|
.header(name, value)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_bearer_token() {
|
||||||
|
let r = req_with_header("authorization", "Bearer abc123");
|
||||||
|
assert_eq!(extract_token(&r).as_deref(), Some("abc123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_bearer_with_no_token() {
|
||||||
|
let r = req_with_header("authorization", "Bearer ");
|
||||||
|
assert_eq!(extract_token(&r), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_cookie_token() {
|
||||||
|
let r = req_with_header("cookie", "foo=bar; picloud_session=xyz; baz=qux");
|
||||||
|
assert_eq!(extract_token(&r).as_deref(), Some("xyz"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bearer_wins_over_cookie() {
|
||||||
|
let r = Request::builder()
|
||||||
|
.header("authorization", "Bearer header-token")
|
||||||
|
.header("cookie", "picloud_session=cookie-token")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(extract_token(&r).as_deref(), Some("header-token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_none_when_neither_present() {
|
||||||
|
let r = Request::builder().body(Body::empty()).unwrap();
|
||||||
|
assert_eq!(extract_token(&r), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,14 @@
|
|||||||
//! the same DB for now; once we add caching and per-node ingress, the
|
//! the same DB for now; once we add caching and per-node ingress, the
|
||||||
//! manager will publish change events.
|
//! manager will publish change events.
|
||||||
|
|
||||||
|
pub mod admin_session_repo;
|
||||||
|
pub mod admin_user_repo;
|
||||||
|
pub mod admin_users_api;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod auth_api;
|
||||||
|
pub mod auth_bootstrap;
|
||||||
|
pub mod auth_middleware;
|
||||||
pub mod log_sink;
|
pub mod log_sink;
|
||||||
pub mod migrations;
|
pub mod migrations;
|
||||||
pub mod repo;
|
pub mod repo;
|
||||||
@@ -13,7 +20,21 @@ pub mod route_repo;
|
|||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
|
|
||||||
|
pub use admin_session_repo::{
|
||||||
|
AdminSessionLookup, AdminSessionRepository, AdminSessionRepositoryError,
|
||||||
|
PostgresAdminSessionRepository,
|
||||||
|
};
|
||||||
|
pub use admin_user_repo::{
|
||||||
|
AdminUserCredentials, AdminUserRepository, AdminUserRepositoryError, AdminUserRow,
|
||||||
|
PostgresAdminUserRepository,
|
||||||
|
};
|
||||||
|
pub use admin_users_api::{admins_router, AdminsState};
|
||||||
pub use api::{admin_router, AdminState};
|
pub use api::{admin_router, AdminState};
|
||||||
|
pub use auth_api::auth_router;
|
||||||
|
pub use auth_bootstrap::{
|
||||||
|
bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError,
|
||||||
|
};
|
||||||
|
pub use auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE};
|
||||||
pub use log_sink::PostgresExecutionLogSink;
|
pub use log_sink::PostgresExecutionLogSink;
|
||||||
pub use repo::{
|
pub use repo::{
|
||||||
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
|
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::middleware::from_fn_with_state;
|
||||||
use axum::{routing::get, Json, Router};
|
use axum::{routing::get, Json, Router};
|
||||||
use picloud_executor_core::{Engine, Limits};
|
use picloud_executor_core::{Engine, Limits};
|
||||||
use picloud_manager_core::{
|
use picloud_manager_core::{
|
||||||
admin_router, compile_routes, migrations, route_admin_router, AdminState,
|
admin_router, admins_router, auth_router, compile_routes, migrations, require_admin,
|
||||||
|
route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
|
||||||
|
AuthState, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
||||||
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
||||||
};
|
};
|
||||||
@@ -24,6 +27,38 @@ use sqlx::postgres::PgPoolOptions;
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
|
/// Default session TTL when `PICLOUD_SESSION_TTL_HOURS` isn't set.
|
||||||
|
const DEFAULT_SESSION_TTL_HOURS: u64 = 24;
|
||||||
|
|
||||||
|
/// Bundles the auth-related dependencies that both `build_app` and the
|
||||||
|
/// startup bootstrap need. Built once in `main.rs` from the shared pool.
|
||||||
|
pub struct AuthDeps {
|
||||||
|
pub users: Arc<dyn AdminUserRepository>,
|
||||||
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||||
|
pub ttl: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthDeps {
|
||||||
|
/// Construct from a pool with the binary's standard defaults.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_pool(pool: PgPool) -> Self {
|
||||||
|
Self {
|
||||||
|
users: Arc::new(PostgresAdminUserRepository::new(pool.clone())),
|
||||||
|
sessions: Arc::new(PostgresAdminSessionRepository::new(pool)),
|
||||||
|
ttl: read_session_ttl(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_session_ttl() -> Duration {
|
||||||
|
let hours = std::env::var("PICLOUD_SESSION_TTL_HOURS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
|
.filter(|h| *h > 0)
|
||||||
|
.unwrap_or(DEFAULT_SESSION_TTL_HOURS);
|
||||||
|
Duration::from_secs(hours * 3600)
|
||||||
|
}
|
||||||
|
|
||||||
/// Compose the manager + orchestrator routes on top of a shared
|
/// Compose the manager + orchestrator routes on top of a shared
|
||||||
/// Postgres pool, returning an Axum router ready to be served.
|
/// Postgres pool, returning an Axum router ready to be served.
|
||||||
///
|
///
|
||||||
@@ -31,7 +66,15 @@ use tower_http::trace::TraceLayer;
|
|||||||
/// is mounted by Caddy at `/admin/*` (its base path). Anything else
|
/// is mounted by Caddy at `/admin/*` (its base path). Anything else
|
||||||
/// falls through to the user-route table — user scripts can bind to
|
/// falls through to the user-route table — user scripts can bind to
|
||||||
/// arbitrary paths (subject to the reserved-prefix list).
|
/// arbitrary paths (subject to the reserved-prefix list).
|
||||||
pub async fn build_app(pool: PgPool) -> anyhow::Result<Router> {
|
///
|
||||||
|
/// `auth` carries the admin user/session repositories and the
|
||||||
|
/// configured session TTL. The manager-side admin endpoints
|
||||||
|
/// (`/api/v1/admin/scripts/*`, `/api/v1/admin/routes/*`,
|
||||||
|
/// `/api/v1/admin/admins/*`, `/api/v1/admin/auth/me`) are guarded by
|
||||||
|
/// the `require_admin` middleware. The data plane
|
||||||
|
/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`,
|
||||||
|
/// `/version`) stays open — it's the public ingress for user scripts.
|
||||||
|
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||||
let engine = Arc::new(Engine::new(Limits::default()));
|
let engine = Arc::new(Engine::new(Limits::default()));
|
||||||
|
|
||||||
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
||||||
@@ -68,9 +111,29 @@ pub async fn build_app(pool: PgPool) -> anyhow::Result<Router> {
|
|||||||
routes: route_table,
|
routes: route_table,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let auth_state = AuthState {
|
||||||
|
users: auth.users.clone(),
|
||||||
|
sessions: auth.sessions.clone(),
|
||||||
|
ttl: auth.ttl,
|
||||||
|
};
|
||||||
|
let admins_state = AdminsState {
|
||||||
|
users: auth.users,
|
||||||
|
sessions: auth.sessions,
|
||||||
|
};
|
||||||
|
|
||||||
|
// /admin/auth/login + /logout are unguarded by design (login is how
|
||||||
|
// you get in). /admin/auth/me applies the middleware internally so
|
||||||
|
// the same Router::with_state machinery composes cleanly. Everything
|
||||||
|
// else under /admin gets the require_admin layer.
|
||||||
|
let guarded_admin = Router::new()
|
||||||
|
.merge(admin_router(admin))
|
||||||
|
.merge(route_admin_router(route_admin))
|
||||||
|
.merge(admins_router(admins_state))
|
||||||
|
.layer(from_fn_with_state(auth_state.clone(), require_admin));
|
||||||
|
|
||||||
let api_v1 = Router::new()
|
let api_v1 = Router::new()
|
||||||
.nest("/admin", admin_router(admin))
|
.nest("/admin", auth_router(auth_state))
|
||||||
.nest("/admin", route_admin_router(route_admin))
|
.nest("/admin", guarded_admin)
|
||||||
.merge(data_plane_router(data_plane.clone()));
|
.merge(data_plane_router(data_plane.clone()));
|
||||||
|
|
||||||
Ok(Router::new()
|
Ok(Router::new()
|
||||||
|
|||||||
@@ -1,17 +1,36 @@
|
|||||||
//! PiCloud all-in-one binary — see `lib.rs` for the actual app
|
//! PiCloud all-in-one binary — see `lib.rs` for the actual app
|
||||||
//! composition; this file is only the runtime shell (env config,
|
//! composition; this file is only the runtime shell (env config,
|
||||||
//! logger, migrations, listener).
|
//! logger, migrations, listener) plus the small `admin` CLI subcommand
|
||||||
|
//! used for out-of-band password recovery.
|
||||||
|
|
||||||
|
use std::io::{BufRead, Write};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use picloud::{build_app, init_db};
|
use picloud::{build_app, init_db, AuthDeps};
|
||||||
use picloud_manager_core::migrations;
|
use picloud_manager_core::{
|
||||||
|
auth::{hash_password, validate_password_hash},
|
||||||
|
bootstrap_first_admin, migrations, AdminSessionRepository, AdminUserRepository,
|
||||||
|
};
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
init_tracing();
|
init_tracing();
|
||||||
|
|
||||||
|
// Subcommand dispatch — `picloud admin reset-password <username>`.
|
||||||
|
// Kept handwritten to avoid pulling clap in just for one verb. Falls
|
||||||
|
// through to the server when no subcommand is given.
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
if args.get(1).map(String::as_str) == Some("admin") {
|
||||||
|
return run_admin_cli(&args[2..]).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
run_server().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_server() -> anyhow::Result<()> {
|
||||||
let addr: SocketAddr = std::env::var("PICLOUD_BIND")
|
let addr: SocketAddr = std::env::var("PICLOUD_BIND")
|
||||||
.unwrap_or_else(|_| "0.0.0.0:8080".into())
|
.unwrap_or_else(|_| "0.0.0.0:8080".into())
|
||||||
.parse()?;
|
.parse()?;
|
||||||
@@ -22,7 +41,15 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
migrations::run(&pool).await?;
|
migrations::run(&pool).await?;
|
||||||
tracing::info!("migrations applied");
|
tracing::info!("migrations applied");
|
||||||
|
|
||||||
let app = build_app(pool).await?;
|
let auth = AuthDeps::from_pool(pool.clone());
|
||||||
|
bootstrap_first_admin(&*auth.users).await?;
|
||||||
|
|
||||||
|
// Background session-prune sweep. Cheap; keeps the table from
|
||||||
|
// growing unbounded. Expired rows are also rejected at lookup time,
|
||||||
|
// so a delayed sweep can't extend session lifetimes.
|
||||||
|
spawn_session_pruner(auth.sessions.clone());
|
||||||
|
|
||||||
|
let app = build_app(pool, auth).await?;
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
tracing::info!(%addr, "picloud all-in-one listening");
|
tracing::info!(%addr, "picloud all-in-one listening");
|
||||||
@@ -33,6 +60,112 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn spawn_session_pruner(sessions: Arc<dyn AdminSessionRepository>) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut ticker = tokio::time::interval(Duration::from_secs(600));
|
||||||
|
// First tick fires immediately; skip it so we don't race startup.
|
||||||
|
ticker.tick().await;
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
match sessions.prune_expired().await {
|
||||||
|
Ok(n) if n > 0 => tracing::debug!(pruned = n, "expired admin sessions pruned"),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) => tracing::warn!(?err, "admin session prune failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// `admin` subcommand
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn run_admin_cli(args: &[String]) -> anyhow::Result<()> {
|
||||||
|
match args.first().map(String::as_str) {
|
||||||
|
Some("reset-password") => {
|
||||||
|
let username = args.get(1).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"usage: picloud admin reset-password <username> [--password-hash <hash>]"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
// Optional inline hash via --password-hash <hash>; otherwise
|
||||||
|
// read a raw password from stdin.
|
||||||
|
let hash_arg = parse_flag(&args[2..], "--password-hash");
|
||||||
|
cmd_reset_password(username, hash_arg).await
|
||||||
|
}
|
||||||
|
Some(other) => Err(anyhow::anyhow!("unknown admin subcommand: {other}")),
|
||||||
|
None => Err(anyhow::anyhow!(
|
||||||
|
"usage: picloud admin reset-password <username>"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_flag(args: &[String], name: &str) -> Option<String> {
|
||||||
|
let mut it = args.iter();
|
||||||
|
while let Some(a) = it.next() {
|
||||||
|
if a == name {
|
||||||
|
return it.next().cloned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cmd_reset_password(username: &str, password_hash: Option<String>) -> anyhow::Result<()> {
|
||||||
|
let database_url =
|
||||||
|
std::env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL is required"))?;
|
||||||
|
let pool = init_db(&database_url).await?;
|
||||||
|
migrations::run(&pool).await?;
|
||||||
|
|
||||||
|
let users = picloud_manager_core::PostgresAdminUserRepository::new(pool.clone());
|
||||||
|
let sessions = picloud_manager_core::PostgresAdminSessionRepository::new(pool);
|
||||||
|
|
||||||
|
let target = users
|
||||||
|
.get_by_username(username)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no admin user named {username:?}"))?;
|
||||||
|
|
||||||
|
let hash = if let Some(h) = password_hash {
|
||||||
|
validate_password_hash(&h)
|
||||||
|
.map_err(|_| anyhow::anyhow!("--password-hash is not a valid Argon2id PHC string"))?;
|
||||||
|
h
|
||||||
|
} else {
|
||||||
|
let raw = prompt_password_from_stdin()?;
|
||||||
|
hash_password(&raw).map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
users.update_password_hash(target.id, &hash).await?;
|
||||||
|
// Recovery implies the operator already lost control of the account;
|
||||||
|
// re-activate it (so a deactivated admin can also recover) and wipe
|
||||||
|
// any pre-existing sessions in case the original holder is still
|
||||||
|
// signed in elsewhere.
|
||||||
|
if !target.is_active {
|
||||||
|
users.set_active(target.id, true).await?;
|
||||||
|
}
|
||||||
|
let dropped = sessions.delete_for_user(target.id).await?;
|
||||||
|
|
||||||
|
println!("Password reset for {username}. Sessions dropped: {dropped}. Active: true.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_password_from_stdin() -> anyhow::Result<String> {
|
||||||
|
eprint!("New password (will be read from stdin, no echo): ");
|
||||||
|
std::io::stderr().flush().ok();
|
||||||
|
let mut line = String::new();
|
||||||
|
std::io::stdin()
|
||||||
|
.lock()
|
||||||
|
.read_line(&mut line)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to read stdin: {e}"))?;
|
||||||
|
let pw = line.trim_end_matches(['\n', '\r']).to_string();
|
||||||
|
if pw.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("password must not be empty"));
|
||||||
|
}
|
||||||
|
Ok(pw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Misc
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
fn init_tracing() {
|
fn init_tracing() {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||||
|
|||||||
@@ -17,9 +17,35 @@ use axum_test::TestServer;
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
/// Build the all-in-one app over the test pool, seed a single admin
|
||||||
|
/// directly through the repo (bypassing the env-var bootstrap path so
|
||||||
|
/// tests don't contaminate the process environment), log in, and bake
|
||||||
|
/// the bearer token into the TestServer as a default header so every
|
||||||
|
/// request in the test passes the `require_admin` middleware.
|
||||||
async fn server(pool: PgPool) -> TestServer {
|
async fn server(pool: PgPool) -> TestServer {
|
||||||
let app = picloud::build_app(pool).await.expect("build_app");
|
use picloud_manager_core::auth::hash_password;
|
||||||
TestServer::new(app).expect("TestServer should build")
|
|
||||||
|
let auth = picloud::AuthDeps::from_pool(pool.clone());
|
||||||
|
let hash = hash_password("test-pw").expect("hash");
|
||||||
|
auth.users
|
||||||
|
.create("test-admin", &hash)
|
||||||
|
.await
|
||||||
|
.expect("seed admin");
|
||||||
|
|
||||||
|
let app = picloud::build_app(pool, auth).await.expect("build_app");
|
||||||
|
let mut server = TestServer::new(app).expect("TestServer should build");
|
||||||
|
|
||||||
|
let resp = server
|
||||||
|
.post("/api/v1/admin/auth/login")
|
||||||
|
.json(&json!({ "username": "test-admin", "password": "test-pw" }))
|
||||||
|
.await;
|
||||||
|
resp.assert_status_ok();
|
||||||
|
let token = resp.json::<Value>()["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("login should return token")
|
||||||
|
.to_string();
|
||||||
|
server.add_header("authorization", format!("Bearer {token}"));
|
||||||
|
server
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -705,7 +731,7 @@ async fn version_includes_public_base_url(pool: PgPool) {
|
|||||||
let v: Value = r.json();
|
let v: Value = r.json();
|
||||||
assert!(v["public_base_url"].is_string());
|
assert!(v["public_base_url"].is_string());
|
||||||
assert_eq!(v["api"], 1);
|
assert_eq!(v["api"], 1);
|
||||||
assert_eq!(v["schema"], 3);
|
assert_eq!(v["schema"], 4);
|
||||||
assert_eq!(v["sdk"], "1.1");
|
assert_eq!(v["sdk"], "1.1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,3 +50,4 @@ macro_rules! id_type {
|
|||||||
id_type!(ScriptId);
|
id_type!(ScriptId);
|
||||||
id_type!(ExecutionId);
|
id_type!(ExecutionId);
|
||||||
id_type!(RequestId);
|
id_type!(RequestId);
|
||||||
|
id_type!(AdminUserId);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ pub mod version;
|
|||||||
|
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
pub use execution_log::{ExecutionLog, ExecutionStatus};
|
pub use execution_log::{ExecutionLog, ExecutionStatus};
|
||||||
pub use ids::{ExecutionId, RequestId, ScriptId};
|
pub use ids::{AdminUserId, ExecutionId, RequestId, ScriptId};
|
||||||
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
||||||
pub use route::{HostKind, PathKind, Route};
|
pub use route::{HostKind, PathKind, Route};
|
||||||
pub use sandbox::ScriptSandbox;
|
pub use sandbox::ScriptSandbox;
|
||||||
|
|||||||
622
dashboard/package-lock.json
generated
622
dashboard/package-lock.json
generated
@@ -1,12 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.1.0",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.1.0",
|
"version": "0.5.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.20.2",
|
||||||
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/search": "^6.7.0",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/view": "^6.43.0",
|
||||||
|
"@lezer/highlight": "^1.2.3",
|
||||||
|
"codemirror": "^6.0.2"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
@@ -23,7 +34,99 @@
|
|||||||
"svelte-check": "^4.1.4",
|
"svelte-check": "^4.1.4",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.20.0",
|
"typescript-eslint": "^8.20.0",
|
||||||
"vite": "^6.0.7"
|
"vite": "^6.0.7",
|
||||||
|
"vitest": "^3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/autocomplete": {
|
||||||
|
"version": "6.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz",
|
||||||
|
"integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/commands": {
|
||||||
|
"version": "6.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
|
||||||
|
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/view": "^6.27.0",
|
||||||
|
"@lezer/common": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lang-json": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@lezer/json": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/language": {
|
||||||
|
"version": "6.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||||
|
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.23.0",
|
||||||
|
"@lezer/common": "^1.5.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0",
|
||||||
|
"style-mod": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lint": {
|
||||||
|
"version": "6.9.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz",
|
||||||
|
"integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.42.0",
|
||||||
|
"crelt": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/search": {
|
||||||
|
"version": "6.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz",
|
||||||
|
"integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.37.0",
|
||||||
|
"crelt": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/state": {
|
||||||
|
"version": "6.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
||||||
|
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@marijn/find-cluster-break": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/view": {
|
||||||
|
"version": "6.43.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
|
||||||
|
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"crelt": "^1.0.6",
|
||||||
|
"style-mod": "^4.1.0",
|
||||||
|
"w3c-keyname": "^2.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
@@ -741,6 +844,47 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@lezer/common": {
|
||||||
|
"version": "1.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
||||||
|
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/highlight": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/json": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/lr": {
|
||||||
|
"version": "1.4.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||||
|
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@marijn/find-cluster-break": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@polka/url": {
|
"node_modules/@polka/url": {
|
||||||
"version": "1.0.0-next.29",
|
"version": "1.0.0-next.29",
|
||||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||||
@@ -1209,6 +1353,17 @@
|
|||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chai": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/deep-eql": "*",
|
||||||
|
"assertion-error": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/cookie": {
|
"node_modules/@types/cookie": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||||
@@ -1216,6 +1371,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/deep-eql": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -1532,6 +1694,121 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/expect": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/spy": "3.2.4",
|
||||||
|
"@vitest/utils": "3.2.4",
|
||||||
|
"chai": "^5.2.0",
|
||||||
|
"tinyrainbow": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/spy": "3.2.4",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^0.30.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"msw": "^2.4.9",
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"msw": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/pretty-format": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyrainbow": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/runner": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/utils": "3.2.4",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"strip-literal": "^3.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/snapshot": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "3.2.4",
|
||||||
|
"magic-string": "^0.30.17",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/spy": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyspy": "^4.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "3.2.4",
|
||||||
|
"loupe": "^3.1.4",
|
||||||
|
"tinyrainbow": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -1606,6 +1883,16 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/assertion-error": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axobject-query": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -1634,6 +1921,16 @@
|
|||||||
"concat-map": "0.0.1"
|
"concat-map": "0.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cac": {
|
||||||
|
"version": "6.7.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||||
|
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/callsites": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
@@ -1644,6 +1941,23 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chai": {
|
||||||
|
"version": "5.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||||
|
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"assertion-error": "^2.0.1",
|
||||||
|
"check-error": "^2.1.1",
|
||||||
|
"deep-eql": "^5.0.1",
|
||||||
|
"loupe": "^3.1.0",
|
||||||
|
"pathval": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -1661,6 +1975,16 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/check-error": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
@@ -1687,6 +2011,21 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codemirror": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/commands": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/lint": "^6.0.0",
|
||||||
|
"@codemirror/search": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -1724,6 +2063,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crelt": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -1770,6 +2115,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/deep-eql": {
|
||||||
|
"version": "5.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||||
|
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -1794,6 +2149,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/es-module-lexer": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -2082,6 +2444,16 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esutils": {
|
"node_modules/esutils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||||
@@ -2092,6 +2464,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expect-type": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -2310,6 +2692,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/js-tokens": {
|
||||||
|
"version": "9.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||||
|
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
@@ -2425,6 +2814,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/loupe": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -2584,6 +2980,23 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pathval": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2923,6 +3336,13 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/siginfo": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/sirv": {
|
"node_modules/sirv": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||||
@@ -2948,6 +3368,20 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackback": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/std-env": {
|
||||||
|
"version": "3.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
|
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/strip-json-comments": {
|
"node_modules/strip-json-comments": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||||
@@ -2961,6 +3395,25 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-literal": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"js-tokens": "^9.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/style-mod": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
@@ -3058,6 +3511,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinybench": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
@@ -3075,6 +3542,36 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinypool": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || >=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyrainbow": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyspy": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/totalist": {
|
"node_modules/totalist": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||||
@@ -3250,6 +3747,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-node": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cac": "^6.7.14",
|
||||||
|
"debug": "^4.4.1",
|
||||||
|
"es-module-lexer": "^1.7.0",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vite-node": "vite-node.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vitefu": {
|
"node_modules/vitefu": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
|
||||||
@@ -3270,6 +3790,85 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/expect": "3.2.4",
|
||||||
|
"@vitest/mocker": "3.2.4",
|
||||||
|
"@vitest/pretty-format": "^3.2.4",
|
||||||
|
"@vitest/runner": "3.2.4",
|
||||||
|
"@vitest/snapshot": "3.2.4",
|
||||||
|
"@vitest/spy": "3.2.4",
|
||||||
|
"@vitest/utils": "3.2.4",
|
||||||
|
"chai": "^5.2.0",
|
||||||
|
"debug": "^4.4.1",
|
||||||
|
"expect-type": "^1.2.1",
|
||||||
|
"magic-string": "^0.30.17",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"picomatch": "^4.0.2",
|
||||||
|
"std-env": "^3.9.0",
|
||||||
|
"tinybench": "^2.9.0",
|
||||||
|
"tinyexec": "^0.3.2",
|
||||||
|
"tinyglobby": "^0.2.14",
|
||||||
|
"tinypool": "^1.1.1",
|
||||||
|
"tinyrainbow": "^2.0.0",
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
|
||||||
|
"vite-node": "3.2.4",
|
||||||
|
"why-is-node-running": "^2.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vitest": "vitest.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@edge-runtime/vm": "*",
|
||||||
|
"@types/debug": "^4.1.12",
|
||||||
|
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||||
|
"@vitest/browser": "3.2.4",
|
||||||
|
"@vitest/ui": "3.2.4",
|
||||||
|
"happy-dom": "*",
|
||||||
|
"jsdom": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@edge-runtime/vm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/debug": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/ui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"happy-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsdom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -3286,6 +3885,23 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/why-is-node-running": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"siginfo": "^2.0.0",
|
||||||
|
"stackback": "0.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"why-is-node-running": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.5.0",
|
"version": "0.5.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -10,14 +10,15 @@
|
|||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lint": "prettier --check . && eslint ."
|
"lint": "prettier --check . && eslint .",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@types/node": "^22.10.5",
|
|
||||||
"@sveltejs/kit": "^2.17.0",
|
"@sveltejs/kit": "^2.17.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
|
"@types/node": "^22.10.5",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-svelte": "^3.0.0",
|
"eslint-plugin-svelte": "^3.0.0",
|
||||||
@@ -28,9 +29,21 @@
|
|||||||
"svelte-check": "^4.1.4",
|
"svelte-check": "^4.1.4",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.20.0",
|
"typescript-eslint": "^8.20.0",
|
||||||
"vite": "^6.0.7"
|
"vite": "^6.0.7",
|
||||||
|
"vitest": "^3.0.5"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"cookie": "^0.7.2"
|
"cookie": "^0.7.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.20.2",
|
||||||
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/search": "^6.7.0",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/view": "^6.43.0",
|
||||||
|
"@lezer/highlight": "^1.2.3",
|
||||||
|
"codemirror": "^6.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
dashboard/src/lib/CodeEditor.svelte
Normal file
107
dashboard/src/lib/CodeEditor.svelte
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<!--
|
||||||
|
CodeMirror-backed text editor for the dashboard.
|
||||||
|
|
||||||
|
Replaces our plain <textarea> with line numbers, syntax highlighting,
|
||||||
|
bracket matching, search/replace (Ctrl+F), and language-aware
|
||||||
|
autocomplete. Two-way bound via `value`; the parent treats it the
|
||||||
|
same as a textarea.
|
||||||
|
|
||||||
|
Languages: `rhai` (custom mode, ./rhai-mode.ts) and `json`
|
||||||
|
(@codemirror/lang-json).
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { basicSetup } from 'codemirror';
|
||||||
|
import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view';
|
||||||
|
import { EditorState } from '@codemirror/state';
|
||||||
|
import { indentWithTab } from '@codemirror/commands';
|
||||||
|
import { json as jsonLang } from '@codemirror/lang-json';
|
||||||
|
import { rhai as rhaiLang } from './rhai-mode';
|
||||||
|
import { dashboardSyntaxHighlighting, dashboardTheme } from './editor-theme';
|
||||||
|
|
||||||
|
type Language = 'rhai' | 'json';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
language = 'rhai' as Language,
|
||||||
|
placeholder = '',
|
||||||
|
minHeight = '12rem'
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
language?: Language;
|
||||||
|
placeholder?: string;
|
||||||
|
minHeight?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let host: HTMLDivElement | null = null;
|
||||||
|
let view: EditorView | null = null;
|
||||||
|
// Guard against the update-listener firing while we're pushing an
|
||||||
|
// external value into the editor — without this, parent-driven
|
||||||
|
// `value` changes would echo back through the listener and create a
|
||||||
|
// dispatch loop in Svelte 5 reactivity.
|
||||||
|
let pushingFromOutside = false;
|
||||||
|
|
||||||
|
function buildExtensions(lang: Language) {
|
||||||
|
const extensions = [
|
||||||
|
basicSetup,
|
||||||
|
lang === 'json' ? jsonLang() : rhaiLang(),
|
||||||
|
keymap.of([indentWithTab]),
|
||||||
|
dashboardSyntaxHighlighting,
|
||||||
|
dashboardTheme,
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged && !pushingFromOutside) {
|
||||||
|
value = update.state.doc.toString();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
|
if (placeholder) extensions.push(cmPlaceholder(placeholder));
|
||||||
|
return extensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!host) return;
|
||||||
|
view = new EditorView({
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: value,
|
||||||
|
extensions: buildExtensions(language)
|
||||||
|
}),
|
||||||
|
parent: host
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
view?.destroy();
|
||||||
|
view = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Push parent-driven `value` updates back into the editor (e.g.
|
||||||
|
// when the script is reloaded after Save, or "Format JSON" rewrites
|
||||||
|
// the body). We only dispatch when the document genuinely differs
|
||||||
|
// from the current `value`.
|
||||||
|
$effect(() => {
|
||||||
|
if (!view) return;
|
||||||
|
const current = view.state.doc.toString();
|
||||||
|
if (current !== value) {
|
||||||
|
pushingFromOutside = true;
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: current.length, insert: value }
|
||||||
|
});
|
||||||
|
pushingFromOutside = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={host} class="cm-host" style:min-height={minHeight}></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cm-host :global(.cm-editor) {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cm-host :global(.cm-scroller) {
|
||||||
|
font-family:
|
||||||
|
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,11 @@
|
|||||||
// the same Caddy upstream so the "Test invoke" panel can hit it
|
// the same Caddy upstream so the "Test invoke" panel can hit it
|
||||||
// without any cross-origin gymnastics.
|
// without any cross-origin gymnastics.
|
||||||
|
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { clearSession, getToken, setSession, type AdminUser } from './auth';
|
||||||
|
|
||||||
export interface ScriptSandbox {
|
export interface ScriptSandbox {
|
||||||
max_operations?: number;
|
max_operations?: number;
|
||||||
max_string_size?: number;
|
max_string_size?: number;
|
||||||
@@ -134,12 +139,26 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function adminRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
async function adminRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(path, {
|
const headers: Record<string, string> = {
|
||||||
...init,
|
'content-type': 'application/json',
|
||||||
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) }
|
...((init?.headers as Record<string, string>) ?? {})
|
||||||
});
|
};
|
||||||
|
const tok = getToken();
|
||||||
|
if (tok && !headers['authorization']) {
|
||||||
|
headers['authorization'] = `Bearer ${tok}`;
|
||||||
|
}
|
||||||
|
const res = await fetch(path, { ...init, headers });
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
const parsed: unknown = text ? safeJson(text) : null;
|
const parsed: unknown = text ? safeJson(text) : null;
|
||||||
|
if (res.status === 401) {
|
||||||
|
// Token gone stale or never present. Drop any cached session
|
||||||
|
// and bounce to login — unless we're already on it, in which
|
||||||
|
// case throw and let the login form render the error.
|
||||||
|
clearSession();
|
||||||
|
if (browser && !window.location.pathname.endsWith('/login')) {
|
||||||
|
void goto(`${base}/login`);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const message =
|
const message =
|
||||||
(parsed && typeof parsed === 'object' && 'error' in parsed
|
(parsed && typeof parsed === 'object' && 'error' in parsed
|
||||||
@@ -158,11 +177,76 @@ function safeJson(text: string): unknown {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminUserRecord {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
last_login_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAdminInput {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatchAdminInput {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
user: AdminUser;
|
||||||
|
token: string;
|
||||||
|
expires_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
health: () => fetch('/healthz').then((r) => r.text()),
|
health: () => fetch('/healthz').then((r) => r.text()),
|
||||||
|
|
||||||
version: () => adminRequest<VersionInfo>('/version'),
|
version: () => adminRequest<VersionInfo>('/version'),
|
||||||
|
|
||||||
|
auth: {
|
||||||
|
login: async (username: string, password: string): Promise<AdminUser> => {
|
||||||
|
const r = await adminRequest<LoginResponse>('/api/v1/admin/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
setSession(r.user, r.token);
|
||||||
|
return r.user;
|
||||||
|
},
|
||||||
|
logout: async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await adminRequest<null>('/api/v1/admin/auth/logout', { method: 'POST' });
|
||||||
|
} finally {
|
||||||
|
// Always clear locally — logout is idempotent server-side
|
||||||
|
// and we don't want a network blip to strand the SPA in
|
||||||
|
// a "logged out on server, still logged in client-side"
|
||||||
|
// state.
|
||||||
|
clearSession();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
me: () => adminRequest<AdminUser>('/api/v1/admin/auth/me')
|
||||||
|
},
|
||||||
|
|
||||||
|
admins: {
|
||||||
|
list: () => adminRequest<AdminUserRecord[]>('/api/v1/admin/admins'),
|
||||||
|
get: (id: string) => adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`),
|
||||||
|
create: (input: CreateAdminInput) =>
|
||||||
|
adminRequest<AdminUserRecord>('/api/v1/admin/admins', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}),
|
||||||
|
update: (id: string, input: PatchAdminInput) =>
|
||||||
|
adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}),
|
||||||
|
remove: (id: string) =>
|
||||||
|
adminRequest<null>(`/api/v1/admin/admins/${id}`, { method: 'DELETE' })
|
||||||
|
},
|
||||||
|
|
||||||
routes: {
|
routes: {
|
||||||
listForScript: (scriptId: string) =>
|
listForScript: (scriptId: string) =>
|
||||||
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
|
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
|
||||||
|
|||||||
60
dashboard/src/lib/auth.ts
Normal file
60
dashboard/src/lib/auth.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// Session state for the dashboard. Backed by a pair of Svelte stores
|
||||||
|
// plus a tiny localStorage echo so a page reload doesn't sign you out.
|
||||||
|
//
|
||||||
|
// The bearer token doubles as the cookie value on the server side, so
|
||||||
|
// in browsers that honor the Set-Cookie response the cookie path "just
|
||||||
|
// works"; the token-in-localStorage path covers the rest (HTTP dev, API
|
||||||
|
// clients impersonating the dashboard) by being injected into the
|
||||||
|
// Authorization header in api.ts.
|
||||||
|
|
||||||
|
import { writable, get } from 'svelte/store';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'picloud.admin.token';
|
||||||
|
|
||||||
|
function readStoredToken(): string | null {
|
||||||
|
if (!browser) return null;
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredToken(value: string | null) {
|
||||||
|
if (!browser) return;
|
||||||
|
try {
|
||||||
|
if (value === null) localStorage.removeItem(TOKEN_KEY);
|
||||||
|
else localStorage.setItem(TOKEN_KEY, value);
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: localStorage can be disabled. The session will
|
||||||
|
// just not survive page reloads, but the in-memory store still
|
||||||
|
// works for the current SPA lifetime.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const token = writable<string | null>(readStoredToken());
|
||||||
|
export const currentUser = writable<AdminUser | null>(null);
|
||||||
|
|
||||||
|
token.subscribe((value) => writeStoredToken(value));
|
||||||
|
|
||||||
|
/** Snapshot of the current token without subscribing — used by the
|
||||||
|
* fetch wrapper. Returns null when no admin is logged in. */
|
||||||
|
export function getToken(): string | null {
|
||||||
|
return get(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSession(user: AdminUser, raw_token: string) {
|
||||||
|
currentUser.set(user);
|
||||||
|
token.set(raw_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSession() {
|
||||||
|
currentUser.set(null);
|
||||||
|
token.set(null);
|
||||||
|
}
|
||||||
159
dashboard/src/lib/editor-theme.ts
Normal file
159
dashboard/src/lib/editor-theme.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
// Dashboard-matching theme + highlight style for CodeMirror.
|
||||||
|
//
|
||||||
|
// Colors are pulled from the existing slate/sky palette used across
|
||||||
|
// the dashboard (#0f172a / #1e293b / #38bdf8) so the editor blends
|
||||||
|
// into the surrounding cards instead of looking like a third-party
|
||||||
|
// transplant. Stock dark themes like "One Dark" or "VS Code Dark"
|
||||||
|
// would each clash with the slate background by a few shades.
|
||||||
|
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||||
|
import { tags as t } from '@lezer/highlight';
|
||||||
|
|
||||||
|
const palette = {
|
||||||
|
bg: '#0b1220', // matches existing <input>/<textarea> backgrounds
|
||||||
|
bgGutter: '#0f172a',
|
||||||
|
border: '#334155',
|
||||||
|
text: '#e2e8f0',
|
||||||
|
textMuted: '#94a3b8',
|
||||||
|
cursor: '#38bdf8',
|
||||||
|
// Selection alpha was originally 30 (≈19%) — bumped to 55 (≈33%)
|
||||||
|
// so it stays clearly visible when it sits on top of (or under, in
|
||||||
|
// CodeMirror's case) the active-line tint. The default CM layering
|
||||||
|
// puts the selection layer behind line backgrounds, so an opaque
|
||||||
|
// active line hides selections on the current line; this pair of
|
||||||
|
// values makes both visible at once.
|
||||||
|
selection: '#38bdf855',
|
||||||
|
// Active line: very subtle sky tint at ~6% alpha. Strong enough to
|
||||||
|
// see at a glance which line the caret is on, weak enough to leave
|
||||||
|
// the selection visible underneath. The active-line gutter (the
|
||||||
|
// brighter line number on the left) is the primary indicator.
|
||||||
|
activeLine: '#38bdf810',
|
||||||
|
activeLineGutter: '#1e293b',
|
||||||
|
matchingBracket: '#38bdf850',
|
||||||
|
searchMatch: '#38bdf850',
|
||||||
|
searchMatchSelected: '#38bdf8',
|
||||||
|
|
||||||
|
// Syntax — chosen to be readable against #0b1220 without making
|
||||||
|
// any single token feel louder than the rest.
|
||||||
|
comment: '#64748b',
|
||||||
|
str: '#86efac',
|
||||||
|
num: '#fbbf24',
|
||||||
|
bool: '#fbbf24',
|
||||||
|
keyword: '#c4b5fd',
|
||||||
|
control: '#c4b5fd',
|
||||||
|
operator: '#cbd5e1',
|
||||||
|
punct: '#cbd5e1',
|
||||||
|
function: '#38bdf8',
|
||||||
|
property: '#e2e8f0',
|
||||||
|
variable: '#e2e8f0',
|
||||||
|
ctx: '#f472b6', // `ctx` is special — pinker so users notice it
|
||||||
|
namespace: '#fbbf24', // `log::`
|
||||||
|
invalid: '#ef4444'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dashboardTheme = EditorView.theme(
|
||||||
|
{
|
||||||
|
'&': {
|
||||||
|
color: palette.text,
|
||||||
|
backgroundColor: palette.bg,
|
||||||
|
borderRadius: '0.375rem'
|
||||||
|
},
|
||||||
|
'&.cm-focused': {
|
||||||
|
outline: `1px solid ${palette.cursor}`
|
||||||
|
},
|
||||||
|
'.cm-content': {
|
||||||
|
caretColor: palette.cursor,
|
||||||
|
fontFamily:
|
||||||
|
'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
padding: '0.5rem 0'
|
||||||
|
},
|
||||||
|
'.cm-cursor, .cm-dropCursor': {
|
||||||
|
borderLeftColor: palette.cursor,
|
||||||
|
borderLeftWidth: '2px'
|
||||||
|
},
|
||||||
|
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
|
||||||
|
{
|
||||||
|
backgroundColor: palette.selection
|
||||||
|
},
|
||||||
|
'.cm-activeLine': {
|
||||||
|
backgroundColor: palette.activeLine
|
||||||
|
},
|
||||||
|
'.cm-gutters': {
|
||||||
|
backgroundColor: palette.bgGutter,
|
||||||
|
color: palette.textMuted,
|
||||||
|
border: 'none',
|
||||||
|
borderRight: `1px solid ${palette.border}`
|
||||||
|
},
|
||||||
|
'.cm-activeLineGutter': {
|
||||||
|
backgroundColor: palette.activeLineGutter,
|
||||||
|
color: palette.text
|
||||||
|
},
|
||||||
|
'.cm-matchingBracket, .cm-nonmatchingBracket': {
|
||||||
|
backgroundColor: palette.matchingBracket,
|
||||||
|
outline: 'none'
|
||||||
|
},
|
||||||
|
// Search / replace panel (Ctrl+F)
|
||||||
|
'.cm-panels': {
|
||||||
|
backgroundColor: palette.bgGutter,
|
||||||
|
color: palette.text,
|
||||||
|
borderTop: `1px solid ${palette.border}`
|
||||||
|
},
|
||||||
|
'.cm-searchMatch': {
|
||||||
|
backgroundColor: palette.searchMatch
|
||||||
|
},
|
||||||
|
'.cm-searchMatch.cm-searchMatch-selected': {
|
||||||
|
backgroundColor: palette.searchMatchSelected
|
||||||
|
},
|
||||||
|
'.cm-panel input, .cm-panel button': {
|
||||||
|
backgroundColor: palette.bg,
|
||||||
|
color: palette.text,
|
||||||
|
border: `1px solid ${palette.border}`,
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
padding: '0.2rem 0.4rem'
|
||||||
|
},
|
||||||
|
'.cm-panel button:hover': {
|
||||||
|
backgroundColor: palette.activeLine
|
||||||
|
},
|
||||||
|
// Autocomplete popup
|
||||||
|
'.cm-tooltip': {
|
||||||
|
backgroundColor: palette.bgGutter,
|
||||||
|
border: `1px solid ${palette.border}`,
|
||||||
|
color: palette.text
|
||||||
|
},
|
||||||
|
'.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]': {
|
||||||
|
backgroundColor: palette.activeLine,
|
||||||
|
color: palette.text
|
||||||
|
},
|
||||||
|
'.cm-completionLabel': {
|
||||||
|
color: palette.text
|
||||||
|
},
|
||||||
|
'.cm-completionDetail': {
|
||||||
|
color: palette.textMuted,
|
||||||
|
fontStyle: 'normal'
|
||||||
|
},
|
||||||
|
'.cm-completionIcon': {
|
||||||
|
color: palette.textMuted
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ dark: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dashboardHighlight = HighlightStyle.define([
|
||||||
|
{ tag: t.comment, color: palette.comment, fontStyle: 'italic' },
|
||||||
|
{ tag: [t.string, t.special(t.string)], color: palette.str },
|
||||||
|
{ tag: [t.number, t.bool, t.null], color: palette.num },
|
||||||
|
{ tag: [t.keyword, t.modifier], color: palette.keyword, fontWeight: '600' },
|
||||||
|
{ tag: t.controlKeyword, color: palette.control, fontWeight: '600' },
|
||||||
|
{ tag: [t.operator, t.derefOperator, t.logicOperator], color: palette.operator },
|
||||||
|
{ tag: [t.punctuation, t.bracket, t.brace, t.paren, t.squareBracket], color: palette.punct },
|
||||||
|
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: palette.function },
|
||||||
|
{ tag: [t.propertyName, t.attributeName], color: palette.property },
|
||||||
|
{ tag: t.variableName, color: palette.variable },
|
||||||
|
{ tag: t.special(t.variableName), color: palette.ctx, fontWeight: '600' },
|
||||||
|
{ tag: t.namespace, color: palette.namespace, fontWeight: '600' },
|
||||||
|
{ tag: t.invalid, color: palette.invalid, textDecoration: 'underline wavy' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const dashboardSyntaxHighlighting = syntaxHighlighting(dashboardHighlight);
|
||||||
600
dashboard/src/lib/rhai-mode.ts
Normal file
600
dashboard/src/lib/rhai-mode.ts
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
// CodeMirror StreamLanguage for Rhai.
|
||||||
|
//
|
||||||
|
// Keyword and operator lists are sourced from the upstream TextMate
|
||||||
|
// grammar maintained by the Rhai authors:
|
||||||
|
// https://github.com/rhaiscript/vscode-rhai
|
||||||
|
// syntax/rhai.tmLanguage.json (MPL-2.0)
|
||||||
|
// This file does NOT copy the upstream grammar bytes — only the
|
||||||
|
// symbol lists. The matching logic is a simple regex tokenizer
|
||||||
|
// tailored to CodeMirror's StreamLanguage shape; if richer
|
||||||
|
// highlighting is wanted later, swap this out for a full tmLanguage
|
||||||
|
// loader (vscode-textmate + oniguruma) without touching callers.
|
||||||
|
//
|
||||||
|
// SDK completions (`ctx.*`, `log::*`) come from our own SDK contract
|
||||||
|
// in crates/executor-core/tests/sdk_contract.rs — that file is the
|
||||||
|
// authoritative list of what scripts can do.
|
||||||
|
|
||||||
|
import { StreamLanguage, LanguageSupport } from '@codemirror/language';
|
||||||
|
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
|
||||||
|
import { EditorSelection, StateEffect, StateField, type Extension } from '@codemirror/state';
|
||||||
|
import { EditorView, keymap, showPanel, type Panel } from '@codemirror/view';
|
||||||
|
import { parse, buildSymbolTable, type ParseResult, type Range, type SymbolTable } from './rhai';
|
||||||
|
|
||||||
|
// Keywords that drive control flow (`if`, `for`, ...) — these get the
|
||||||
|
// `controlKeyword` tag so the theme can color them distinctly from
|
||||||
|
// declaration-style keywords like `let` or `fn`.
|
||||||
|
const CONTROL_KEYWORDS = new Set([
|
||||||
|
'if',
|
||||||
|
'else',
|
||||||
|
'for',
|
||||||
|
'while',
|
||||||
|
'loop',
|
||||||
|
'do',
|
||||||
|
'switch',
|
||||||
|
'case',
|
||||||
|
'default',
|
||||||
|
'return',
|
||||||
|
'break',
|
||||||
|
'continue',
|
||||||
|
'try',
|
||||||
|
'catch',
|
||||||
|
'throw'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const DECLARATION_KEYWORDS = new Set([
|
||||||
|
'let',
|
||||||
|
'const',
|
||||||
|
'fn',
|
||||||
|
'private',
|
||||||
|
'in',
|
||||||
|
'as',
|
||||||
|
'is'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reserved-but-not-currently-valid keywords from the upstream grammar.
|
||||||
|
// We still highlight them so users notice them; the parser will reject
|
||||||
|
// at execute time.
|
||||||
|
const RESERVED_KEYWORDS = new Set([
|
||||||
|
'var',
|
||||||
|
'match',
|
||||||
|
'public',
|
||||||
|
'protected',
|
||||||
|
'new',
|
||||||
|
'use',
|
||||||
|
'with',
|
||||||
|
'module',
|
||||||
|
'package',
|
||||||
|
'super',
|
||||||
|
'spawn',
|
||||||
|
'thread',
|
||||||
|
'go',
|
||||||
|
'sync',
|
||||||
|
'async',
|
||||||
|
'await',
|
||||||
|
'yield',
|
||||||
|
'void',
|
||||||
|
'null',
|
||||||
|
'nil',
|
||||||
|
'debug',
|
||||||
|
'eval',
|
||||||
|
'print',
|
||||||
|
'import',
|
||||||
|
'export'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BOOLEAN_LITERALS = new Set(['true', 'false']);
|
||||||
|
|
||||||
|
const SPECIAL_VARIABLES = new Set(['ctx']);
|
||||||
|
const NAMESPACES = new Set(['log']);
|
||||||
|
|
||||||
|
interface RhaiState {
|
||||||
|
inBlockComment: boolean;
|
||||||
|
inString: false | '"' | '`';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rhaiLanguage = StreamLanguage.define<RhaiState>({
|
||||||
|
name: 'rhai',
|
||||||
|
startState: () => ({ inBlockComment: false, inString: false }),
|
||||||
|
token(stream, state) {
|
||||||
|
// --- inside a /* … */ block comment ---
|
||||||
|
if (state.inBlockComment) {
|
||||||
|
if (stream.match(/.*?\*\//)) {
|
||||||
|
state.inBlockComment = false;
|
||||||
|
} else {
|
||||||
|
stream.skipToEnd();
|
||||||
|
}
|
||||||
|
return 'comment';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- inside a multi-line string (rare but possible with ` ` strings) ---
|
||||||
|
if (state.inString) {
|
||||||
|
const quote = state.inString;
|
||||||
|
while (!stream.eol()) {
|
||||||
|
const ch = stream.next();
|
||||||
|
if (ch === '\\') {
|
||||||
|
stream.next();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === quote) {
|
||||||
|
state.inString = false;
|
||||||
|
return 'string';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip whitespace
|
||||||
|
if (stream.eatSpace()) return null;
|
||||||
|
|
||||||
|
// --- line comment ---
|
||||||
|
if (stream.match('//')) {
|
||||||
|
stream.skipToEnd();
|
||||||
|
return 'comment';
|
||||||
|
}
|
||||||
|
// --- block comment ---
|
||||||
|
if (stream.match('/*')) {
|
||||||
|
state.inBlockComment = true;
|
||||||
|
if (stream.match(/.*?\*\//)) {
|
||||||
|
state.inBlockComment = false;
|
||||||
|
} else {
|
||||||
|
stream.skipToEnd();
|
||||||
|
}
|
||||||
|
return 'comment';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- strings ---
|
||||||
|
const quote = stream.peek();
|
||||||
|
if (quote === '"' || quote === '`') {
|
||||||
|
stream.next();
|
||||||
|
while (!stream.eol()) {
|
||||||
|
const ch = stream.next();
|
||||||
|
if (ch === '\\') {
|
||||||
|
stream.next();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === quote) {
|
||||||
|
return 'string';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// String continues on next line (only really valid for ` `).
|
||||||
|
state.inString = quote;
|
||||||
|
return 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- numbers (hex, binary, decimal, float) ---
|
||||||
|
if (stream.match(/^0x[0-9a-fA-F_]+/)) return 'number';
|
||||||
|
if (stream.match(/^0b[01_]+/)) return 'number';
|
||||||
|
if (stream.match(/^\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?/)) return 'number';
|
||||||
|
|
||||||
|
// --- module path: name::name (used for log::info etc.) ---
|
||||||
|
// Recognized before plain identifiers so we can return 'namespace'.
|
||||||
|
if (stream.match(/^[a-zA-Z_]\w*(?=::)/)) {
|
||||||
|
const word = stream.current();
|
||||||
|
if (NAMESPACES.has(word)) return 'namespace';
|
||||||
|
return 'variableName';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- identifiers + keywords ---
|
||||||
|
if (stream.match(/^[a-zA-Z_]\w*/)) {
|
||||||
|
const word = stream.current();
|
||||||
|
if (CONTROL_KEYWORDS.has(word)) return 'controlKeyword';
|
||||||
|
if (DECLARATION_KEYWORDS.has(word)) return 'keyword';
|
||||||
|
if (RESERVED_KEYWORDS.has(word)) return 'keyword';
|
||||||
|
if (BOOLEAN_LITERALS.has(word)) return 'bool';
|
||||||
|
if (SPECIAL_VARIABLES.has(word)) return 'variableName.special';
|
||||||
|
if (NAMESPACES.has(word)) return 'namespace';
|
||||||
|
// Followed by `(` → function call. We highlight as a function name.
|
||||||
|
if (stream.peek() === '(') return 'function(variableName)';
|
||||||
|
// Property after `.`
|
||||||
|
const before = stream.string.slice(0, stream.start);
|
||||||
|
if (before.endsWith('.')) return 'propertyName';
|
||||||
|
return 'variableName';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- operators / punctuation ---
|
||||||
|
if (stream.match(/^(\?\?|\.\.=|\.\.|::|==|!=|<=|>=|&&|\|\||<<|>>|=>|->|[+\-*/%<>!&|^~=])/)) {
|
||||||
|
return 'operator';
|
||||||
|
}
|
||||||
|
if (stream.match(/^[(){}[\];,.]/)) return 'punctuation';
|
||||||
|
|
||||||
|
// Unrecognized — advance one char and bail.
|
||||||
|
stream.next();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
languageData: {
|
||||||
|
commentTokens: { line: '//', block: { open: '/*', close: '*/' } },
|
||||||
|
closeBrackets: { brackets: ['(', '[', '{', '"', '`'] },
|
||||||
|
indentOnInput: /^\s*[}\])]$/
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Autocomplete
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface CompletionItem {
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
type?: 'keyword' | 'variable' | 'function' | 'property' | 'namespace';
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEYWORD_COMPLETIONS: CompletionItem[] = [
|
||||||
|
...['let', 'const', 'fn'].map((k) => ({ label: k, type: 'keyword' as const, detail: 'declaration' })),
|
||||||
|
...['if', 'else', 'for', 'while', 'loop', 'switch', 'return', 'break', 'continue', 'try', 'catch', 'throw'].map(
|
||||||
|
(k) => ({ label: k, type: 'keyword' as const, detail: 'control flow' })
|
||||||
|
),
|
||||||
|
...['in', 'as', 'is'].map((k) => ({ label: k, type: 'keyword' as const })),
|
||||||
|
{ label: 'true', type: 'keyword', detail: 'boolean' },
|
||||||
|
{ label: 'false', type: 'keyword', detail: 'boolean' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ctx.* — keep aligned with `build_ctx_map` in
|
||||||
|
// crates/executor-core/src/engine.rs.
|
||||||
|
const CTX_TOP_COMPLETIONS: CompletionItem[] = [
|
||||||
|
{ label: 'execution_id', type: 'property', detail: 'string' },
|
||||||
|
{ label: 'script_id', type: 'property', detail: 'string' },
|
||||||
|
{ label: 'script_name', type: 'property', detail: 'string' },
|
||||||
|
{ label: 'request_id', type: 'property', detail: 'string' },
|
||||||
|
{ label: 'invocation_type', type: 'property', detail: '"http" | "function" | "scheduled"' },
|
||||||
|
{ label: 'sdk_version', type: 'property', detail: 'string ("major.minor")' },
|
||||||
|
{ label: 'request', type: 'property', detail: 'object' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const CTX_REQUEST_COMPLETIONS: CompletionItem[] = [
|
||||||
|
{ label: 'path', type: 'property', detail: 'string' },
|
||||||
|
{ label: 'headers', type: 'property', detail: 'map of string→string' },
|
||||||
|
{ label: 'body', type: 'property', detail: 'parsed JSON value' },
|
||||||
|
{ label: 'params', type: 'property', detail: 'map (param-route captures, SDK 1.1+)' },
|
||||||
|
{ label: 'query', type: 'property', detail: 'map (parsed query string, SDK 1.1+)' },
|
||||||
|
{ label: 'rest', type: 'property', detail: 'string (prefix-route tail, SDK 1.1+)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOG_COMPLETIONS: CompletionItem[] = [
|
||||||
|
{ label: 'info', type: 'function', detail: 'log::info(msg, data?)' },
|
||||||
|
{ label: 'warn', type: 'function', detail: 'log::warn(msg, data?)' },
|
||||||
|
{ label: 'error', type: 'function', detail: 'log::error(msg, data?)' },
|
||||||
|
{ label: 'trace', type: 'function', detail: 'log::trace(msg, data?) — use instead of "debug" (reserved keyword)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOP_LEVEL_GLOBALS: CompletionItem[] = [
|
||||||
|
{ label: 'ctx', type: 'variable', detail: 'invocation context' },
|
||||||
|
{ label: 'log', type: 'namespace', detail: 'log::info/warn/error/trace' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function toCMCompletions(items: CompletionItem[]) {
|
||||||
|
return items.map((c) => ({
|
||||||
|
label: c.label,
|
||||||
|
type: c.type,
|
||||||
|
detail: c.detail
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rhaiCompletions(context: CompletionContext): CompletionResult | null {
|
||||||
|
// `log::` namespace
|
||||||
|
const ns = context.matchBefore(/log::\w*/);
|
||||||
|
if (ns) {
|
||||||
|
return {
|
||||||
|
from: ns.from + 'log::'.length,
|
||||||
|
options: toCMCompletions(LOG_COMPLETIONS),
|
||||||
|
validFor: /^\w*$/
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// `ctx.request.` properties
|
||||||
|
const ctxReq = context.matchBefore(/ctx\.request\.\w*/);
|
||||||
|
if (ctxReq) {
|
||||||
|
return {
|
||||||
|
from: ctxReq.from + 'ctx.request.'.length,
|
||||||
|
options: toCMCompletions(CTX_REQUEST_COMPLETIONS),
|
||||||
|
validFor: /^\w*$/
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// `ctx.` properties
|
||||||
|
const ctx = context.matchBefore(/ctx\.\w*/);
|
||||||
|
if (ctx) {
|
||||||
|
return {
|
||||||
|
from: ctx.from + 'ctx.'.length,
|
||||||
|
options: toCMCompletions(CTX_TOP_COMPLETIONS),
|
||||||
|
validFor: /^\w*$/
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member access on something other than `ctx`/`log` — let the
|
||||||
|
// script-aware source decide whether it has fields to offer. We bow
|
||||||
|
// out so we don't flood the popup with keywords after a `.`.
|
||||||
|
if (context.matchBefore(/\.\w*$/)) return null;
|
||||||
|
|
||||||
|
// Plain word at the cursor → keywords + top-level names.
|
||||||
|
const word = context.matchBefore(/\w+/);
|
||||||
|
if (!word && !context.explicit) return null;
|
||||||
|
return {
|
||||||
|
from: word ? word.from : context.pos,
|
||||||
|
options: toCMCompletions([...KEYWORD_COMPLETIONS, ...TOP_LEVEL_GLOBALS]),
|
||||||
|
validFor: /^\w*$/
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Script-aware analysis
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// One AST + symbol table per editor state. Rebuilt on every doc change.
|
||||||
|
// Scripts in the dashboard are small (20–200 lines is the target); the
|
||||||
|
// parse + walk is sub-millisecond at that size, so we don't bother
|
||||||
|
// debouncing for now.
|
||||||
|
interface RhaiAnalysis {
|
||||||
|
parse: ParseResult;
|
||||||
|
table: SymbolTable;
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyze(source: string): RhaiAnalysis {
|
||||||
|
const parsed = parse(source);
|
||||||
|
return { parse: parsed, table: buildSymbolTable(parsed) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rhaiAnalysisField = StateField.define<RhaiAnalysis>({
|
||||||
|
create: (state) => analyze(state.doc.toString()),
|
||||||
|
update: (value, tr) => (tr.docChanged ? analyze(tr.newDoc.toString()) : value)
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script-aware completion source.
|
||||||
|
*
|
||||||
|
* Two things on top of the static `ctx.*` / `log::*` list:
|
||||||
|
* 1. After `name.`, if `name` was initialized to an object-map
|
||||||
|
* literal, suggest that literal's field names.
|
||||||
|
* 2. At a plain word position, suggest user-defined symbols in scope
|
||||||
|
* (locals, function parameters, top-level `fn` decls). `fn` decls
|
||||||
|
* get their signature in the `detail` field so the popup shows
|
||||||
|
* `process(order, user)` rather than just `process`.
|
||||||
|
*
|
||||||
|
* Composes with `rhaiCompletions` via `autocompletion({ override:
|
||||||
|
* [scopeCompletionSource, rhaiCompletions] })` — CodeMirror merges the
|
||||||
|
* results.
|
||||||
|
*/
|
||||||
|
export function scopeCompletionSource(context: CompletionContext): CompletionResult | null {
|
||||||
|
const analysis = context.state.field(rhaiAnalysisField, false);
|
||||||
|
if (!analysis) return null;
|
||||||
|
|
||||||
|
// Member access — `obj.fie|`. Only fire if `obj` resolves to a known
|
||||||
|
// object-literal in scope; otherwise leave the popup empty rather
|
||||||
|
// than guess wrong fields.
|
||||||
|
const member = context.matchBefore(/(\w+)\.(\w*)/);
|
||||||
|
if (member) {
|
||||||
|
const dotIdx = member.text.indexOf('.');
|
||||||
|
const objectName = member.text.slice(0, dotIdx);
|
||||||
|
// `ctx.*` is handled by the static source — don't double up.
|
||||||
|
if (objectName !== 'ctx') {
|
||||||
|
const fields = analysis.table.objectFieldsOf(objectName, member.from);
|
||||||
|
if (fields.length > 0) {
|
||||||
|
return {
|
||||||
|
from: member.from + dotIdx + 1,
|
||||||
|
options: fields.map((label) => ({ label, type: 'property' })),
|
||||||
|
validFor: /^\w*$/
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip when right after `::` (handled by the static source for log::).
|
||||||
|
if (context.matchBefore(/::\w*$/)) return null;
|
||||||
|
|
||||||
|
// Plain word — surface in-scope decls.
|
||||||
|
const word = context.matchBefore(/\w+/);
|
||||||
|
if (!word && !context.explicit) return null;
|
||||||
|
const decls = analysis.table.scopeCompletions(context.pos);
|
||||||
|
if (decls.length === 0) return null;
|
||||||
|
return {
|
||||||
|
from: word ? word.from : context.pos,
|
||||||
|
options: decls.map((d) => ({
|
||||||
|
label: d.name,
|
||||||
|
type: d.kind === 'fn' ? 'function' : 'variable',
|
||||||
|
detail: d.signature ?? d.kind
|
||||||
|
})),
|
||||||
|
validFor: /^\w*$/
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Go-to-definition, Ctrl/Cmd+Click, find-usages
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface UsagesPanelState {
|
||||||
|
name: string;
|
||||||
|
ranges: Range[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const setUsagesPanel = StateEffect.define<UsagesPanelState | null>();
|
||||||
|
|
||||||
|
const usagesPanelField = StateField.define<UsagesPanelState | null>({
|
||||||
|
create: () => null,
|
||||||
|
update(value, tr) {
|
||||||
|
for (const e of tr.effects) if (e.is(setUsagesPanel)) return e.value;
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
provide: (f) => showPanel.from(f, (value) => (value ? buildUsagesPanel : null))
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildUsagesPanel(view: EditorView): Panel {
|
||||||
|
const state = view.state.field(usagesPanelField);
|
||||||
|
const dom = document.createElement('div');
|
||||||
|
dom.className = 'cm-rhai-usages';
|
||||||
|
|
||||||
|
const head = document.createElement('div');
|
||||||
|
head.className = 'cm-rhai-usages-head';
|
||||||
|
const count = state ? state.ranges.length : 0;
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = `${count} occurrence${count === 1 ? '' : 's'} of "${state?.name ?? ''}"`;
|
||||||
|
head.appendChild(label);
|
||||||
|
const close = document.createElement('button');
|
||||||
|
close.type = 'button';
|
||||||
|
close.textContent = '×';
|
||||||
|
close.title = 'Close (Esc)';
|
||||||
|
close.onclick = () => {
|
||||||
|
view.dispatch({ effects: setUsagesPanel.of(null) });
|
||||||
|
view.focus();
|
||||||
|
};
|
||||||
|
head.appendChild(close);
|
||||||
|
dom.appendChild(head);
|
||||||
|
|
||||||
|
if (state) {
|
||||||
|
for (const r of state.ranges) {
|
||||||
|
const line = view.state.doc.lineAt(r.start);
|
||||||
|
const row = document.createElement('button');
|
||||||
|
row.type = 'button';
|
||||||
|
row.className = 'cm-rhai-usages-row';
|
||||||
|
const num = document.createElement('span');
|
||||||
|
num.className = 'cm-rhai-usages-line';
|
||||||
|
num.textContent = String(line.number);
|
||||||
|
const snip = document.createElement('span');
|
||||||
|
snip.className = 'cm-rhai-usages-snip';
|
||||||
|
snip.textContent = line.text.trim();
|
||||||
|
row.appendChild(num);
|
||||||
|
row.appendChild(snip);
|
||||||
|
row.onclick = () => {
|
||||||
|
view.dispatch({
|
||||||
|
selection: EditorSelection.cursor(r.start),
|
||||||
|
scrollIntoView: true
|
||||||
|
});
|
||||||
|
view.focus();
|
||||||
|
};
|
||||||
|
dom.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dom, top: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const usagesPanelTheme = EditorView.baseTheme({
|
||||||
|
'.cm-rhai-usages': {
|
||||||
|
background: '#0f172a',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
borderTop: '1px solid #334155',
|
||||||
|
padding: '0.4rem 0.5rem',
|
||||||
|
maxHeight: '180px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
fontSize: '0.85rem'
|
||||||
|
},
|
||||||
|
'.cm-rhai-usages-head': {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: '#94a3b8',
|
||||||
|
marginBottom: '0.25rem'
|
||||||
|
},
|
||||||
|
'.cm-rhai-usages-head button': {
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#94a3b8',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '1rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0 0.4rem'
|
||||||
|
},
|
||||||
|
'.cm-rhai-usages-row': {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
width: '100%',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
border: 'none',
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: '0.15rem 0.25rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 'inherit',
|
||||||
|
borderRadius: '0.25rem'
|
||||||
|
},
|
||||||
|
'.cm-rhai-usages-row:hover': {
|
||||||
|
background: '#1e293b'
|
||||||
|
},
|
||||||
|
'.cm-rhai-usages-line': {
|
||||||
|
color: '#64748b',
|
||||||
|
minWidth: '2.5rem',
|
||||||
|
flexShrink: 0
|
||||||
|
},
|
||||||
|
'.cm-rhai-usages-snip': {
|
||||||
|
fontFamily:
|
||||||
|
'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function declAtCursor(view: EditorView, pos: number) {
|
||||||
|
const analysis = view.state.field(rhaiAnalysisField, false);
|
||||||
|
if (!analysis) return null;
|
||||||
|
return analysis.table.declOfUsageAt(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gotoDefinition(view: EditorView): boolean {
|
||||||
|
const pos = view.state.selection.main.head;
|
||||||
|
const decl = declAtCursor(view, pos);
|
||||||
|
if (!decl) return false;
|
||||||
|
view.dispatch({
|
||||||
|
selection: EditorSelection.cursor(decl.nameRange.start),
|
||||||
|
scrollIntoView: true
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findUsages(view: EditorView): boolean {
|
||||||
|
const pos = view.state.selection.main.head;
|
||||||
|
const analysis = view.state.field(rhaiAnalysisField, false);
|
||||||
|
if (!analysis) return false;
|
||||||
|
const decl = analysis.table.declOfUsageAt(pos);
|
||||||
|
if (!decl) return false;
|
||||||
|
view.dispatch({
|
||||||
|
effects: setUsagesPanel.of({
|
||||||
|
name: decl.name,
|
||||||
|
ranges: analysis.table.usagesOf(decl)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUsagesPanel(view: EditorView): boolean {
|
||||||
|
if (!view.state.field(usagesPanelField, false)) return false;
|
||||||
|
view.dispatch({ effects: setUsagesPanel.of(null) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rhaiKeymap = keymap.of([
|
||||||
|
{ key: 'F12', run: gotoDefinition },
|
||||||
|
{ key: 'Shift-F12', run: findUsages },
|
||||||
|
{ key: 'Escape', run: closeUsagesPanel }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Ctrl+Click (Cmd+Click on macOS) on an identifier → jump to its decl.
|
||||||
|
// Returning `true` suppresses CodeMirror's default selection behavior so
|
||||||
|
// the click doesn't simultaneously place the caret at the click site.
|
||||||
|
const ctrlClickHandler = EditorView.domEventHandlers({
|
||||||
|
mousedown(event, view) {
|
||||||
|
if (!(event.metaKey || event.ctrlKey) || event.button !== 0) return false;
|
||||||
|
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
|
||||||
|
if (pos == null) return false;
|
||||||
|
const decl = declAtCursor(view, pos);
|
||||||
|
if (!decl) return false;
|
||||||
|
view.dispatch({
|
||||||
|
selection: EditorSelection.cursor(decl.nameRange.start),
|
||||||
|
scrollIntoView: true
|
||||||
|
});
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function rhai(): LanguageSupport {
|
||||||
|
const extensions: Extension[] = [
|
||||||
|
rhaiAnalysisField,
|
||||||
|
usagesPanelField,
|
||||||
|
usagesPanelTheme,
|
||||||
|
rhaiKeymap,
|
||||||
|
ctrlClickHandler,
|
||||||
|
autocompletion({ override: [scopeCompletionSource, rhaiCompletions] })
|
||||||
|
];
|
||||||
|
return new LanguageSupport(rhaiLanguage, extensions);
|
||||||
|
}
|
||||||
279
dashboard/src/lib/rhai/ast.ts
Normal file
279
dashboard/src/lib/rhai/ast.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
// AST node definitions for the dashboard's hand-rolled Rhai parser.
|
||||||
|
//
|
||||||
|
// Every node carries `start` / `end` byte offsets into the source so the
|
||||||
|
// editor features (autocomplete, goto-def, find-usages, format) can map
|
||||||
|
// between positions in the document and nodes in the tree.
|
||||||
|
//
|
||||||
|
// The shape mirrors the Rhai book grammar (https://rhai.rs/book/language/)
|
||||||
|
// but simplified: type annotations are absent (Rhai is dynamic), and
|
||||||
|
// statement-vs-expression duality is collapsed by letting `if` / `switch` /
|
||||||
|
// block expressions appear in both positions (an `ExprStmt` wrapper turns
|
||||||
|
// any expression into a statement).
|
||||||
|
|
||||||
|
export interface Range {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Comments — captured by the lexer with their positions and re-emitted by
|
||||||
|
// the formatter. Kept off the AST tree so they don't clutter walkers.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Comment extends Range {
|
||||||
|
kind: 'LineComment' | 'BlockComment';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Statements
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type Stmt =
|
||||||
|
| LetStmt
|
||||||
|
| ConstStmt
|
||||||
|
| FnDecl
|
||||||
|
| ExprStmt
|
||||||
|
| ReturnStmt
|
||||||
|
| WhileStmt
|
||||||
|
| LoopStmt
|
||||||
|
| ForStmt
|
||||||
|
| BreakStmt
|
||||||
|
| ContinueStmt
|
||||||
|
| TryStmt;
|
||||||
|
|
||||||
|
export interface LetStmt extends Range {
|
||||||
|
kind: 'Let';
|
||||||
|
name: string;
|
||||||
|
nameRange: Range;
|
||||||
|
init: Expr | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConstStmt extends Range {
|
||||||
|
kind: 'Const';
|
||||||
|
name: string;
|
||||||
|
nameRange: Range;
|
||||||
|
init: Expr | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Param extends Range {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FnDecl extends Range {
|
||||||
|
kind: 'FnDecl';
|
||||||
|
name: string;
|
||||||
|
nameRange: Range;
|
||||||
|
params: Param[];
|
||||||
|
body: BlockExpr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExprStmt extends Range {
|
||||||
|
kind: 'ExprStmt';
|
||||||
|
expr: Expr;
|
||||||
|
// Whether the statement is terminated with `;`. Block-form expressions
|
||||||
|
// (`if`/`switch`/`{...}`) don't require it; everything else does.
|
||||||
|
semi: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReturnStmt extends Range {
|
||||||
|
kind: 'Return';
|
||||||
|
value: Expr | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WhileStmt extends Range {
|
||||||
|
kind: 'While';
|
||||||
|
cond: Expr;
|
||||||
|
body: BlockExpr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoopStmt extends Range {
|
||||||
|
kind: 'Loop';
|
||||||
|
body: BlockExpr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForStmt extends Range {
|
||||||
|
kind: 'For';
|
||||||
|
varName: string;
|
||||||
|
varRange: Range;
|
||||||
|
iter: Expr;
|
||||||
|
body: BlockExpr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BreakStmt extends Range {
|
||||||
|
kind: 'Break';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContinueStmt extends Range {
|
||||||
|
kind: 'Continue';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TryStmt extends Range {
|
||||||
|
kind: 'Try';
|
||||||
|
body: BlockExpr;
|
||||||
|
catchVar: string | null;
|
||||||
|
catchVarRange: Range | null;
|
||||||
|
handler: BlockExpr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Expressions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type Expr =
|
||||||
|
| IdentExpr
|
||||||
|
| NumberExpr
|
||||||
|
| StringExpr
|
||||||
|
| BoolExpr
|
||||||
|
| NullExpr
|
||||||
|
| CallExpr
|
||||||
|
| MemberExpr
|
||||||
|
| IndexExpr
|
||||||
|
| UnaryExpr
|
||||||
|
| BinaryExpr
|
||||||
|
| AssignExpr
|
||||||
|
| ParenExpr
|
||||||
|
| ObjectMapExpr
|
||||||
|
| ArrayExpr
|
||||||
|
| FnExpr
|
||||||
|
| IfExpr
|
||||||
|
| SwitchExpr
|
||||||
|
| BlockExpr;
|
||||||
|
|
||||||
|
export interface IdentExpr extends Range {
|
||||||
|
kind: 'Ident';
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberExpr extends Range {
|
||||||
|
kind: 'Number';
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StringExpr extends Range {
|
||||||
|
kind: 'String';
|
||||||
|
// The surrounding quote — `"` is escape-processed, backtick is raw and
|
||||||
|
// may span multiple lines. We don't decode escapes; the formatter just
|
||||||
|
// preserves the raw text between the quotes.
|
||||||
|
quote: '"' | '`';
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoolExpr extends Range {
|
||||||
|
kind: 'Bool';
|
||||||
|
value: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NullExpr extends Range {
|
||||||
|
kind: 'Null';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallExpr extends Range {
|
||||||
|
kind: 'Call';
|
||||||
|
callee: Expr;
|
||||||
|
args: Expr[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberExpr extends Range {
|
||||||
|
kind: 'Member';
|
||||||
|
object: Expr;
|
||||||
|
property: string;
|
||||||
|
propertyRange: Range;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexExpr extends Range {
|
||||||
|
kind: 'Index';
|
||||||
|
object: Expr;
|
||||||
|
index: Expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnaryExpr extends Range {
|
||||||
|
kind: 'Unary';
|
||||||
|
op: string;
|
||||||
|
operand: Expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BinaryExpr extends Range {
|
||||||
|
kind: 'Binary';
|
||||||
|
op: string;
|
||||||
|
left: Expr;
|
||||||
|
right: Expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssignExpr extends Range {
|
||||||
|
kind: 'Assign';
|
||||||
|
op: string; // = += -= *= /= %= ??=
|
||||||
|
target: Expr;
|
||||||
|
value: Expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParenExpr extends Range {
|
||||||
|
kind: 'Paren';
|
||||||
|
expr: Expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectMapEntry extends Range {
|
||||||
|
key: string;
|
||||||
|
keyRange: Range;
|
||||||
|
value: Expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectMapExpr extends Range {
|
||||||
|
kind: 'ObjectMap';
|
||||||
|
entries: ObjectMapEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArrayExpr extends Range {
|
||||||
|
kind: 'Array';
|
||||||
|
elements: Expr[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FnExpr extends Range {
|
||||||
|
kind: 'FnExpr';
|
||||||
|
params: Param[];
|
||||||
|
body: BlockExpr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IfExpr extends Range {
|
||||||
|
kind: 'IfExpr';
|
||||||
|
cond: Expr;
|
||||||
|
then: BlockExpr;
|
||||||
|
// else branch: either a block or another `if` for `else if` chains.
|
||||||
|
else_: BlockExpr | IfExpr | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwitchArm extends Range {
|
||||||
|
pattern: Expr | null; // null = `_` default case
|
||||||
|
guard: Expr | null;
|
||||||
|
value: Expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwitchExpr extends Range {
|
||||||
|
kind: 'SwitchExpr';
|
||||||
|
subject: Expr;
|
||||||
|
arms: SwitchArm[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlockExpr extends Range {
|
||||||
|
kind: 'BlockExpr';
|
||||||
|
stmts: Stmt[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Top-level parse output
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ParseError extends Range {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParseResult {
|
||||||
|
source: string;
|
||||||
|
program: BlockExpr;
|
||||||
|
errors: ParseError[];
|
||||||
|
comments: Comment[];
|
||||||
|
// Offsets at which the source contained a blank line (a whitespace
|
||||||
|
// run with two or more newlines). One entry per blank run; the
|
||||||
|
// formatter consults these to preserve user-intent vertical grouping.
|
||||||
|
blankLines: number[];
|
||||||
|
}
|
||||||
153
dashboard/src/lib/rhai/format.test.ts
Normal file
153
dashboard/src/lib/rhai/format.test.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { format } from './format';
|
||||||
|
|
||||||
|
function formatted(src: string): string {
|
||||||
|
const r = format(src);
|
||||||
|
if (!r.ok) throw new Error(`expected format to succeed, got: ${r.error.message}`);
|
||||||
|
return r.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('format — basic shape', () => {
|
||||||
|
it('normalizes a simple let with operator spacing', () => {
|
||||||
|
const out = formatted('let x=1+2 * 3;');
|
||||||
|
expect(out).toBe('let x = 1 + 2 * 3;\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a fn declaration with body', () => {
|
||||||
|
const out = formatted('fn process(order,user){order.total}');
|
||||||
|
expect(out).toBe(
|
||||||
|
'fn process(order, user) {\n' +
|
||||||
|
'\torder.total\n' +
|
||||||
|
'}\n'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not insert a blank between fn decls the user did not separate', () => {
|
||||||
|
// Strict preserve-only policy: no source blank => no emitted blank.
|
||||||
|
const out = formatted('fn a(){1}fn b(){2}');
|
||||||
|
expect(out).toBe('fn a() {\n\t1\n}\nfn b() {\n\t2\n}\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders if / else if / else with blocks', () => {
|
||||||
|
const out = formatted('if a{1}else if b{2}else{3}');
|
||||||
|
expect(out).toBe(
|
||||||
|
'if a {\n\t1\n} else if b {\n\t2\n} else {\n\t3\n}\n'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an object-map literal inline when short', () => {
|
||||||
|
const out = formatted('let o=#{a:1,b:2};');
|
||||||
|
expect(out).toBe('let o = #{ a: 1, b: 2 };\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders log::info as a namespace call', () => {
|
||||||
|
const out = formatted('log::info( "hi" );');
|
||||||
|
expect(out).toBe('log::info("hi");\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves comments verbatim before statements', () => {
|
||||||
|
const out = formatted('// docstring\nfn process(){1}');
|
||||||
|
expect(out).toBe(
|
||||||
|
'// docstring\nfn process() {\n\t1\n}\n'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps block comments verbatim', () => {
|
||||||
|
const out = formatted('/* keep me */ let x = 1;');
|
||||||
|
expect(out).toContain('/* keep me */');
|
||||||
|
expect(out).toContain('let x = 1;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits an empty block as `{}` without padding', () => {
|
||||||
|
const out = formatted('fn noop(){}');
|
||||||
|
expect(out).toBe('fn noop() {}\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves string literals verbatim', () => {
|
||||||
|
const out = formatted('let s = "hello\\nworld";');
|
||||||
|
expect(out).toBe('let s = "hello\\nworld";\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('format — reflow', () => {
|
||||||
|
it('reflows a long argument list onto separate lines', () => {
|
||||||
|
const src =
|
||||||
|
'process(aaaaaaaaaa, bbbbbbbbbb, cccccccccc, dddddddddd, eeeeeeeeee, ffffffffff, gggggggggg, hhhhhhhhhh);';
|
||||||
|
const out = formatted(src);
|
||||||
|
// Should contain at least one newline inside the parens (multi-line).
|
||||||
|
const callBlock = out.slice(out.indexOf('('), out.lastIndexOf(')') + 1);
|
||||||
|
expect(callBlock).toContain('\n');
|
||||||
|
expect(callBlock.endsWith(',\n)')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps short argument lists inline', () => {
|
||||||
|
const out = formatted('process(1, 2, 3);');
|
||||||
|
expect(out).toBe('process(1, 2, 3);\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('format — blank-line preservation', () => {
|
||||||
|
it('preserves a single blank line between statements', () => {
|
||||||
|
const src = 'let a = 1;\n\nlet b = 2;';
|
||||||
|
expect(formatted(src)).toBe('let a = 1;\n\nlet b = 2;\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses multiple blank lines to a single one', () => {
|
||||||
|
const src = 'let a = 1;\n\n\n\nlet b = 2;';
|
||||||
|
expect(formatted(src)).toBe('let a = 1;\n\nlet b = 2;\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves blanks inside block bodies', () => {
|
||||||
|
const src = 'fn process() {\n\tlet a = 1;\n\n\tlet b = 2;\n}';
|
||||||
|
expect(formatted(src)).toBe('fn process() {\n\tlet a = 1;\n\n\tlet b = 2;\n}\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not invent blanks between adjacent statements', () => {
|
||||||
|
expect(formatted('let a=1;let b=2;')).toBe('let a = 1;\nlet b = 2;\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('format — parse failures', () => {
|
||||||
|
it('returns ok=false with a Rhai-flavored message and 1-based line/column', () => {
|
||||||
|
// Pattern from the user complaint: `let;` should surface as
|
||||||
|
// "Expecting name of a variable" at line/column.
|
||||||
|
const r = format('let msg = ctx.request.params.name;\nlet;\n');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.error.message).toBe('Expecting name of a variable');
|
||||||
|
expect(r.error.line).toBe(2);
|
||||||
|
expect(r.error.column).toBe(4);
|
||||||
|
expect(r.error.offset).toBeGreaterThanOrEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports script-incomplete on truncated input', () => {
|
||||||
|
// `fn` alone — the parser expects a function name and hits EOF.
|
||||||
|
const r = format('fn');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.error.message).toMatch(/script is incomplete/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not partially rewrite when parsing fails', () => {
|
||||||
|
const r = format('let x = 1; this is garbage');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('format — idempotent', () => {
|
||||||
|
it('formatting twice yields the same output', () => {
|
||||||
|
const src = `
|
||||||
|
fn process(order,user) {
|
||||||
|
if order.total > 100 {
|
||||||
|
log::info("big", #{id:order.id});
|
||||||
|
} else {
|
||||||
|
log::info("small");
|
||||||
|
}
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const a = formatted(src);
|
||||||
|
const b = formatted(a);
|
||||||
|
expect(b).toBe(a);
|
||||||
|
});
|
||||||
|
});
|
||||||
479
dashboard/src/lib/rhai/format.ts
Normal file
479
dashboard/src/lib/rhai/format.ts
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
// Rhai source formatter.
|
||||||
|
//
|
||||||
|
// Parses the source, walks the AST, emits canonical text. On a parse
|
||||||
|
// failure it returns the first error and leaves the caller responsible
|
||||||
|
// for showing it (the dashboard's UX mirrors the JSON "Format" button:
|
||||||
|
// the doc is untouched and the error is surfaced inline).
|
||||||
|
//
|
||||||
|
// Choices:
|
||||||
|
// * Indent = one tab. The dashboard CSS is tab-based and the editor
|
||||||
|
// keymaps `indentWithTab`, so matching the existing convention.
|
||||||
|
// * Print width = 100 cols. If an inline-printed call's argument list
|
||||||
|
// would push the current line past 100 cols, the args reflow one
|
||||||
|
// per line with a trailing comma.
|
||||||
|
// * Comments preserved verbatim. Each comment lands on its own line at
|
||||||
|
// the indent of the statement it precedes — same-line inline
|
||||||
|
// positioning is intentionally NOT recovered; the goal is "verbatim
|
||||||
|
// text", not "byte-exact placement".
|
||||||
|
// * Blank lines between statements are preserved when the user wrote
|
||||||
|
// them; multiples collapse to one. The formatter never *adds* blank
|
||||||
|
// lines the user didn't write (rustfmt's default policy applied
|
||||||
|
// strictly — no forced separation between top-level fn decls).
|
||||||
|
// * Block bodies always use multi-line braces. `{}` for empty.
|
||||||
|
// * If parse errors are reported by the parser, the formatter refuses
|
||||||
|
// to emit anything and returns the first error with line / column
|
||||||
|
// coordinates (1-based, matching Rhai's own diagnostic format).
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BlockExpr,
|
||||||
|
Comment,
|
||||||
|
Expr,
|
||||||
|
IfExpr,
|
||||||
|
ObjectMapExpr,
|
||||||
|
ParseError,
|
||||||
|
ParseResult,
|
||||||
|
Stmt,
|
||||||
|
SwitchExpr
|
||||||
|
} from './ast';
|
||||||
|
import { parse } from './parser';
|
||||||
|
|
||||||
|
const PRINT_WIDTH = 100;
|
||||||
|
|
||||||
|
export type FormatResult =
|
||||||
|
| { ok: true; text: string }
|
||||||
|
| { ok: false; error: FormatError };
|
||||||
|
|
||||||
|
export interface FormatError {
|
||||||
|
message: string;
|
||||||
|
// 1-based line and column, matching Rhai's own diagnostic format.
|
||||||
|
line: number;
|
||||||
|
column: number;
|
||||||
|
// Byte offset retained for callers that want to jump the editor
|
||||||
|
// cursor (CodeMirror works in offsets, not line/col).
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function format(source: string): FormatResult {
|
||||||
|
const result = parse(source);
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
return { ok: false, error: errorPayload(source, result.errors[0]) };
|
||||||
|
}
|
||||||
|
const p = new Printer(result);
|
||||||
|
p.printProgram();
|
||||||
|
return { ok: true, text: p.finish() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorPayload(source: string, e: ParseError): FormatError {
|
||||||
|
const { line, column } = lineColAt(source, e.start);
|
||||||
|
return { message: e.message, line, column, offset: e.start };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a byte offset into 1-based (line, column). Used for rendering
|
||||||
|
// parser errors in a way that matches Rhai's own diagnostic format
|
||||||
|
// (e.g. "Expecting name of a variable (line 2, position 4)").
|
||||||
|
function lineColAt(source: string, offset: number): { line: number; column: number } {
|
||||||
|
let line = 1;
|
||||||
|
let lineStart = 0;
|
||||||
|
const limit = Math.min(offset, source.length);
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
if (source.charCodeAt(i) === 10) {
|
||||||
|
line++;
|
||||||
|
lineStart = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { line, column: limit - lineStart + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
class Printer {
|
||||||
|
private buf = '';
|
||||||
|
private indent = 0;
|
||||||
|
private commentPtr = 0;
|
||||||
|
|
||||||
|
constructor(private result: ParseResult) {}
|
||||||
|
|
||||||
|
finish(): string {
|
||||||
|
this.drainCommentsBefore(this.result.source.length + 1);
|
||||||
|
// Strip trailing whitespace from every line; ensure a single
|
||||||
|
// terminating newline.
|
||||||
|
const text = this.buf.replace(/[ \t]+$/gm, '').replace(/\n*$/, '\n');
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- emit
|
||||||
|
|
||||||
|
private emit(s: string): void {
|
||||||
|
this.buf += s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private newline(): void {
|
||||||
|
this.buf += '\n' + '\t'.repeat(this.indent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private blankLine(): void {
|
||||||
|
// Two newlines, but never more than that.
|
||||||
|
if (this.buf.endsWith('\n\n' + '\t'.repeat(this.indent))) return;
|
||||||
|
// Remove any trailing indent we already wrote so the blank line is
|
||||||
|
// truly blank (no stray tabs).
|
||||||
|
this.buf = this.buf.replace(/[ \t]*$/, '');
|
||||||
|
if (!this.buf.endsWith('\n')) this.buf += '\n';
|
||||||
|
this.buf += '\n' + '\t'.repeat(this.indent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private column(): number {
|
||||||
|
const last = this.buf.lastIndexOf('\n');
|
||||||
|
return this.buf.length - (last + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run `body` against a scratch buffer, return the text it would have
|
||||||
|
// appended. Useful for measuring before deciding whether to reflow.
|
||||||
|
private measure(body: () => void): string {
|
||||||
|
const prev = this.buf;
|
||||||
|
body();
|
||||||
|
const text = this.buf.slice(prev.length);
|
||||||
|
this.buf = prev;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ comments
|
||||||
|
|
||||||
|
private drainCommentsBefore(pos: number): void {
|
||||||
|
const comments = this.result.comments;
|
||||||
|
while (this.commentPtr < comments.length && comments[this.commentPtr].start < pos) {
|
||||||
|
const c = comments[this.commentPtr++];
|
||||||
|
this.emitComment(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitComment(c: Comment): void {
|
||||||
|
// Emit each comment on its own line at the current indent. For
|
||||||
|
// block comments we still keep the original text (which may span
|
||||||
|
// multiple lines) verbatim.
|
||||||
|
this.emit(c.text);
|
||||||
|
this.newline();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- program
|
||||||
|
|
||||||
|
printProgram(): void {
|
||||||
|
const stmts = this.result.program.stmts;
|
||||||
|
for (let i = 0; i < stmts.length; i++) {
|
||||||
|
const stmt = stmts[i];
|
||||||
|
if (i > 0) {
|
||||||
|
if (this.hadBlankBetween(stmts[i - 1].end, stmt.start)) this.blankLine();
|
||||||
|
else this.newline();
|
||||||
|
}
|
||||||
|
this.drainCommentsBefore(stmt.start);
|
||||||
|
this.printStmt(stmt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Did the user leave a blank line in this gap?" Consulted between
|
||||||
|
// every pair of emitted statements to decide whether to keep the
|
||||||
|
// vertical separator the source originally had.
|
||||||
|
private hadBlankBetween(prevEnd: number, currStart: number): boolean {
|
||||||
|
for (const offset of this.result.blankLines) {
|
||||||
|
if (offset >= prevEnd && offset < currStart) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------- statements
|
||||||
|
|
||||||
|
private printStmt(stmt: Stmt): void {
|
||||||
|
switch (stmt.kind) {
|
||||||
|
case 'Let':
|
||||||
|
case 'Const': {
|
||||||
|
this.emit(stmt.kind === 'Let' ? 'let ' : 'const ');
|
||||||
|
this.emit(stmt.name);
|
||||||
|
if (stmt.init) {
|
||||||
|
this.emit(' = ');
|
||||||
|
this.printExpr(stmt.init);
|
||||||
|
}
|
||||||
|
this.emit(';');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'FnDecl': {
|
||||||
|
this.emit('fn ');
|
||||||
|
this.emit(stmt.name);
|
||||||
|
this.emit('(');
|
||||||
|
this.emit(stmt.params.map((p) => p.name).join(', '));
|
||||||
|
this.emit(') ');
|
||||||
|
this.printBlock(stmt.body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'ExprStmt': {
|
||||||
|
this.printExpr(stmt.expr);
|
||||||
|
// Preserve whether the user terminated with `;`. Block-form
|
||||||
|
// expressions never take one, so suppress regardless.
|
||||||
|
if (stmt.semi && !isBlockForm(stmt.expr)) this.emit(';');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'Return': {
|
||||||
|
this.emit('return');
|
||||||
|
if (stmt.value) {
|
||||||
|
this.emit(' ');
|
||||||
|
this.printExpr(stmt.value);
|
||||||
|
}
|
||||||
|
this.emit(';');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'While': {
|
||||||
|
this.emit('while ');
|
||||||
|
this.printExpr(stmt.cond);
|
||||||
|
this.emit(' ');
|
||||||
|
this.printBlock(stmt.body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'Loop': {
|
||||||
|
this.emit('loop ');
|
||||||
|
this.printBlock(stmt.body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'For': {
|
||||||
|
this.emit('for ');
|
||||||
|
this.emit(stmt.varName);
|
||||||
|
this.emit(' in ');
|
||||||
|
this.printExpr(stmt.iter);
|
||||||
|
this.emit(' ');
|
||||||
|
this.printBlock(stmt.body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'Break':
|
||||||
|
this.emit('break;');
|
||||||
|
return;
|
||||||
|
case 'Continue':
|
||||||
|
this.emit('continue;');
|
||||||
|
return;
|
||||||
|
case 'Try': {
|
||||||
|
this.emit('try ');
|
||||||
|
this.printBlock(stmt.body);
|
||||||
|
this.emit(' catch');
|
||||||
|
if (stmt.catchVar) {
|
||||||
|
this.emit(' (');
|
||||||
|
this.emit(stmt.catchVar);
|
||||||
|
this.emit(') ');
|
||||||
|
} else {
|
||||||
|
this.emit(' ');
|
||||||
|
}
|
||||||
|
this.printBlock(stmt.handler);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private printBlock(block: BlockExpr): void {
|
||||||
|
if (block.stmts.length === 0) {
|
||||||
|
this.drainCommentsBefore(block.end);
|
||||||
|
this.emit('{}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emit('{');
|
||||||
|
this.indent++;
|
||||||
|
for (let i = 0; i < block.stmts.length; i++) {
|
||||||
|
if (i > 0 && this.hadBlankBetween(block.stmts[i - 1].end, block.stmts[i].start)) {
|
||||||
|
this.blankLine();
|
||||||
|
} else {
|
||||||
|
this.newline();
|
||||||
|
}
|
||||||
|
this.drainCommentsBefore(block.stmts[i].start);
|
||||||
|
this.printStmt(block.stmts[i]);
|
||||||
|
}
|
||||||
|
this.drainCommentsBefore(block.end);
|
||||||
|
this.indent--;
|
||||||
|
this.newline();
|
||||||
|
this.emit('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------- expressions
|
||||||
|
|
||||||
|
private printExpr(expr: Expr): void {
|
||||||
|
switch (expr.kind) {
|
||||||
|
case 'Ident':
|
||||||
|
this.emit(expr.name);
|
||||||
|
return;
|
||||||
|
case 'Number':
|
||||||
|
case 'String':
|
||||||
|
this.emit(expr.raw);
|
||||||
|
return;
|
||||||
|
case 'Bool':
|
||||||
|
this.emit(expr.value ? 'true' : 'false');
|
||||||
|
return;
|
||||||
|
case 'Null':
|
||||||
|
this.emit('null');
|
||||||
|
return;
|
||||||
|
case 'Member': {
|
||||||
|
this.printExpr(expr.object);
|
||||||
|
// `log::info` was parsed as Member(Ident(log), 'info'); restore
|
||||||
|
// the namespace separator for known namespaces. `ctx.request`
|
||||||
|
// always uses `.` because we parsed it that way.
|
||||||
|
const sep = isNamespacePath(expr.object) ? '::' : '.';
|
||||||
|
this.emit(sep);
|
||||||
|
this.emit(expr.property);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'Index':
|
||||||
|
this.printExpr(expr.object);
|
||||||
|
this.emit('[');
|
||||||
|
this.printExpr(expr.index);
|
||||||
|
this.emit(']');
|
||||||
|
return;
|
||||||
|
case 'Call':
|
||||||
|
this.printExpr(expr.callee);
|
||||||
|
this.printArgList('(', ')', expr.args);
|
||||||
|
return;
|
||||||
|
case 'Unary':
|
||||||
|
this.emit(expr.op);
|
||||||
|
this.printExpr(expr.operand);
|
||||||
|
return;
|
||||||
|
case 'Binary':
|
||||||
|
this.printExpr(expr.left);
|
||||||
|
this.emit(` ${expr.op} `);
|
||||||
|
this.printExpr(expr.right);
|
||||||
|
return;
|
||||||
|
case 'Assign':
|
||||||
|
this.printExpr(expr.target);
|
||||||
|
this.emit(` ${expr.op} `);
|
||||||
|
this.printExpr(expr.value);
|
||||||
|
return;
|
||||||
|
case 'Paren':
|
||||||
|
this.emit('(');
|
||||||
|
this.printExpr(expr.expr);
|
||||||
|
this.emit(')');
|
||||||
|
return;
|
||||||
|
case 'Array':
|
||||||
|
this.printArgList('[', ']', expr.elements);
|
||||||
|
return;
|
||||||
|
case 'ObjectMap':
|
||||||
|
this.printObjectMap(expr);
|
||||||
|
return;
|
||||||
|
case 'FnExpr':
|
||||||
|
this.emit('fn (');
|
||||||
|
this.emit(expr.params.map((p) => p.name).join(', '));
|
||||||
|
this.emit(') ');
|
||||||
|
this.printBlock(expr.body);
|
||||||
|
return;
|
||||||
|
case 'IfExpr':
|
||||||
|
this.printIf(expr);
|
||||||
|
return;
|
||||||
|
case 'SwitchExpr':
|
||||||
|
this.printSwitch(expr);
|
||||||
|
return;
|
||||||
|
case 'BlockExpr':
|
||||||
|
this.printBlock(expr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private printArgList(open: string, close: string, items: Expr[]): void {
|
||||||
|
if (items.length === 0) {
|
||||||
|
this.emit(open);
|
||||||
|
this.emit(close);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inline = this.measure(() => {
|
||||||
|
this.emit(open);
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (i > 0) this.emit(', ');
|
||||||
|
this.printExpr(items[i]);
|
||||||
|
}
|
||||||
|
this.emit(close);
|
||||||
|
});
|
||||||
|
if (!inline.includes('\n') && this.column() + inline.length <= PRINT_WIDTH) {
|
||||||
|
this.emit(inline);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emit(open);
|
||||||
|
this.indent++;
|
||||||
|
for (const item of items) {
|
||||||
|
this.newline();
|
||||||
|
this.printExpr(item);
|
||||||
|
this.emit(',');
|
||||||
|
}
|
||||||
|
this.indent--;
|
||||||
|
this.newline();
|
||||||
|
this.emit(close);
|
||||||
|
}
|
||||||
|
|
||||||
|
private printObjectMap(expr: ObjectMapExpr): void {
|
||||||
|
if (expr.entries.length === 0) {
|
||||||
|
this.emit('#{}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inline = this.measure(() => {
|
||||||
|
this.emit('#{ ');
|
||||||
|
for (let i = 0; i < expr.entries.length; i++) {
|
||||||
|
const e = expr.entries[i];
|
||||||
|
if (i > 0) this.emit(', ');
|
||||||
|
this.emit(e.key);
|
||||||
|
this.emit(': ');
|
||||||
|
this.printExpr(e.value);
|
||||||
|
}
|
||||||
|
this.emit(' }');
|
||||||
|
});
|
||||||
|
if (!inline.includes('\n') && this.column() + inline.length <= PRINT_WIDTH) {
|
||||||
|
this.emit(inline);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emit('#{');
|
||||||
|
this.indent++;
|
||||||
|
for (const e of expr.entries) {
|
||||||
|
this.newline();
|
||||||
|
this.emit(e.key);
|
||||||
|
this.emit(': ');
|
||||||
|
this.printExpr(e.value);
|
||||||
|
this.emit(',');
|
||||||
|
}
|
||||||
|
this.indent--;
|
||||||
|
this.newline();
|
||||||
|
this.emit('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
private printIf(expr: IfExpr): void {
|
||||||
|
this.emit('if ');
|
||||||
|
this.printExpr(expr.cond);
|
||||||
|
this.emit(' ');
|
||||||
|
this.printBlock(expr.then);
|
||||||
|
if (expr.else_) {
|
||||||
|
this.emit(' else ');
|
||||||
|
if (expr.else_.kind === 'IfExpr') this.printIf(expr.else_);
|
||||||
|
else this.printBlock(expr.else_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private printSwitch(expr: SwitchExpr): void {
|
||||||
|
this.emit('switch ');
|
||||||
|
this.printExpr(expr.subject);
|
||||||
|
this.emit(' {');
|
||||||
|
this.indent++;
|
||||||
|
for (const arm of expr.arms) {
|
||||||
|
this.newline();
|
||||||
|
if (arm.pattern === null) this.emit('_');
|
||||||
|
else this.printExpr(arm.pattern);
|
||||||
|
if (arm.guard) {
|
||||||
|
this.emit(' if ');
|
||||||
|
this.printExpr(arm.guard);
|
||||||
|
}
|
||||||
|
this.emit(' => ');
|
||||||
|
this.printExpr(arm.value);
|
||||||
|
this.emit(',');
|
||||||
|
}
|
||||||
|
this.indent--;
|
||||||
|
this.newline();
|
||||||
|
this.emit('}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockForm(expr: Expr): boolean {
|
||||||
|
return expr.kind === 'IfExpr' || expr.kind === 'SwitchExpr' || expr.kind === 'BlockExpr' || expr.kind === 'FnExpr';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Namespace path detection — used by `Member` printing to decide between
|
||||||
|
// `.` and `::`. Currently the only well-known namespace in scripts is
|
||||||
|
// `log`, but we generalize to any bare identifier whose name happens to
|
||||||
|
// be the namespace token. False positives are harmless (we'd render
|
||||||
|
// `something::field` for a local named `log`); the parser-side fix would
|
||||||
|
// be a dedicated `Path` node — not worth it for one keyword.
|
||||||
|
function isNamespacePath(expr: Expr): boolean {
|
||||||
|
if (expr.kind === 'Ident') return expr.name === 'log';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
18
dashboard/src/lib/rhai/index.ts
Normal file
18
dashboard/src/lib/rhai/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Public entry points for the Rhai parser package. Editor features
|
||||||
|
// import from here.
|
||||||
|
|
||||||
|
export { parse } from './parser';
|
||||||
|
export { tokenize, KEYWORDS } from './lexer';
|
||||||
|
export { buildSymbolTable, renderFnSignature } from './symbols';
|
||||||
|
export { format } from './format';
|
||||||
|
export type { FormatError, FormatResult } from './format';
|
||||||
|
export type { Decl, DeclKind, Scope, SymbolTable, Usage } from './symbols';
|
||||||
|
export type {
|
||||||
|
BlockExpr,
|
||||||
|
Comment,
|
||||||
|
Expr,
|
||||||
|
ParseError,
|
||||||
|
ParseResult,
|
||||||
|
Range,
|
||||||
|
Stmt
|
||||||
|
} from './ast';
|
||||||
73
dashboard/src/lib/rhai/lexer.test.ts
Normal file
73
dashboard/src/lib/rhai/lexer.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { tokenize } from './lexer';
|
||||||
|
|
||||||
|
function kinds(src: string): string[] {
|
||||||
|
return tokenize(src).tokens.filter((t) => t.kind !== 'EOF').map((t) => t.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function texts(src: string): string[] {
|
||||||
|
return tokenize(src).tokens.filter((t) => t.kind !== 'EOF').map((t) => t.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('lexer', () => {
|
||||||
|
it('emits an EOF for empty input', () => {
|
||||||
|
const { tokens } = tokenize('');
|
||||||
|
expect(tokens).toHaveLength(1);
|
||||||
|
expect(tokens[0].kind).toBe('EOF');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distinguishes keywords from identifiers', () => {
|
||||||
|
const { tokens } = tokenize('let foo = bar;');
|
||||||
|
expect(tokens[0]).toMatchObject({ kind: 'Keyword', text: 'let' });
|
||||||
|
expect(tokens[1]).toMatchObject({ kind: 'Ident', text: 'foo' });
|
||||||
|
expect(tokens[2]).toMatchObject({ kind: 'Operator', text: '=' });
|
||||||
|
expect(tokens[3]).toMatchObject({ kind: 'Ident', text: 'bar' });
|
||||||
|
expect(tokens[4]).toMatchObject({ kind: 'Punct', text: ';' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lexes integer, float, hex, and binary numbers', () => {
|
||||||
|
expect(texts('1 1.5 0xff 0b1010 1e10 1_000')).toEqual(['1', '1.5', '0xff', '0b1010', '1e10', '1_000']);
|
||||||
|
expect(kinds('1 1.5 0xff')).toEqual(['Number', 'Number', 'Number']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lexes double-quote and backtick strings', () => {
|
||||||
|
const { tokens } = tokenize('"hi" `world`');
|
||||||
|
expect(tokens[0]).toMatchObject({ kind: 'String', text: '"hi"' });
|
||||||
|
expect(tokens[1]).toMatchObject({ kind: 'String', text: '`world`' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves backslash escapes inside double-quoted strings', () => {
|
||||||
|
const { tokens } = tokenize('"a\\"b"');
|
||||||
|
expect(tokens[0].text).toBe('"a\\"b"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures line and block comments as comments, not tokens', () => {
|
||||||
|
const { tokens, comments } = tokenize('let x = 1; // tail\n/* block */ y');
|
||||||
|
expect(comments.map((c) => c.kind)).toEqual(['LineComment', 'BlockComment']);
|
||||||
|
expect(tokens.find((t) => t.text === '//' || t.text === '/*')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles nested block comments', () => {
|
||||||
|
const { comments } = tokenize('/* outer /* inner */ still outer */');
|
||||||
|
expect(comments).toHaveLength(1);
|
||||||
|
expect(comments[0].text).toBe('/* outer /* inner */ still outer */');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lexes multi-character operators greedily', () => {
|
||||||
|
expect(texts('a == b && c != d')).toEqual(['a', '==', 'b', '&&', 'c', '!=', 'd']);
|
||||||
|
expect(texts('a ?? b ??= c')).toEqual(['a', '??', 'b', '??=', 'c']);
|
||||||
|
expect(texts('1..=10')).toEqual(['1', '..=', '10']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes #{ as separate punctuation tokens', () => {
|
||||||
|
const { tokens } = tokenize('#{}');
|
||||||
|
expect(tokens.slice(0, 3).map((t) => t.text)).toEqual(['#', '{', '}']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records accurate byte ranges', () => {
|
||||||
|
const src = 'let abc = 42;';
|
||||||
|
const { tokens } = tokenize(src);
|
||||||
|
const abc = tokens.find((t) => t.text === 'abc')!;
|
||||||
|
expect(src.slice(abc.start, abc.end)).toBe('abc');
|
||||||
|
});
|
||||||
|
});
|
||||||
266
dashboard/src/lib/rhai/lexer.ts
Normal file
266
dashboard/src/lib/rhai/lexer.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
// Tokenizer for the dashboard's Rhai parser.
|
||||||
|
//
|
||||||
|
// Produces a flat array of tokens (eager — Rhai scripts in the dashboard
|
||||||
|
// are small, 20–200 lines typical) plus a separate list of comments. The
|
||||||
|
// parser only sees tokens; comments are handed to the formatter so it
|
||||||
|
// can re-emit them at the right positions.
|
||||||
|
//
|
||||||
|
// Keyword and operator lists trace back to the upstream TextMate grammar
|
||||||
|
// (rhaiscript/vscode-rhai). We don't copy any grammar bytes.
|
||||||
|
|
||||||
|
import type { Comment, Range } from './ast';
|
||||||
|
|
||||||
|
export type TokenKind =
|
||||||
|
| 'Ident'
|
||||||
|
| 'Keyword'
|
||||||
|
| 'Number'
|
||||||
|
| 'String'
|
||||||
|
| 'Punct'
|
||||||
|
| 'Operator'
|
||||||
|
| 'EOF';
|
||||||
|
|
||||||
|
export interface Token extends Range {
|
||||||
|
kind: TokenKind;
|
||||||
|
// For Ident/Keyword/Punct/Operator: the literal source text. For
|
||||||
|
// Number/String: the full literal including quotes.
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KEYWORDS = new Set([
|
||||||
|
'let',
|
||||||
|
'const',
|
||||||
|
'fn',
|
||||||
|
'if',
|
||||||
|
'else',
|
||||||
|
'while',
|
||||||
|
'loop',
|
||||||
|
'do',
|
||||||
|
'for',
|
||||||
|
'in',
|
||||||
|
'return',
|
||||||
|
'break',
|
||||||
|
'continue',
|
||||||
|
'switch',
|
||||||
|
'case',
|
||||||
|
'default',
|
||||||
|
'true',
|
||||||
|
'false',
|
||||||
|
'null',
|
||||||
|
'try',
|
||||||
|
'catch',
|
||||||
|
'throw',
|
||||||
|
'as',
|
||||||
|
'is',
|
||||||
|
'private'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Multi-char operators, longest first so the lexer picks them up greedily.
|
||||||
|
const MULTI_CHAR_OPS = [
|
||||||
|
'??=',
|
||||||
|
'..=',
|
||||||
|
'??',
|
||||||
|
'..',
|
||||||
|
'::',
|
||||||
|
'==',
|
||||||
|
'!=',
|
||||||
|
'<=',
|
||||||
|
'>=',
|
||||||
|
'&&',
|
||||||
|
'||',
|
||||||
|
'<<',
|
||||||
|
'>>',
|
||||||
|
'+=',
|
||||||
|
'-=',
|
||||||
|
'*=',
|
||||||
|
'/=',
|
||||||
|
'%=',
|
||||||
|
'=>',
|
||||||
|
'->'
|
||||||
|
];
|
||||||
|
|
||||||
|
const SINGLE_CHAR_OPS = new Set(['+', '-', '*', '/', '%', '<', '>', '!', '&', '|', '^', '~', '=', '?']);
|
||||||
|
|
||||||
|
// `#` is included so we can recognize the start of `#{` object-map literals;
|
||||||
|
// the lexer emits it as a separate `Punct` and the parser combines it with
|
||||||
|
// the following `{`.
|
||||||
|
const PUNCTS = new Set(['(', ')', '{', '}', '[', ']', ';', ',', '.', ':', '#']);
|
||||||
|
|
||||||
|
export interface LexResult {
|
||||||
|
tokens: Token[];
|
||||||
|
comments: Comment[];
|
||||||
|
// Offsets at which the source contained at least one blank line (a
|
||||||
|
// run of whitespace with two or more newlines). One entry per blank
|
||||||
|
// run, pointing at the second-newline position. Used by the formatter
|
||||||
|
// to preserve user-intent vertical grouping.
|
||||||
|
blankLines: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tokenize(source: string): LexResult {
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
const comments: Comment[] = [];
|
||||||
|
const blankLines: number[] = [];
|
||||||
|
let i = 0;
|
||||||
|
const n = source.length;
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
const ch = source[i];
|
||||||
|
|
||||||
|
// Whitespace — coalesce runs and record blank-line offsets.
|
||||||
|
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
|
||||||
|
let newlines = 0;
|
||||||
|
let blankAt = -1;
|
||||||
|
while (i < n) {
|
||||||
|
const c = source[i];
|
||||||
|
if (c === '\n') {
|
||||||
|
newlines++;
|
||||||
|
if (newlines === 2) blankAt = i;
|
||||||
|
} else if (c !== ' ' && c !== '\t' && c !== '\r') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (blankAt >= 0) blankLines.push(blankAt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line comment
|
||||||
|
if (ch === '/' && source[i + 1] === '/') {
|
||||||
|
const start = i;
|
||||||
|
while (i < n && source[i] !== '\n') i++;
|
||||||
|
comments.push({ kind: 'LineComment', start, end: i, text: source.slice(start, i) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block comment (supports nesting per the Rhai book)
|
||||||
|
if (ch === '/' && source[i + 1] === '*') {
|
||||||
|
const start = i;
|
||||||
|
i += 2;
|
||||||
|
let depth = 1;
|
||||||
|
while (i < n && depth > 0) {
|
||||||
|
if (source[i] === '/' && source[i + 1] === '*') {
|
||||||
|
depth++;
|
||||||
|
i += 2;
|
||||||
|
} else if (source[i] === '*' && source[i + 1] === '/') {
|
||||||
|
depth--;
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
comments.push({ kind: 'BlockComment', start, end: i, text: source.slice(start, i) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strings: " ... " (escape-aware, single-line by convention) and
|
||||||
|
// ` ... ` (raw, multi-line). We tokenize the entire literal including
|
||||||
|
// quotes; the parser only cares about its position and text.
|
||||||
|
if (ch === '"' || ch === '`') {
|
||||||
|
const quote = ch;
|
||||||
|
const start = i;
|
||||||
|
i++;
|
||||||
|
while (i < n) {
|
||||||
|
const c = source[i];
|
||||||
|
if (c === '\\' && quote === '"') {
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === quote) {
|
||||||
|
i++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
tokens.push({ kind: 'String', start, end: i, text: source.slice(start, i) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numbers: hex, binary, decimal, optional `.frac`, optional exponent.
|
||||||
|
// Underscores are allowed as digit separators per Rhai.
|
||||||
|
if (isDigit(ch)) {
|
||||||
|
const start = i;
|
||||||
|
if (ch === '0' && (source[i + 1] === 'x' || source[i + 1] === 'X')) {
|
||||||
|
i += 2;
|
||||||
|
while (i < n && (isHexDigit(source[i]) || source[i] === '_')) i++;
|
||||||
|
} else if (ch === '0' && (source[i + 1] === 'b' || source[i + 1] === 'B')) {
|
||||||
|
i += 2;
|
||||||
|
while (i < n && (source[i] === '0' || source[i] === '1' || source[i] === '_')) i++;
|
||||||
|
} else {
|
||||||
|
while (i < n && (isDigit(source[i]) || source[i] === '_')) i++;
|
||||||
|
if (source[i] === '.' && isDigit(source[i + 1])) {
|
||||||
|
i++;
|
||||||
|
while (i < n && (isDigit(source[i]) || source[i] === '_')) i++;
|
||||||
|
}
|
||||||
|
if (source[i] === 'e' || source[i] === 'E') {
|
||||||
|
i++;
|
||||||
|
if (source[i] === '+' || source[i] === '-') i++;
|
||||||
|
while (i < n && isDigit(source[i])) i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokens.push({ kind: 'Number', start, end: i, text: source.slice(start, i) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identifier or keyword
|
||||||
|
if (isIdentStart(ch)) {
|
||||||
|
const start = i;
|
||||||
|
i++;
|
||||||
|
while (i < n && isIdentCont(source[i])) i++;
|
||||||
|
const text = source.slice(start, i);
|
||||||
|
tokens.push({
|
||||||
|
kind: KEYWORDS.has(text) ? 'Keyword' : 'Ident',
|
||||||
|
start,
|
||||||
|
end: i,
|
||||||
|
text
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-char operators
|
||||||
|
let matched = false;
|
||||||
|
for (const op of MULTI_CHAR_OPS) {
|
||||||
|
if (source.startsWith(op, i)) {
|
||||||
|
tokens.push({ kind: 'Operator', start: i, end: i + op.length, text: op });
|
||||||
|
i += op.length;
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matched) continue;
|
||||||
|
|
||||||
|
// Single-char operator
|
||||||
|
if (SINGLE_CHAR_OPS.has(ch)) {
|
||||||
|
tokens.push({ kind: 'Operator', start: i, end: i + 1, text: ch });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Punctuation
|
||||||
|
if (PUNCTS.has(ch)) {
|
||||||
|
tokens.push({ kind: 'Punct', start: i, end: i + 1, text: ch });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unrecognized: skip and let the parser report the gap if needed.
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.push({ kind: 'EOF', start: n, end: n, text: '' });
|
||||||
|
return { tokens, comments, blankLines };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDigit(c: string): boolean {
|
||||||
|
return c >= '0' && c <= '9';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHexDigit(c: string): boolean {
|
||||||
|
return isDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIdentStart(c: string): boolean {
|
||||||
|
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIdentCont(c: string): boolean {
|
||||||
|
return isIdentStart(c) || isDigit(c);
|
||||||
|
}
|
||||||
126
dashboard/src/lib/rhai/parser.test.ts
Normal file
126
dashboard/src/lib/rhai/parser.test.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parse } from './parser';
|
||||||
|
import type { BinaryExpr, ExprStmt, FnDecl, LetStmt } from './ast';
|
||||||
|
|
||||||
|
describe('parser — declarations', () => {
|
||||||
|
it('parses a let binding with initializer', () => {
|
||||||
|
const { program, errors } = parse('let x = 1 + 2;');
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
expect(program.stmts).toHaveLength(1);
|
||||||
|
const let_ = program.stmts[0] as LetStmt;
|
||||||
|
expect(let_.kind).toBe('Let');
|
||||||
|
expect(let_.name).toBe('x');
|
||||||
|
expect(let_.init?.kind).toBe('Binary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a const binding', () => {
|
||||||
|
const { program, errors } = parse('const PI = 3.14;');
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
expect(program.stmts[0]).toMatchObject({ kind: 'Const', name: 'PI' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses fn declarations with parameters', () => {
|
||||||
|
const { program, errors } = parse('fn process(order, user) { order.total }');
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
const fn = program.stmts[0] as FnDecl;
|
||||||
|
expect(fn.kind).toBe('FnDecl');
|
||||||
|
expect(fn.name).toBe('process');
|
||||||
|
expect(fn.params.map((p) => p.name)).toEqual(['order', 'user']);
|
||||||
|
expect(fn.body.stmts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parser — expressions', () => {
|
||||||
|
it('respects binary precedence (* before +)', () => {
|
||||||
|
const { program } = parse('let a = 1 + 2 * 3;');
|
||||||
|
const e = (program.stmts[0] as LetStmt).init as BinaryExpr;
|
||||||
|
expect(e.kind).toBe('Binary');
|
||||||
|
expect(e.op).toBe('+');
|
||||||
|
const right = e.right as BinaryExpr;
|
||||||
|
expect(right.op).toBe('*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses method chains (member + call + index)', () => {
|
||||||
|
const { program, errors } = parse('let x = ctx.request.body["k"];');
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
const init = (program.stmts[0] as LetStmt).init!;
|
||||||
|
expect(init.kind).toBe('Index');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses log::info("hi") as Call(Member(Ident(log), "info"), ["hi"])', () => {
|
||||||
|
const { program, errors } = parse('log::info("hi");');
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
const stmt = program.stmts[0] as ExprStmt;
|
||||||
|
expect(stmt.expr.kind).toBe('Call');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses object-map literal #{} with keys', () => {
|
||||||
|
const { program, errors } = parse('let o = #{ a: 1, b: 2 };');
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
const init = (program.stmts[0] as LetStmt).init!;
|
||||||
|
expect(init.kind).toBe('ObjectMap');
|
||||||
|
if (init.kind === 'ObjectMap') {
|
||||||
|
expect(init.entries.map((e) => e.key)).toEqual(['a', 'b']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses array literals', () => {
|
||||||
|
const { program, errors } = parse('let xs = [1, 2, 3];');
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
expect((program.stmts[0] as LetStmt).init!.kind).toBe('Array');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses if-as-expression for let RHS', () => {
|
||||||
|
const { program, errors } = parse('let x = if true { 1 } else { 2 };');
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
expect((program.stmts[0] as LetStmt).init!.kind).toBe('IfExpr');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parser — control flow', () => {
|
||||||
|
it('parses while, for, loop', () => {
|
||||||
|
const { errors: e1 } = parse('while true { break; }');
|
||||||
|
const { errors: e2 } = parse('for x in [1, 2] { x }');
|
||||||
|
const { errors: e3 } = parse('loop { break; }');
|
||||||
|
expect(e1).toEqual([]);
|
||||||
|
expect(e2).toEqual([]);
|
||||||
|
expect(e3).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses if / else if / else chains', () => {
|
||||||
|
const { program, errors } = parse(`
|
||||||
|
if a { 1 } else if b { 2 } else { 3 }
|
||||||
|
`);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
const stmt = program.stmts[0] as ExprStmt;
|
||||||
|
expect(stmt.expr.kind).toBe('IfExpr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses try / catch with binding', () => {
|
||||||
|
const { errors } = parse('try { foo(); } catch (e) { log::error(e); }');
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parser — error tolerance', () => {
|
||||||
|
it('is lenient about missing semicolons between statements', () => {
|
||||||
|
// The parser accepts implicit statement separation so completions
|
||||||
|
// remain useful while the user is still typing. Both bindings
|
||||||
|
// should land in the program regardless of the missing `;`.
|
||||||
|
const { program } = parse('let x = 1 let y = 2;');
|
||||||
|
const names = program.stmts.flatMap((s) => (s.kind === 'Let' ? [s.name] : []));
|
||||||
|
expect(names).toContain('x');
|
||||||
|
expect(names).toContain('y');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not loop forever on garbage', () => {
|
||||||
|
const { errors } = parse('@@@ ### }}}');
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers after a bad statement and parses the next one', () => {
|
||||||
|
const { program } = parse('let = ; let y = 2;');
|
||||||
|
const y = program.stmts.find((s) => s.kind === 'Let' && s.name === 'y');
|
||||||
|
expect(y).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
605
dashboard/src/lib/rhai/parser.ts
Normal file
605
dashboard/src/lib/rhai/parser.ts
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
// Parser for the dashboard's Rhai mode.
|
||||||
|
//
|
||||||
|
// Recursive descent for statements, Pratt precedence climbing for
|
||||||
|
// expressions. Error-tolerant: on unexpected input the parser records an
|
||||||
|
// error, resyncs to the next `;` or matching `}`, and keeps going. The
|
||||||
|
// AST it returns is best-effort — partial trees are fine; callers
|
||||||
|
// (autocomplete, goto-def) tolerate gaps.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BlockExpr,
|
||||||
|
Expr,
|
||||||
|
FnDecl,
|
||||||
|
IfExpr,
|
||||||
|
ObjectMapEntry,
|
||||||
|
Param,
|
||||||
|
ParseError,
|
||||||
|
ParseResult,
|
||||||
|
Stmt,
|
||||||
|
SwitchArm
|
||||||
|
} from './ast';
|
||||||
|
import { tokenize, type Token, type TokenKind } from './lexer';
|
||||||
|
|
||||||
|
export function parse(source: string): ParseResult {
|
||||||
|
const { tokens, comments, blankLines } = tokenize(source);
|
||||||
|
const p = new Parser(source, tokens);
|
||||||
|
const program = p.parseProgram();
|
||||||
|
return { source, program, errors: p.errors, comments, blankLines };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precedence levels for binary operators. Higher binds tighter. Assignment
|
||||||
|
// is special-cased outside the binary chain because it's right-associative
|
||||||
|
// and only legal at the top of an expression.
|
||||||
|
const BINARY_PRECEDENCE: Record<string, number> = {
|
||||||
|
'??': 1,
|
||||||
|
'||': 2,
|
||||||
|
'&&': 3,
|
||||||
|
'==': 4,
|
||||||
|
'!=': 4,
|
||||||
|
'<': 5,
|
||||||
|
'<=': 5,
|
||||||
|
'>': 5,
|
||||||
|
'>=': 5,
|
||||||
|
'|': 6,
|
||||||
|
'^': 7,
|
||||||
|
'&': 8,
|
||||||
|
'<<': 9,
|
||||||
|
'>>': 9,
|
||||||
|
'+': 10,
|
||||||
|
'-': 10,
|
||||||
|
'*': 11,
|
||||||
|
'/': 11,
|
||||||
|
'%': 11,
|
||||||
|
'..': 12,
|
||||||
|
'..=': 12
|
||||||
|
};
|
||||||
|
|
||||||
|
const ASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '??=']);
|
||||||
|
const UNARY_OPS = new Set(['!', '-', '+', '~']);
|
||||||
|
|
||||||
|
class Parser {
|
||||||
|
pos = 0;
|
||||||
|
errors: ParseError[] = [];
|
||||||
|
constructor(
|
||||||
|
private source: string,
|
||||||
|
private tokens: Token[]
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- nav
|
||||||
|
|
||||||
|
private peek(offset = 0): Token {
|
||||||
|
return this.tokens[Math.min(this.pos + offset, this.tokens.length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private advance(): Token {
|
||||||
|
const t = this.tokens[this.pos];
|
||||||
|
if (this.pos < this.tokens.length - 1) this.pos++;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private match(kind: TokenKind, text?: string): boolean {
|
||||||
|
const t = this.peek();
|
||||||
|
if (t.kind !== kind) return false;
|
||||||
|
if (text !== undefined && t.text !== text) return false;
|
||||||
|
this.advance();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private check(kind: TokenKind, text?: string): boolean {
|
||||||
|
const t = this.peek();
|
||||||
|
if (t.kind !== kind) return false;
|
||||||
|
if (text !== undefined && t.text !== text) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `role` is a human-readable description of what was expected, used
|
||||||
|
// in place of the bare token kind so the message reads like Rhai's
|
||||||
|
// own diagnostics (`Expecting name of a variable` rather than
|
||||||
|
// `expected ident`). Falls back to the literal/kind when omitted.
|
||||||
|
private expect(kind: TokenKind, text?: string, role?: string): Token {
|
||||||
|
const t = this.peek();
|
||||||
|
if (t.kind === kind && (text === undefined || t.text === text)) {
|
||||||
|
return this.advance();
|
||||||
|
}
|
||||||
|
if (t.kind === 'EOF') {
|
||||||
|
this.error(t, role ? `Expecting ${role} — script is incomplete` : 'Script is incomplete');
|
||||||
|
} else {
|
||||||
|
const desc = role ?? (text !== undefined ? `'${text}'` : kind.toLowerCase());
|
||||||
|
this.error(t, `Expecting ${desc}`);
|
||||||
|
}
|
||||||
|
// Return the token without consuming so the caller's parent can
|
||||||
|
// still resync at its own boundary.
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private error(at: Token, message: string): void {
|
||||||
|
this.errors.push({ start: at.start, end: at.end, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resync to the next statement boundary inside the current block. Used
|
||||||
|
// when a statement fails to parse — we drop tokens until we either land
|
||||||
|
// on `;` (consumed) or `}` / EOF (left for the caller).
|
||||||
|
private resyncStmt(): void {
|
||||||
|
let depth = 0;
|
||||||
|
while (true) {
|
||||||
|
const t = this.peek();
|
||||||
|
if (t.kind === 'EOF') return;
|
||||||
|
if (t.kind === 'Punct') {
|
||||||
|
if (t.text === '{' || t.text === '(' || t.text === '[') depth++;
|
||||||
|
else if (t.text === '}' || t.text === ')' || t.text === ']') {
|
||||||
|
if (depth === 0) return;
|
||||||
|
depth--;
|
||||||
|
} else if (depth === 0 && t.text === ';') {
|
||||||
|
this.advance();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.advance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ top level
|
||||||
|
|
||||||
|
parseProgram(): BlockExpr {
|
||||||
|
const start = this.peek().start;
|
||||||
|
const stmts: Stmt[] = [];
|
||||||
|
while (this.peek().kind !== 'EOF') {
|
||||||
|
const before = this.pos;
|
||||||
|
const stmt = this.parseStmt();
|
||||||
|
if (stmt) stmts.push(stmt);
|
||||||
|
else if (this.pos === before) {
|
||||||
|
// No forward progress — drop a token to avoid an infinite loop.
|
||||||
|
this.advance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const last = this.tokens[this.tokens.length - 1];
|
||||||
|
return { kind: 'BlockExpr', start, end: last.end, stmts };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- statements
|
||||||
|
|
||||||
|
private parseStmt(): Stmt | null {
|
||||||
|
const t = this.peek();
|
||||||
|
|
||||||
|
if (t.kind === 'Keyword') {
|
||||||
|
switch (t.text) {
|
||||||
|
case 'let':
|
||||||
|
return this.parseLetOrConst('Let');
|
||||||
|
case 'const':
|
||||||
|
return this.parseLetOrConst('Const');
|
||||||
|
case 'fn':
|
||||||
|
return this.parseFnDecl();
|
||||||
|
case 'return':
|
||||||
|
return this.parseReturn();
|
||||||
|
case 'while':
|
||||||
|
return this.parseWhile();
|
||||||
|
case 'loop':
|
||||||
|
return this.parseLoop();
|
||||||
|
case 'for':
|
||||||
|
return this.parseFor();
|
||||||
|
case 'break': {
|
||||||
|
this.advance();
|
||||||
|
const semi = this.match('Punct', ';');
|
||||||
|
return { kind: 'Break', start: t.start, end: semi ? t.end + 1 : t.end };
|
||||||
|
}
|
||||||
|
case 'continue': {
|
||||||
|
this.advance();
|
||||||
|
const semi = this.match('Punct', ';');
|
||||||
|
return { kind: 'Continue', start: t.start, end: semi ? t.end + 1 : t.end };
|
||||||
|
}
|
||||||
|
case 'try':
|
||||||
|
return this.parseTry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stray semicolons are no-ops; consume and try again.
|
||||||
|
if (this.match('Punct', ';')) return null;
|
||||||
|
|
||||||
|
// Expression statement (also covers if/switch/block-as-stmt because
|
||||||
|
// those parse as expressions).
|
||||||
|
const expr = this.tryParseExpr();
|
||||||
|
if (!expr) {
|
||||||
|
const bad = this.peek();
|
||||||
|
this.error(bad, bad.kind === 'EOF' ? 'Script is incomplete' : `Unexpected token '${bad.text}'`);
|
||||||
|
this.resyncStmt();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const semi = this.match('Punct', ';');
|
||||||
|
return {
|
||||||
|
kind: 'ExprStmt',
|
||||||
|
start: expr.start,
|
||||||
|
end: semi ? this.tokens[this.pos - 1].end : expr.end,
|
||||||
|
expr,
|
||||||
|
semi
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseLetOrConst(kind: 'Let' | 'Const'): Stmt {
|
||||||
|
const start = this.advance().start; // let|const
|
||||||
|
const nameTok = this.expect('Ident', undefined, 'name of a variable');
|
||||||
|
const name = nameTok.text;
|
||||||
|
const nameRange = { start: nameTok.start, end: nameTok.end };
|
||||||
|
let init: Expr | null = null;
|
||||||
|
if (this.match('Operator', '=')) {
|
||||||
|
init = this.tryParseExpr() ?? null;
|
||||||
|
}
|
||||||
|
const semi = this.match('Punct', ';');
|
||||||
|
const end = semi ? this.tokens[this.pos - 1].end : init ? init.end : nameTok.end;
|
||||||
|
return { kind, start, end, name, nameRange, init } as Stmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseFnDecl(): FnDecl {
|
||||||
|
const start = this.advance().start; // fn
|
||||||
|
const nameTok = this.expect('Ident', undefined, 'function name in function declaration');
|
||||||
|
this.expect('Punct', '(');
|
||||||
|
const params: Param[] = [];
|
||||||
|
while (!this.check('Punct', ')') && this.peek().kind !== 'EOF') {
|
||||||
|
const pTok = this.expect('Ident', undefined, 'parameter name');
|
||||||
|
params.push({ name: pTok.text, start: pTok.start, end: pTok.end });
|
||||||
|
if (!this.match('Punct', ',')) break;
|
||||||
|
}
|
||||||
|
this.expect('Punct', ')');
|
||||||
|
const body = this.parseBlockExpr();
|
||||||
|
return {
|
||||||
|
kind: 'FnDecl',
|
||||||
|
start,
|
||||||
|
end: body.end,
|
||||||
|
name: nameTok.text,
|
||||||
|
nameRange: { start: nameTok.start, end: nameTok.end },
|
||||||
|
params,
|
||||||
|
body
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseReturn(): Stmt {
|
||||||
|
const start = this.advance().start; // return
|
||||||
|
let value: Expr | null = null;
|
||||||
|
if (!this.check('Punct', ';') && !this.check('Punct', '}') && this.peek().kind !== 'EOF') {
|
||||||
|
value = this.tryParseExpr() ?? null;
|
||||||
|
}
|
||||||
|
const semi = this.match('Punct', ';');
|
||||||
|
const end = semi ? this.tokens[this.pos - 1].end : value ? value.end : start + 'return'.length;
|
||||||
|
return { kind: 'Return', start, end, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseWhile(): Stmt {
|
||||||
|
const start = this.advance().start; // while
|
||||||
|
const cond = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
const body = this.parseBlockExpr();
|
||||||
|
return { kind: 'While', start, end: body.end, cond, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseLoop(): Stmt {
|
||||||
|
const start = this.advance().start; // loop
|
||||||
|
const body = this.parseBlockExpr();
|
||||||
|
return { kind: 'Loop', start, end: body.end, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseFor(): Stmt {
|
||||||
|
const start = this.advance().start; // for
|
||||||
|
const nameTok = this.expect('Ident', undefined, 'loop variable name');
|
||||||
|
this.expect('Keyword', 'in');
|
||||||
|
const iter = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
const body = this.parseBlockExpr();
|
||||||
|
return {
|
||||||
|
kind: 'For',
|
||||||
|
start,
|
||||||
|
end: body.end,
|
||||||
|
varName: nameTok.text,
|
||||||
|
varRange: { start: nameTok.start, end: nameTok.end },
|
||||||
|
iter,
|
||||||
|
body
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseTry(): Stmt {
|
||||||
|
const start = this.advance().start; // try
|
||||||
|
const body = this.parseBlockExpr();
|
||||||
|
this.expect('Keyword', 'catch');
|
||||||
|
let catchVar: string | null = null;
|
||||||
|
let catchVarRange: { start: number; end: number } | null = null;
|
||||||
|
if (this.match('Punct', '(')) {
|
||||||
|
if (this.check('Ident')) {
|
||||||
|
const id = this.advance();
|
||||||
|
catchVar = id.text;
|
||||||
|
catchVarRange = { start: id.start, end: id.end };
|
||||||
|
}
|
||||||
|
this.expect('Punct', ')');
|
||||||
|
}
|
||||||
|
const handler = this.parseBlockExpr();
|
||||||
|
return { kind: 'Try', start, end: handler.end, body, catchVar, catchVarRange, handler };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseBlockExpr(): BlockExpr {
|
||||||
|
const openTok = this.peek();
|
||||||
|
if (!this.match('Punct', '{')) {
|
||||||
|
this.error(openTok, "Expecting '{' to begin a block");
|
||||||
|
return { kind: 'BlockExpr', start: openTok.start, end: openTok.start, stmts: [] };
|
||||||
|
}
|
||||||
|
const start = openTok.start;
|
||||||
|
const stmts: Stmt[] = [];
|
||||||
|
while (!this.check('Punct', '}') && this.peek().kind !== 'EOF') {
|
||||||
|
const before = this.pos;
|
||||||
|
const s = this.parseStmt();
|
||||||
|
if (s) stmts.push(s);
|
||||||
|
else if (this.pos === before) this.advance();
|
||||||
|
}
|
||||||
|
const closeTok = this.peek();
|
||||||
|
this.match('Punct', '}');
|
||||||
|
return { kind: 'BlockExpr', start, end: closeTok.end, stmts };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------- expressions
|
||||||
|
|
||||||
|
private tryParseExpr(): Expr | null {
|
||||||
|
const t = this.peek();
|
||||||
|
if (t.kind === 'EOF' || (t.kind === 'Punct' && (t.text === ';' || t.text === '}' || t.text === ')' || t.text === ']' || t.text === ','))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.parseAssign();
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseAssign(): Expr {
|
||||||
|
const left = this.parseBinary(0);
|
||||||
|
const t = this.peek();
|
||||||
|
if (t.kind === 'Operator' && ASSIGN_OPS.has(t.text)) {
|
||||||
|
this.advance();
|
||||||
|
const right = this.parseAssign();
|
||||||
|
return { kind: 'Assign', start: left.start, end: right.end, op: t.text, target: left, value: right };
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseBinary(minPrec: number): Expr {
|
||||||
|
let left = this.parseUnary();
|
||||||
|
while (true) {
|
||||||
|
const t = this.peek();
|
||||||
|
if (t.kind !== 'Operator') break;
|
||||||
|
const prec = BINARY_PRECEDENCE[t.text];
|
||||||
|
if (prec === undefined || prec < minPrec) break;
|
||||||
|
this.advance();
|
||||||
|
const right = this.parseBinary(prec + 1);
|
||||||
|
left = { kind: 'Binary', start: left.start, end: right.end, op: t.text, left, right };
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseUnary(): Expr {
|
||||||
|
const t = this.peek();
|
||||||
|
if (t.kind === 'Operator' && UNARY_OPS.has(t.text)) {
|
||||||
|
this.advance();
|
||||||
|
const operand = this.parseUnary();
|
||||||
|
return { kind: 'Unary', start: t.start, end: operand.end, op: t.text, operand };
|
||||||
|
}
|
||||||
|
return this.parsePostfix(this.parsePrimary());
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePostfix(initial: Expr): Expr {
|
||||||
|
let expr = initial;
|
||||||
|
while (true) {
|
||||||
|
const t = this.peek();
|
||||||
|
if (t.kind === 'Punct' && t.text === '.') {
|
||||||
|
this.advance();
|
||||||
|
const prop = this.expect('Ident', undefined, 'name of a property');
|
||||||
|
expr = {
|
||||||
|
kind: 'Member',
|
||||||
|
start: expr.start,
|
||||||
|
end: prop.end,
|
||||||
|
object: expr,
|
||||||
|
property: prop.text,
|
||||||
|
propertyRange: { start: prop.start, end: prop.end }
|
||||||
|
};
|
||||||
|
} else if (t.kind === 'Punct' && t.text === '(') {
|
||||||
|
this.advance();
|
||||||
|
const args: Expr[] = [];
|
||||||
|
while (!this.check('Punct', ')') && this.peek().kind !== 'EOF') {
|
||||||
|
const a = this.tryParseExpr();
|
||||||
|
if (!a) break;
|
||||||
|
args.push(a);
|
||||||
|
if (!this.match('Punct', ',')) break;
|
||||||
|
}
|
||||||
|
const close = this.peek();
|
||||||
|
this.expect('Punct', ')');
|
||||||
|
expr = { kind: 'Call', start: expr.start, end: close.end, callee: expr, args };
|
||||||
|
} else if (t.kind === 'Punct' && t.text === '[') {
|
||||||
|
this.advance();
|
||||||
|
const idx = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
const close = this.peek();
|
||||||
|
this.expect('Punct', ']');
|
||||||
|
expr = { kind: 'Index', start: expr.start, end: close.end, object: expr, index: idx };
|
||||||
|
} else if (t.kind === 'Operator' && t.text === '::') {
|
||||||
|
// Namespace path: treat `log::info` as a Member chain on an
|
||||||
|
// Ident so completion and lookup can walk the same shape.
|
||||||
|
this.advance();
|
||||||
|
const next = this.expect('Ident', undefined, "name after '::'");
|
||||||
|
expr = {
|
||||||
|
kind: 'Member',
|
||||||
|
start: expr.start,
|
||||||
|
end: next.end,
|
||||||
|
object: expr,
|
||||||
|
property: next.text,
|
||||||
|
propertyRange: { start: next.start, end: next.end }
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePrimary(): Expr {
|
||||||
|
const t = this.peek();
|
||||||
|
|
||||||
|
// Literals
|
||||||
|
if (t.kind === 'Number') {
|
||||||
|
this.advance();
|
||||||
|
return { kind: 'Number', start: t.start, end: t.end, raw: t.text };
|
||||||
|
}
|
||||||
|
if (t.kind === 'String') {
|
||||||
|
this.advance();
|
||||||
|
const quote = t.text.charAt(0) === '`' ? '`' : '"';
|
||||||
|
return { kind: 'String', start: t.start, end: t.end, quote, raw: t.text };
|
||||||
|
}
|
||||||
|
if (t.kind === 'Keyword') {
|
||||||
|
if (t.text === 'true' || t.text === 'false') {
|
||||||
|
this.advance();
|
||||||
|
return { kind: 'Bool', start: t.start, end: t.end, value: t.text === 'true' };
|
||||||
|
}
|
||||||
|
if (t.text === 'null') {
|
||||||
|
this.advance();
|
||||||
|
return { kind: 'Null', start: t.start, end: t.end };
|
||||||
|
}
|
||||||
|
if (t.text === 'if') return this.parseIfExpr();
|
||||||
|
if (t.text === 'switch') return this.parseSwitchExpr();
|
||||||
|
if (t.text === 'fn') return this.parseFnExpr();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identifier
|
||||||
|
if (t.kind === 'Ident') {
|
||||||
|
this.advance();
|
||||||
|
return { kind: 'Ident', start: t.start, end: t.end, name: t.text };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paren expression
|
||||||
|
if (t.kind === 'Punct' && t.text === '(') {
|
||||||
|
this.advance();
|
||||||
|
const inner = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
const close = this.peek();
|
||||||
|
this.expect('Punct', ')');
|
||||||
|
return { kind: 'Paren', start: t.start, end: close.end, expr: inner };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array literal
|
||||||
|
if (t.kind === 'Punct' && t.text === '[') {
|
||||||
|
return this.parseArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object-map literal: `#{`
|
||||||
|
if (t.kind === 'Punct' && t.text === '#' && this.peek(1).kind === 'Punct' && this.peek(1).text === '{') {
|
||||||
|
return this.parseObjectMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block expression `{ ... }`
|
||||||
|
if (t.kind === 'Punct' && t.text === '{') {
|
||||||
|
return this.parseBlockExpr();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.error(t, t.kind === 'EOF' ? 'Script is incomplete' : `Unexpected token '${t.text}'`);
|
||||||
|
// Consume one token so we make forward progress, then return a
|
||||||
|
// placeholder so the surrounding parser keeps its shape.
|
||||||
|
this.advance();
|
||||||
|
return this.placeholderExpr(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseIfExpr(): IfExpr {
|
||||||
|
const start = this.advance().start; // if
|
||||||
|
const cond = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
const thenB = this.parseBlockExpr();
|
||||||
|
let else_: BlockExpr | IfExpr | null = null;
|
||||||
|
if (this.match('Keyword', 'else')) {
|
||||||
|
if (this.check('Keyword', 'if')) {
|
||||||
|
else_ = this.parseIfExpr();
|
||||||
|
} else {
|
||||||
|
else_ = this.parseBlockExpr();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const end = else_ ? else_.end : thenB.end;
|
||||||
|
return { kind: 'IfExpr', start, end, cond, then: thenB, else_ };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSwitchExpr(): Expr {
|
||||||
|
const start = this.advance().start; // switch
|
||||||
|
const subject = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
this.expect('Punct', '{');
|
||||||
|
const arms: SwitchArm[] = [];
|
||||||
|
while (!this.check('Punct', '}') && this.peek().kind !== 'EOF') {
|
||||||
|
const armStart = this.peek().start;
|
||||||
|
let pattern: Expr | null;
|
||||||
|
if (this.check('Operator', '_') || (this.peek().kind === 'Ident' && this.peek().text === '_')) {
|
||||||
|
this.advance();
|
||||||
|
pattern = null;
|
||||||
|
} else {
|
||||||
|
pattern = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
}
|
||||||
|
let guard: Expr | null = null;
|
||||||
|
if (this.match('Keyword', 'if')) {
|
||||||
|
guard = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
}
|
||||||
|
this.expect('Operator', '=>');
|
||||||
|
const value = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
arms.push({ start: armStart, end: value.end, pattern, guard, value });
|
||||||
|
if (!this.match('Punct', ',')) break;
|
||||||
|
}
|
||||||
|
const close = this.peek();
|
||||||
|
this.expect('Punct', '}');
|
||||||
|
return { kind: 'SwitchExpr', start, end: close.end, subject, arms };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseFnExpr(): Expr {
|
||||||
|
// `fn (params) { ... }` — anonymous function expression. Rare in
|
||||||
|
// Rhai but legal; some scripts use it for callbacks.
|
||||||
|
const start = this.advance().start; // fn
|
||||||
|
this.expect('Punct', '(');
|
||||||
|
const params: Param[] = [];
|
||||||
|
while (!this.check('Punct', ')') && this.peek().kind !== 'EOF') {
|
||||||
|
const pTok = this.expect('Ident', undefined, 'parameter name');
|
||||||
|
params.push({ name: pTok.text, start: pTok.start, end: pTok.end });
|
||||||
|
if (!this.match('Punct', ',')) break;
|
||||||
|
}
|
||||||
|
this.expect('Punct', ')');
|
||||||
|
const body = this.parseBlockExpr();
|
||||||
|
return { kind: 'FnExpr', start, end: body.end, params, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseArray(): Expr {
|
||||||
|
const start = this.advance().start; // [
|
||||||
|
const elements: Expr[] = [];
|
||||||
|
while (!this.check('Punct', ']') && this.peek().kind !== 'EOF') {
|
||||||
|
const e = this.tryParseExpr();
|
||||||
|
if (!e) break;
|
||||||
|
elements.push(e);
|
||||||
|
if (!this.match('Punct', ',')) break;
|
||||||
|
}
|
||||||
|
const close = this.peek();
|
||||||
|
this.expect('Punct', ']');
|
||||||
|
return { kind: 'Array', start, end: close.end, elements };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseObjectMap(): Expr {
|
||||||
|
const start = this.advance().start; // #
|
||||||
|
this.advance(); // {
|
||||||
|
const entries: ObjectMapEntry[] = [];
|
||||||
|
while (!this.check('Punct', '}') && this.peek().kind !== 'EOF') {
|
||||||
|
const k = this.peek();
|
||||||
|
let key: string;
|
||||||
|
let keyRange: { start: number; end: number };
|
||||||
|
if (k.kind === 'Ident' || k.kind === 'Keyword') {
|
||||||
|
this.advance();
|
||||||
|
key = k.text;
|
||||||
|
keyRange = { start: k.start, end: k.end };
|
||||||
|
} else if (k.kind === 'String') {
|
||||||
|
this.advance();
|
||||||
|
// Strip surrounding quotes for the key name (best-effort —
|
||||||
|
// we don't decode escape sequences; this is only used for
|
||||||
|
// completion labels).
|
||||||
|
key = k.text.length >= 2 ? k.text.slice(1, -1) : k.text;
|
||||||
|
keyRange = { start: k.start, end: k.end };
|
||||||
|
} else {
|
||||||
|
this.error(k, 'Expecting name of a map key');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.expect('Punct', ':');
|
||||||
|
const value = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
|
entries.push({ start: keyRange.start, end: value.end, key, keyRange, value });
|
||||||
|
if (!this.match('Punct', ',')) break;
|
||||||
|
}
|
||||||
|
const close = this.peek();
|
||||||
|
this.expect('Punct', '}');
|
||||||
|
return { kind: 'ObjectMap', start, end: close.end, entries };
|
||||||
|
}
|
||||||
|
|
||||||
|
private placeholderExpr(at?: Token): Expr {
|
||||||
|
const t = at ?? this.peek();
|
||||||
|
return { kind: 'Ident', start: t.start, end: t.start, name: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
119
dashboard/src/lib/rhai/symbols.test.ts
Normal file
119
dashboard/src/lib/rhai/symbols.test.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parse } from './parser';
|
||||||
|
import { buildSymbolTable } from './symbols';
|
||||||
|
|
||||||
|
function build(src: string) {
|
||||||
|
const r = parse(src);
|
||||||
|
return { ...r, table: buildSymbolTable(r) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('symbols — declarations and usages', () => {
|
||||||
|
it('captures let declarations', () => {
|
||||||
|
const { table } = build('let x = 1; x + 1;');
|
||||||
|
const x = table.allDecls.find((d) => d.name === 'x')!;
|
||||||
|
expect(x.kind).toBe('let');
|
||||||
|
expect(table.usages.find((u) => u.name === 'x')!.resolved).toBe(x);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records fn signatures for completion detail', () => {
|
||||||
|
const { table } = build('fn process(order, user) { order }');
|
||||||
|
const fn = table.allDecls.find((d) => d.name === 'process')!;
|
||||||
|
expect(fn.kind).toBe('fn');
|
||||||
|
expect(fn.signature).toBe('process(order, user)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hoists fn declarations: calls above the decl resolve', () => {
|
||||||
|
const { table } = build('greet("world"); fn greet(s) { s }');
|
||||||
|
const u = table.usages.find((u) => u.name === 'greet')!;
|
||||||
|
expect(u.resolved?.kind).toBe('fn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('function bodies do not see outer locals', () => {
|
||||||
|
const { table } = build(`
|
||||||
|
let outer = 1;
|
||||||
|
fn f() { outer }
|
||||||
|
`);
|
||||||
|
const outerUse = table.usages.find((u) => u.name === 'outer')!;
|
||||||
|
expect(outerUse.resolved).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('function bodies do see outer fn declarations', () => {
|
||||||
|
const { table } = build(`
|
||||||
|
fn helper() { 1 }
|
||||||
|
fn caller() { helper() }
|
||||||
|
`);
|
||||||
|
const helperUse = table.usages.find((u) => u.name === 'helper' && u.range.start > 30)!;
|
||||||
|
expect(helperUse.resolved?.kind).toBe('fn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures function parameters in their body scope', () => {
|
||||||
|
const { table } = build('fn f(a, b) { a + b }');
|
||||||
|
const a = table.allDecls.find((d) => d.name === 'a')!;
|
||||||
|
expect(a.kind).toBe('param');
|
||||||
|
const useOfA = table.usages.find((u) => u.name === 'a')!;
|
||||||
|
expect(useOfA.resolved).toBe(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures for-loop binders', () => {
|
||||||
|
const { table } = build('for item in [1, 2, 3] { item }');
|
||||||
|
const item = table.allDecls.find((d) => d.name === 'item')!;
|
||||||
|
expect(item.kind).toBe('for');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects forward-declaration: cannot use a let before its decl', () => {
|
||||||
|
const { table } = build('x; let x = 1;');
|
||||||
|
const earlyUse = table.usages.find((u) => u.name === 'x' && u.range.start < 5)!;
|
||||||
|
expect(earlyUse.resolved).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('symbols — object-literal field maps', () => {
|
||||||
|
it('records fields of an object-map literal initializer', () => {
|
||||||
|
const { table } = build('let order = #{ id: 1, total: 5 };');
|
||||||
|
const order = table.allDecls.find((d) => d.name === 'order')!;
|
||||||
|
expect(order.objectFields).toEqual(['id', 'total']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('objectFieldsOf returns the set after the declaration', () => {
|
||||||
|
const src = 'let order = #{ id: 1 }; order.id';
|
||||||
|
const { table } = build(src);
|
||||||
|
const afterDecl = src.indexOf('order.id') + 'order.'.length;
|
||||||
|
expect(table.objectFieldsOf('order', afterDecl)).toEqual(['id']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('symbols — completion + navigation helpers', () => {
|
||||||
|
it('scopeCompletions surfaces in-scope locals and hoisted fns', () => {
|
||||||
|
const src = `
|
||||||
|
let outer = 1;
|
||||||
|
fn process(order) {
|
||||||
|
order
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const { table } = build(src);
|
||||||
|
const insideFn = src.indexOf('order\n');
|
||||||
|
const names = table.scopeCompletions(insideFn).map((d) => d.name);
|
||||||
|
expect(names).toContain('order');
|
||||||
|
expect(names).toContain('process');
|
||||||
|
// outer is not visible from inside `fn process`.
|
||||||
|
expect(names).not.toContain('outer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declOfUsageAt resolves a usage to its declaration', () => {
|
||||||
|
const src = 'fn process(o) { o } process(1)';
|
||||||
|
const { table } = build(src);
|
||||||
|
const callPos = src.lastIndexOf('process');
|
||||||
|
const d = table.declOfUsageAt(callPos)!;
|
||||||
|
expect(d.name).toBe('process');
|
||||||
|
expect(d.kind).toBe('fn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('usagesOf collects declaration + every reference', () => {
|
||||||
|
const src = 'fn process(o) { o } process(1); process(2);';
|
||||||
|
const { table } = build(src);
|
||||||
|
const fn = table.allDecls.find((d) => d.name === 'process')!;
|
||||||
|
const all = table.usagesOf(fn);
|
||||||
|
// 1 decl name + 2 call sites = 3 ranges
|
||||||
|
expect(all).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
447
dashboard/src/lib/rhai/symbols.ts
Normal file
447
dashboard/src/lib/rhai/symbols.ts
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
// Symbol table built from the parsed AST.
|
||||||
|
//
|
||||||
|
// One walk produces everything the editor features need:
|
||||||
|
// * declarations (let, const, fn, params, for-loop binders, catch binder)
|
||||||
|
// * usages (every Ident reference, resolved by walking the scope chain)
|
||||||
|
// * object-literal field maps (so `obj.` can suggest known keys)
|
||||||
|
//
|
||||||
|
// Resolution rules (matching Rhai):
|
||||||
|
// * `fn` declarations live in the script-root scope regardless of where
|
||||||
|
// they appear textually. They form a flat namespace; nested functions
|
||||||
|
// are not allowed in standard Rhai.
|
||||||
|
// * A function body is a fresh scope that does NOT inherit the enclosing
|
||||||
|
// locals — Rhai's `fn` is a pure function, not a closure. It can
|
||||||
|
// still see top-level `fn`s (call them) but not top-level `let`s.
|
||||||
|
// * Blocks (if/while/loop/for/try) nest within their containing scope.
|
||||||
|
// * `let`/`const` are visible only after their declaration site within
|
||||||
|
// their scope.
|
||||||
|
//
|
||||||
|
// Known limit: object-literal field tracking is best-effort. We only
|
||||||
|
// record fields at the literal-initialization site — `let o = #{ a: 1 };`.
|
||||||
|
// Reassignments and member writes (`o.b = 2;`) don't update the field set.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BlockExpr,
|
||||||
|
Comment,
|
||||||
|
Expr,
|
||||||
|
ForStmt,
|
||||||
|
FnDecl,
|
||||||
|
IfExpr,
|
||||||
|
ObjectMapExpr,
|
||||||
|
ParseResult,
|
||||||
|
Range,
|
||||||
|
Stmt,
|
||||||
|
SwitchExpr,
|
||||||
|
TryStmt
|
||||||
|
} from './ast';
|
||||||
|
|
||||||
|
export type DeclKind = 'let' | 'const' | 'fn' | 'param' | 'for' | 'catch';
|
||||||
|
|
||||||
|
export interface Decl {
|
||||||
|
kind: DeclKind;
|
||||||
|
name: string;
|
||||||
|
nameRange: Range;
|
||||||
|
// For `fn`: rendered signature like `process(order, user)`.
|
||||||
|
signature?: string;
|
||||||
|
// For `let`/`const` initialized to an object-map literal — the field
|
||||||
|
// names at the literal site. Empty otherwise.
|
||||||
|
objectFields?: string[];
|
||||||
|
// Lexical visibility: the offset at which references can resolve to
|
||||||
|
// this declaration. For `let`/`const` it's just past the declaration;
|
||||||
|
// for `fn`, parameters, `for`, and `catch` binders it's the start of
|
||||||
|
// the scope they belong to.
|
||||||
|
visibleFrom: number;
|
||||||
|
scope: Scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Usage {
|
||||||
|
name: string;
|
||||||
|
range: Range;
|
||||||
|
scope: Scope;
|
||||||
|
resolved: Decl | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Scope {
|
||||||
|
id: number;
|
||||||
|
kind: 'root' | 'fn' | 'block';
|
||||||
|
// A scope's range covers the source span where its locals are
|
||||||
|
// reachable. For the root scope this is the whole document.
|
||||||
|
range: Range;
|
||||||
|
parent: Scope | null;
|
||||||
|
children: Scope[];
|
||||||
|
decls: Decl[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SymbolTable {
|
||||||
|
root: Scope;
|
||||||
|
allDecls: Decl[];
|
||||||
|
usages: Usage[];
|
||||||
|
// Public API used by the editor features.
|
||||||
|
declAt(pos: number): Decl | null;
|
||||||
|
declOfUsageAt(pos: number): Decl | null;
|
||||||
|
usagesOf(decl: Decl): Range[];
|
||||||
|
objectFieldsOf(name: string, atPos: number): string[];
|
||||||
|
scopeCompletions(atPos: number): Decl[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSymbolTable(result: ParseResult): SymbolTable {
|
||||||
|
const builder = new Builder(result);
|
||||||
|
builder.walkProgram();
|
||||||
|
builder.resolveUsages();
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cosmetic helper: format a function's signature for the completion
|
||||||
|
// `detail` field. Kept here so the symbol table is the single source of
|
||||||
|
// truth for "how a `fn` shows up in the UI".
|
||||||
|
export function renderFnSignature(fn: FnDecl): string {
|
||||||
|
return `${fn.name}(${fn.params.map((p) => p.name).join(', ')})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Builder {
|
||||||
|
allDecls: Decl[] = [];
|
||||||
|
usages: Usage[] = [];
|
||||||
|
root: Scope;
|
||||||
|
private currentScope: Scope;
|
||||||
|
private nextScopeId = 0;
|
||||||
|
|
||||||
|
constructor(private result: ParseResult) {
|
||||||
|
const span = { start: 0, end: result.source.length };
|
||||||
|
this.root = this.makeScope('root', span, null);
|
||||||
|
this.currentScope = this.root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeScope(kind: Scope['kind'], range: Range, parent: Scope | null): Scope {
|
||||||
|
const s: Scope = { id: this.nextScopeId++, kind, range, parent, children: [], decls: [] };
|
||||||
|
if (parent) parent.children.push(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private declare(d: Decl): void {
|
||||||
|
this.currentScope.decls.push(d);
|
||||||
|
this.allDecls.push(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- walk
|
||||||
|
|
||||||
|
walkProgram(): void {
|
||||||
|
// First pass: hoist `fn` decls into the root scope so calls anywhere
|
||||||
|
// in the file can resolve to them regardless of source order.
|
||||||
|
for (const stmt of this.result.program.stmts) {
|
||||||
|
if (stmt.kind === 'FnDecl') {
|
||||||
|
this.declare({
|
||||||
|
kind: 'fn',
|
||||||
|
name: stmt.name,
|
||||||
|
nameRange: stmt.nameRange,
|
||||||
|
signature: renderFnSignature(stmt),
|
||||||
|
visibleFrom: 0,
|
||||||
|
scope: this.root
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Second pass: walk statements normally. Skip re-declaring the
|
||||||
|
// already-hoisted fn names; just descend into their bodies.
|
||||||
|
for (const stmt of this.result.program.stmts) this.walkStmt(stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private walkStmt(stmt: Stmt): void {
|
||||||
|
switch (stmt.kind) {
|
||||||
|
case 'Let':
|
||||||
|
case 'Const': {
|
||||||
|
if (stmt.init) this.walkExpr(stmt.init);
|
||||||
|
const objectFields =
|
||||||
|
stmt.init && stmt.init.kind === 'ObjectMap'
|
||||||
|
? (stmt.init as ObjectMapExpr).entries.map((e) => e.key)
|
||||||
|
: undefined;
|
||||||
|
this.declare({
|
||||||
|
kind: stmt.kind === 'Let' ? 'let' : 'const',
|
||||||
|
name: stmt.name,
|
||||||
|
nameRange: stmt.nameRange,
|
||||||
|
objectFields,
|
||||||
|
visibleFrom: stmt.nameRange.end,
|
||||||
|
scope: this.currentScope
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'FnDecl': {
|
||||||
|
const prev = this.currentScope;
|
||||||
|
const fnScope = this.makeScope('fn', stmt.body, prev);
|
||||||
|
this.currentScope = fnScope;
|
||||||
|
for (const p of stmt.params) {
|
||||||
|
this.declare({
|
||||||
|
kind: 'param',
|
||||||
|
name: p.name,
|
||||||
|
nameRange: { start: p.start, end: p.end },
|
||||||
|
visibleFrom: stmt.body.start,
|
||||||
|
scope: fnScope
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const s of stmt.body.stmts) this.walkStmt(s);
|
||||||
|
this.currentScope = prev;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'ExprStmt':
|
||||||
|
this.walkExpr(stmt.expr);
|
||||||
|
return;
|
||||||
|
case 'Return':
|
||||||
|
if (stmt.value) this.walkExpr(stmt.value);
|
||||||
|
return;
|
||||||
|
case 'While':
|
||||||
|
this.walkExpr(stmt.cond);
|
||||||
|
this.walkBlock(stmt.body);
|
||||||
|
return;
|
||||||
|
case 'Loop':
|
||||||
|
this.walkBlock(stmt.body);
|
||||||
|
return;
|
||||||
|
case 'For':
|
||||||
|
this.walkFor(stmt);
|
||||||
|
return;
|
||||||
|
case 'Try':
|
||||||
|
this.walkTry(stmt);
|
||||||
|
return;
|
||||||
|
case 'Break':
|
||||||
|
case 'Continue':
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private walkFor(stmt: ForStmt): void {
|
||||||
|
this.walkExpr(stmt.iter);
|
||||||
|
const prev = this.currentScope;
|
||||||
|
const blockScope = this.makeScope('block', stmt.body, prev);
|
||||||
|
this.currentScope = blockScope;
|
||||||
|
this.declare({
|
||||||
|
kind: 'for',
|
||||||
|
name: stmt.varName,
|
||||||
|
nameRange: stmt.varRange,
|
||||||
|
visibleFrom: stmt.body.start,
|
||||||
|
scope: blockScope
|
||||||
|
});
|
||||||
|
for (const s of stmt.body.stmts) this.walkStmt(s);
|
||||||
|
this.currentScope = prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
private walkTry(stmt: TryStmt): void {
|
||||||
|
this.walkBlock(stmt.body);
|
||||||
|
const prev = this.currentScope;
|
||||||
|
const handlerScope = this.makeScope('block', stmt.handler, prev);
|
||||||
|
this.currentScope = handlerScope;
|
||||||
|
if (stmt.catchVar && stmt.catchVarRange) {
|
||||||
|
this.declare({
|
||||||
|
kind: 'catch',
|
||||||
|
name: stmt.catchVar,
|
||||||
|
nameRange: stmt.catchVarRange,
|
||||||
|
visibleFrom: stmt.handler.start,
|
||||||
|
scope: handlerScope
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const s of stmt.handler.stmts) this.walkStmt(s);
|
||||||
|
this.currentScope = prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
private walkBlock(block: BlockExpr): void {
|
||||||
|
const prev = this.currentScope;
|
||||||
|
const blockScope = this.makeScope('block', block, prev);
|
||||||
|
this.currentScope = blockScope;
|
||||||
|
for (const s of block.stmts) this.walkStmt(s);
|
||||||
|
this.currentScope = prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
private walkExpr(expr: Expr): void {
|
||||||
|
switch (expr.kind) {
|
||||||
|
case 'Ident':
|
||||||
|
if (expr.name) {
|
||||||
|
this.usages.push({
|
||||||
|
name: expr.name,
|
||||||
|
range: { start: expr.start, end: expr.end },
|
||||||
|
scope: this.currentScope,
|
||||||
|
resolved: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'Number':
|
||||||
|
case 'String':
|
||||||
|
case 'Bool':
|
||||||
|
case 'Null':
|
||||||
|
return;
|
||||||
|
case 'Call':
|
||||||
|
this.walkExpr(expr.callee);
|
||||||
|
for (const a of expr.args) this.walkExpr(a);
|
||||||
|
return;
|
||||||
|
case 'Member':
|
||||||
|
this.walkExpr(expr.object);
|
||||||
|
// We don't record the property as a usage — it's resolved
|
||||||
|
// against the object's shape, not the lexical scope.
|
||||||
|
return;
|
||||||
|
case 'Index':
|
||||||
|
this.walkExpr(expr.object);
|
||||||
|
this.walkExpr(expr.index);
|
||||||
|
return;
|
||||||
|
case 'Unary':
|
||||||
|
this.walkExpr(expr.operand);
|
||||||
|
return;
|
||||||
|
case 'Binary':
|
||||||
|
this.walkExpr(expr.left);
|
||||||
|
this.walkExpr(expr.right);
|
||||||
|
return;
|
||||||
|
case 'Assign':
|
||||||
|
this.walkExpr(expr.target);
|
||||||
|
this.walkExpr(expr.value);
|
||||||
|
return;
|
||||||
|
case 'Paren':
|
||||||
|
this.walkExpr(expr.expr);
|
||||||
|
return;
|
||||||
|
case 'ObjectMap':
|
||||||
|
for (const e of expr.entries) this.walkExpr(e.value);
|
||||||
|
return;
|
||||||
|
case 'Array':
|
||||||
|
for (const e of expr.elements) this.walkExpr(e);
|
||||||
|
return;
|
||||||
|
case 'FnExpr': {
|
||||||
|
const prev = this.currentScope;
|
||||||
|
const fnScope = this.makeScope('fn', expr.body, prev);
|
||||||
|
this.currentScope = fnScope;
|
||||||
|
for (const p of expr.params) {
|
||||||
|
this.declare({
|
||||||
|
kind: 'param',
|
||||||
|
name: p.name,
|
||||||
|
nameRange: { start: p.start, end: p.end },
|
||||||
|
visibleFrom: expr.body.start,
|
||||||
|
scope: fnScope
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const s of expr.body.stmts) this.walkStmt(s);
|
||||||
|
this.currentScope = prev;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'IfExpr':
|
||||||
|
this.walkIf(expr);
|
||||||
|
return;
|
||||||
|
case 'SwitchExpr':
|
||||||
|
this.walkSwitch(expr);
|
||||||
|
return;
|
||||||
|
case 'BlockExpr':
|
||||||
|
this.walkBlock(expr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private walkIf(expr: IfExpr): void {
|
||||||
|
this.walkExpr(expr.cond);
|
||||||
|
this.walkBlock(expr.then);
|
||||||
|
if (expr.else_) {
|
||||||
|
if (expr.else_.kind === 'IfExpr') this.walkIf(expr.else_);
|
||||||
|
else this.walkBlock(expr.else_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private walkSwitch(expr: SwitchExpr): void {
|
||||||
|
this.walkExpr(expr.subject);
|
||||||
|
for (const arm of expr.arms) {
|
||||||
|
if (arm.pattern) this.walkExpr(arm.pattern);
|
||||||
|
if (arm.guard) this.walkExpr(arm.guard);
|
||||||
|
this.walkExpr(arm.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- resolution
|
||||||
|
|
||||||
|
resolveUsages(): void {
|
||||||
|
for (const u of this.usages) {
|
||||||
|
u.resolved = this.resolveName(u.name, u.range.start, u.scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveName(name: string, atPos: number, fromScope: Scope): Decl | null {
|
||||||
|
let scope: Scope | null = fromScope;
|
||||||
|
let crossedFn = false;
|
||||||
|
while (scope) {
|
||||||
|
for (const d of scope.decls) {
|
||||||
|
if (d.name !== name) continue;
|
||||||
|
// Function scopes don't see outer locals — only the root's
|
||||||
|
// `fn` decls. So once we've crossed a fn boundary, accept
|
||||||
|
// only `fn` declarations.
|
||||||
|
if (crossedFn && d.kind !== 'fn') continue;
|
||||||
|
if (d.visibleFrom <= atPos) return d;
|
||||||
|
}
|
||||||
|
if (scope.kind === 'fn') crossedFn = true;
|
||||||
|
scope = scope.parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- finish
|
||||||
|
|
||||||
|
finish(): SymbolTable {
|
||||||
|
const { allDecls, usages, root } = this;
|
||||||
|
|
||||||
|
const declAt = (pos: number): Decl | null => {
|
||||||
|
let best: Decl | null = null;
|
||||||
|
for (const d of allDecls) {
|
||||||
|
if (pos >= d.nameRange.start && pos <= d.nameRange.end) {
|
||||||
|
best = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findScopeAt = (pos: number, scope: Scope = root): Scope => {
|
||||||
|
for (const c of scope.children) {
|
||||||
|
if (pos >= c.range.start && pos <= c.range.end) {
|
||||||
|
return findScopeAt(pos, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scope;
|
||||||
|
};
|
||||||
|
|
||||||
|
const declOfUsageAt = (pos: number): Decl | null => {
|
||||||
|
const direct = declAt(pos);
|
||||||
|
if (direct) return direct;
|
||||||
|
for (const u of usages) {
|
||||||
|
if (pos >= u.range.start && pos <= u.range.end) return u.resolved;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const usagesOf = (decl: Decl): Range[] => {
|
||||||
|
const out: Range[] = [{ start: decl.nameRange.start, end: decl.nameRange.end }];
|
||||||
|
for (const u of usages) {
|
||||||
|
if (u.resolved === decl) out.push({ start: u.range.start, end: u.range.end });
|
||||||
|
}
|
||||||
|
out.sort((a, b) => a.start - b.start);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const objectFieldsOf = (name: string, atPos: number): string[] => {
|
||||||
|
const fromScope = findScopeAt(atPos);
|
||||||
|
const d = (this as Builder).resolveName(name, atPos, fromScope);
|
||||||
|
return d?.objectFields ?? [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const scopeCompletions = (atPos: number): Decl[] => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: Decl[] = [];
|
||||||
|
let scope: Scope | null = findScopeAt(atPos);
|
||||||
|
let crossedFn = false;
|
||||||
|
while (scope) {
|
||||||
|
for (const d of scope.decls) {
|
||||||
|
if (seen.has(d.name)) continue;
|
||||||
|
if (crossedFn && d.kind !== 'fn') continue;
|
||||||
|
if (d.visibleFrom > atPos) continue;
|
||||||
|
seen.add(d.name);
|
||||||
|
out.push(d);
|
||||||
|
}
|
||||||
|
if (scope.kind === 'fn') crossedFn = true;
|
||||||
|
scope = scope.parent;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { root, allDecls, usages, declAt, declOfUsageAt, usagesOf, objectFieldsOf, scopeCompletions };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used by symbols.test.ts; harmless to export.
|
||||||
|
export function commentsAt(comments: Comment[], pos: number): Comment | undefined {
|
||||||
|
return comments.find((c) => pos >= c.start && pos < c.end);
|
||||||
|
}
|
||||||
@@ -1,6 +1,44 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { currentUser, getToken } from '$lib/auth';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
let booting = $state(true);
|
||||||
|
const user = $derived($currentUser);
|
||||||
|
|
||||||
|
const isLoginRoute = $derived(page.url.pathname.endsWith('/login'));
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// Hydrate the session: if there's a token, ask the server who we
|
||||||
|
// are. On 401 the fetch wrapper already redirects to /login and
|
||||||
|
// clears state; on success we land in the SPA fully signed in.
|
||||||
|
const tok = getToken();
|
||||||
|
if (!tok) {
|
||||||
|
if (!isLoginRoute) {
|
||||||
|
await goto(`${base}/login`);
|
||||||
|
}
|
||||||
|
booting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const me = await api.auth.me();
|
||||||
|
currentUser.set(me);
|
||||||
|
} catch {
|
||||||
|
// adminRequest handles 401 redirects. For other errors fall
|
||||||
|
// through — the page will surface its own error state.
|
||||||
|
}
|
||||||
|
booting = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await api.auth.logout();
|
||||||
|
await goto(`${base}/login`);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
@@ -8,10 +46,22 @@
|
|||||||
<a href={base + '/'} class="brand">PiCloud</a>
|
<a href={base + '/'} class="brand">PiCloud</a>
|
||||||
<nav>
|
<nav>
|
||||||
<a href={base + '/'}>Scripts</a>
|
<a href={base + '/'}>Scripts</a>
|
||||||
|
<a href={base + '/admins'}>Admins</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
{#if user}
|
||||||
|
<div class="usermenu">
|
||||||
|
<span class="username">{user.username}</span>
|
||||||
|
<button type="button" class="logout" onclick={handleLogout}>Logout</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
{@render children?.()}
|
{#if booting}
|
||||||
|
<p class="boot">Loading…</p>
|
||||||
|
{:else}
|
||||||
|
{@render children?.()}
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -45,6 +95,11 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
nav a {
|
nav a {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -55,6 +110,36 @@
|
|||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usermenu {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
@@ -63,4 +148,8 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boot {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { api, ApiError, type Script } from '$lib/api';
|
import { api, ApiError, type Script } from '$lib/api';
|
||||||
|
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||||
|
|
||||||
const SAMPLE_SOURCE = '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
const SAMPLE_SOURCE = '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="full">
|
<label class="full">
|
||||||
<span>Source (Rhai)</span>
|
<span>Source (Rhai)</span>
|
||||||
<textarea bind:value={createSource} rows="10" spellcheck="false"></textarea>
|
<CodeEditor bind:value={createSource} language="rhai" minHeight="14rem" />
|
||||||
</label>
|
</label>
|
||||||
{#if createError}
|
{#if createError}
|
||||||
<div class="error">{createError}</div>
|
<div class="error">{createError}</div>
|
||||||
@@ -191,8 +192,7 @@
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-form input,
|
.create-form input {
|
||||||
.create-form textarea {
|
|
||||||
background: #0b1220;
|
background: #0b1220;
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
border: 1px solid #334155;
|
border: 1px solid #334155;
|
||||||
@@ -201,13 +201,6 @@
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-form textarea {
|
|
||||||
font-family:
|
|
||||||
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
|
|
||||||
min-height: 8rem;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
687
dashboard/src/routes/admins/+page.svelte
Normal file
687
dashboard/src/routes/admins/+page.svelte
Normal file
@@ -0,0 +1,687 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api, ApiError, type AdminUserRecord } from '$lib/api';
|
||||||
|
import { currentUser } from '$lib/auth';
|
||||||
|
|
||||||
|
let admins = $state<AdminUserRecord[]>([]);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
|
||||||
|
|
||||||
|
const me = $derived($currentUser);
|
||||||
|
|
||||||
|
let createOpen = $state(false);
|
||||||
|
let createForm = $state({ username: '', password: '', confirm: '' });
|
||||||
|
let createPending = $state(false);
|
||||||
|
let createError = $state<string | null>(null);
|
||||||
|
|
||||||
|
let passwordTarget = $state<AdminUserRecord | null>(null);
|
||||||
|
let passwordForm = $state({ password: '', confirm: '' });
|
||||||
|
let passwordPending = $state(false);
|
||||||
|
let passwordError = $state<string | null>(null);
|
||||||
|
|
||||||
|
let deleteTarget = $state<AdminUserRecord | null>(null);
|
||||||
|
let deletePending = $state(false);
|
||||||
|
|
||||||
|
let actionsOpenFor = $state<string | null>(null);
|
||||||
|
|
||||||
|
onMount(refresh);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loadError = null;
|
||||||
|
try {
|
||||||
|
admins = await api.admins.list();
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e instanceof ApiError ? e.message : 'failed to load admins';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(kind: 'error' | 'info', message: string) {
|
||||||
|
banner = { kind, message };
|
||||||
|
setTimeout(() => {
|
||||||
|
if (banner?.message === message) banner = null;
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
createForm = { username: '', password: '', confirm: '' };
|
||||||
|
createError = null;
|
||||||
|
createOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
createError = null;
|
||||||
|
if (createForm.password !== createForm.confirm) {
|
||||||
|
createError = 'Passwords do not match';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createPending = true;
|
||||||
|
try {
|
||||||
|
await api.admins.create({
|
||||||
|
username: createForm.username.trim(),
|
||||||
|
password: createForm.password
|
||||||
|
});
|
||||||
|
createOpen = false;
|
||||||
|
await refresh();
|
||||||
|
flash('info', `Created admin "${createForm.username.trim()}".`);
|
||||||
|
} catch (e) {
|
||||||
|
createError = e instanceof ApiError ? e.message : 'failed to create admin';
|
||||||
|
} finally {
|
||||||
|
createPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPassword(row: AdminUserRecord) {
|
||||||
|
passwordTarget = row;
|
||||||
|
passwordForm = { password: '', confirm: '' };
|
||||||
|
passwordError = null;
|
||||||
|
actionsOpenFor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPassword(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!passwordTarget) return;
|
||||||
|
passwordError = null;
|
||||||
|
if (passwordForm.password !== passwordForm.confirm) {
|
||||||
|
passwordError = 'Passwords do not match';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
passwordPending = true;
|
||||||
|
try {
|
||||||
|
await api.admins.update(passwordTarget.id, { password: passwordForm.password });
|
||||||
|
const name = passwordTarget.username;
|
||||||
|
passwordTarget = null;
|
||||||
|
flash('info', `Password updated for "${name}".`);
|
||||||
|
} catch (e) {
|
||||||
|
passwordError = e instanceof ApiError ? e.message : 'failed to update password';
|
||||||
|
} finally {
|
||||||
|
passwordPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActive(row: AdminUserRecord) {
|
||||||
|
actionsOpenFor = null;
|
||||||
|
try {
|
||||||
|
const updated = await api.admins.update(row.id, { is_active: !row.is_active });
|
||||||
|
admins = admins.map((a) => (a.id === updated.id ? updated : a));
|
||||||
|
flash('info', `${updated.username} ${updated.is_active ? 'reactivated' : 'deactivated'}.`);
|
||||||
|
} catch (e) {
|
||||||
|
flash('error', e instanceof ApiError ? e.message : 'failed to update admin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDelete(row: AdminUserRecord) {
|
||||||
|
deleteTarget = row;
|
||||||
|
actionsOpenFor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deletePending = true;
|
||||||
|
const target = deleteTarget;
|
||||||
|
try {
|
||||||
|
await api.admins.remove(target.id);
|
||||||
|
deleteTarget = null;
|
||||||
|
if (me && me.id === target.id) {
|
||||||
|
// Just deleted ourselves — sign out and bounce.
|
||||||
|
await api.auth.logout();
|
||||||
|
await goto(`${base}/login`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
flash('info', `Deleted "${target.username}".`);
|
||||||
|
} catch (e) {
|
||||||
|
flash('error', e instanceof ApiError ? e.message : 'failed to delete admin');
|
||||||
|
} finally {
|
||||||
|
deletePending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleActions(id: string) {
|
||||||
|
actionsOpenFor = actionsOpenFor === id ? null : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relative(iso: string | null): string {
|
||||||
|
if (!iso) return 'Never';
|
||||||
|
const then = new Date(iso).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
const sec = Math.round((now - then) / 1000);
|
||||||
|
if (sec < 60) return `${sec} second${sec === 1 ? '' : 's'} ago`;
|
||||||
|
const min = Math.round(sec / 60);
|
||||||
|
if (min < 60) return `${min} minute${min === 1 ? '' : 's'} ago`;
|
||||||
|
const hr = Math.round(min / 60);
|
||||||
|
if (hr < 24) return `${hr} hour${hr === 1 ? '' : 's'} ago`;
|
||||||
|
const day = Math.round(hr / 24);
|
||||||
|
if (day === 1) return 'Yesterday';
|
||||||
|
if (day < 7) return `${day} days ago`;
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function absolute(iso: string | null): string {
|
||||||
|
return iso ? new Date(iso).toISOString() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(iso: string): string {
|
||||||
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="head">
|
||||||
|
<h1>Admin Users</h1>
|
||||||
|
<button type="button" class="primary" onclick={openCreate}>+ New admin user</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if banner}
|
||||||
|
<div class="banner banner-{banner.kind}">{banner.message}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<div class="error">
|
||||||
|
{loadError}
|
||||||
|
<button type="button" class="retry" onclick={refresh}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if admins.length === 0}
|
||||||
|
<p class="empty">No admin users yet. Add one to get started.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="table">
|
||||||
|
<div class="row head-row">
|
||||||
|
<div>Username</div>
|
||||||
|
<div>Status</div>
|
||||||
|
<div>Created</div>
|
||||||
|
<div>Last login</div>
|
||||||
|
<div class="actions-col"></div>
|
||||||
|
</div>
|
||||||
|
{#each admins as row (row.id)}
|
||||||
|
<div class="row">
|
||||||
|
<div class="username-cell">
|
||||||
|
<span class="name">{row.username}</span>
|
||||||
|
{#if me && me.id === row.id}
|
||||||
|
<span class="you-tag">(you)</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{#if row.is_active}
|
||||||
|
<span class="status status-active">● Active</span>
|
||||||
|
{:else}
|
||||||
|
<span class="status status-inactive">○ Inactive</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div>{shortDate(row.created_at)}</div>
|
||||||
|
<div title={absolute(row.last_login_at)}>{relative(row.last_login_at)}</div>
|
||||||
|
<div class="actions-col">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="kebab"
|
||||||
|
aria-label="Actions for {row.username}"
|
||||||
|
onclick={() => toggleActions(row.id)}
|
||||||
|
>
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
{#if actionsOpenFor === row.id}
|
||||||
|
<div class="menu">
|
||||||
|
<button type="button" onclick={() => openPassword(row)}>Change password</button>
|
||||||
|
<button type="button" onclick={() => toggleActive(row)}>
|
||||||
|
{row.is_active ? 'Deactivate' : 'Reactivate'}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="danger" onclick={() => openDelete(row)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- New admin modal -->
|
||||||
|
{#if createOpen}
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) createOpen = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form class="modal" onsubmit={submitCreate}>
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>New admin user</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="x"
|
||||||
|
aria-label="Close"
|
||||||
|
onclick={() => (createOpen = false)}>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Username</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={createForm.username}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small>Lowercase letters, digits, . _ -</small>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
bind:value={createForm.password}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small>Minimum 8 characters</small>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confirm password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
bind:value={createForm.confirm}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{#if createError}
|
||||||
|
<div class="error">{createError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="ghost" onclick={() => (createOpen = false)}>Cancel</button>
|
||||||
|
<button type="submit" class="primary" disabled={createPending}>
|
||||||
|
{createPending ? 'Creating…' : 'Create user'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Change password modal -->
|
||||||
|
{#if passwordTarget}
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) passwordTarget = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form class="modal" onsubmit={submitPassword}>
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>Change password — {passwordTarget.username}</h2>
|
||||||
|
<button type="button" class="x" aria-label="Close" onclick={() => (passwordTarget = null)}
|
||||||
|
>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>New password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
bind:value={passwordForm.password}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confirm password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
bind:value={passwordForm.confirm}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{#if passwordError}
|
||||||
|
<div class="error">{passwordError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="ghost" onclick={() => (passwordTarget = null)}>Cancel</button>
|
||||||
|
<button type="submit" class="primary" disabled={passwordPending}>
|
||||||
|
{passwordPending ? 'Updating…' : 'Update'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete confirmation modal -->
|
||||||
|
{#if deleteTarget}
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) deleteTarget = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>Delete {deleteTarget.username}?</h2>
|
||||||
|
<button type="button" class="x" aria-label="Close" onclick={() => (deleteTarget = null)}
|
||||||
|
>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if me && me.id === deleteTarget.id}
|
||||||
|
<p>
|
||||||
|
You are about to delete <strong>your own</strong> account. You will be signed out immediately
|
||||||
|
and will not be able to sign back in with these credentials.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p>
|
||||||
|
This permanently removes <strong>{deleteTarget.username}</strong> and all their sessions.
|
||||||
|
This cannot be undone.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="ghost" onclick={() => (deleteTarget = null)}>Cancel</button>
|
||||||
|
<button type="button" class="danger" disabled={deletePending} onclick={confirmDelete}>
|
||||||
|
{deletePending ? 'Deleting…' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.banner-error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
.banner-info {
|
||||||
|
background: #0c2a36;
|
||||||
|
border: 1px solid #155e75;
|
||||||
|
color: #a5f3fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 0;
|
||||||
|
border: 1px dashed #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: visible;
|
||||||
|
background: #0b1220;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.5fr 0.9fr 1fr 1.2fr 3rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-row {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.you-tag {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.status-active {
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
.status-inactive {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-col {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.kebab:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 12rem;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #cbd5e1;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.menu button:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.menu button.danger {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
.menu button.danger:hover {
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
button.primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
button.ghost:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: #b91c1c;
|
||||||
|
color: #fef2f2;
|
||||||
|
border: none;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
button.danger:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.7);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
min-width: 24rem;
|
||||||
|
max-width: 28rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.x {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.x:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal label small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal input {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.modal input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
172
dashboard/src/routes/login/+page.svelte
Normal file
172
dashboard/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api, ApiError } from '$lib/api';
|
||||||
|
import { getToken } from '$lib/auth';
|
||||||
|
|
||||||
|
let username = $state('');
|
||||||
|
let password = $state('');
|
||||||
|
let pending = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// Already signed in? Skip the form.
|
||||||
|
if (!getToken()) return;
|
||||||
|
try {
|
||||||
|
await api.auth.me();
|
||||||
|
await goto(`${base}/`);
|
||||||
|
} catch {
|
||||||
|
// stale token; let the form render
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
error = null;
|
||||||
|
pending = true;
|
||||||
|
try {
|
||||||
|
await api.auth.login(username, password);
|
||||||
|
await goto(`${base}/`);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'Login failed';
|
||||||
|
} finally {
|
||||||
|
pending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="login-shell">
|
||||||
|
<form class="card" onsubmit={submit}>
|
||||||
|
<h1>PiCloud</h1>
|
||||||
|
<p class="sub">Admin sign-in</p>
|
||||||
|
<label>
|
||||||
|
<span>Username</span>
|
||||||
|
<input
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
autocomplete="username"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={username}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Password</span>
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
bind:value={password}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={pending}>
|
||||||
|
{pending ? 'Signing in…' : 'Sign in →'}
|
||||||
|
</button>
|
||||||
|
{#if error}
|
||||||
|
<div class="error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
<p class="hint">
|
||||||
|
Lost access? Run <code>picloud admin reset-password <username></code> on the host.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.login-shell {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
min-width: 22rem;
|
||||||
|
max-width: 26rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #38bdf8;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -13,6 +13,19 @@
|
|||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
import { logLevelColor, statusColor } from '$lib/styles';
|
import { logLevelColor, statusColor } from '$lib/styles';
|
||||||
import { guessHostKind, guessPathKind, pathKindMismatchWarning } from '$lib/route-utils';
|
import { guessHostKind, guessPathKind, pathKindMismatchWarning } from '$lib/route-utils';
|
||||||
|
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||||
|
import { format as formatRhai } from '$lib/rhai';
|
||||||
|
|
||||||
|
/// Pretty-print a JSON string in place, leaving it untouched if the
|
||||||
|
/// input doesn't parse. The error state is shown next to the button
|
||||||
|
/// so users see why it didn't reformat.
|
||||||
|
function formatJson(s: string): { ok: true; text: string } | { ok: false; error: string } {
|
||||||
|
try {
|
||||||
|
return { ok: true, text: JSON.stringify(JSON.parse(s), null, 2) };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Route is `/scripts/[id]` so `page.params.id` is always present.
|
// Route is `/scripts/[id]` so `page.params.id` is always present.
|
||||||
let id = $derived(page.params.id ?? '');
|
let id = $derived(page.params.id ?? '');
|
||||||
@@ -55,6 +68,17 @@
|
|||||||
let editableSource = $state('');
|
let editableSource = $state('');
|
||||||
let savingSource = $state(false);
|
let savingSource = $state(false);
|
||||||
let saveSourceError = $state<string | null>(null);
|
let saveSourceError = $state<string | null>(null);
|
||||||
|
let rhaiFormatError = $state<string | null>(null);
|
||||||
|
|
||||||
|
function formatRhaiSource() {
|
||||||
|
const r = formatRhai(editableSource);
|
||||||
|
if (r.ok) {
|
||||||
|
editableSource = r.text;
|
||||||
|
rhaiFormatError = null;
|
||||||
|
} else {
|
||||||
|
rhaiFormatError = `Parse error: ${r.error.message} (line ${r.error.line}, position ${r.error.column})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveSource() {
|
async function saveSource() {
|
||||||
if (!script) return;
|
if (!script) return;
|
||||||
@@ -72,7 +96,28 @@
|
|||||||
|
|
||||||
let testBody = $state('{}');
|
let testBody = $state('{}');
|
||||||
let testHeaders = $state('{}');
|
let testHeaders = $state('{}');
|
||||||
|
let testBodyFormatError = $state<string | null>(null);
|
||||||
|
let testHeadersFormatError = $state<string | null>(null);
|
||||||
let testInProgress = $state(false);
|
let testInProgress = $state(false);
|
||||||
|
|
||||||
|
function formatTestBody() {
|
||||||
|
const r = formatJson(testBody);
|
||||||
|
if (r.ok) {
|
||||||
|
testBody = r.text;
|
||||||
|
testBodyFormatError = null;
|
||||||
|
} else {
|
||||||
|
testBodyFormatError = r.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function formatTestHeaders() {
|
||||||
|
const r = formatJson(testHeaders);
|
||||||
|
if (r.ok) {
|
||||||
|
testHeaders = r.text;
|
||||||
|
testHeadersFormatError = null;
|
||||||
|
} else {
|
||||||
|
testHeadersFormatError = r.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
let testResult = $state<{
|
let testResult = $state<{
|
||||||
status: number;
|
status: number;
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
@@ -351,8 +396,16 @@
|
|||||||
{#if tab === 'edit'}
|
{#if tab === 'edit'}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Source</h2>
|
<header class="editor-header">
|
||||||
<textarea bind:value={editableSource} rows="14" spellcheck="false"></textarea>
|
<h2>Source</h2>
|
||||||
|
<button type="button" class="ghost small" onclick={formatRhaiSource}>
|
||||||
|
Format
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<CodeEditor bind:value={editableSource} language="rhai" minHeight="22rem" />
|
||||||
|
{#if rhaiFormatError}
|
||||||
|
<div class="error inline">{rhaiFormatError}</div>
|
||||||
|
{/if}
|
||||||
{#if saveSourceError}
|
{#if saveSourceError}
|
||||||
<div class="error inline">{saveSourceError}</div>
|
<div class="error inline">{saveSourceError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -369,14 +422,30 @@
|
|||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Test invoke</h2>
|
<h2>Test invoke</h2>
|
||||||
<label>
|
<div class="json-block">
|
||||||
<span>Request body (JSON)</span>
|
<header class="json-header">
|
||||||
<textarea bind:value={testBody} rows="5" spellcheck="false"></textarea>
|
<span>Request body (JSON)</span>
|
||||||
</label>
|
<button type="button" class="ghost small" onclick={formatTestBody}>
|
||||||
<label>
|
Format
|
||||||
<span>Headers (JSON object)</span>
|
</button>
|
||||||
<textarea bind:value={testHeaders} rows="3" spellcheck="false"></textarea>
|
</header>
|
||||||
</label>
|
<CodeEditor bind:value={testBody} language="json" minHeight="9rem" />
|
||||||
|
{#if testBodyFormatError}
|
||||||
|
<div class="error inline">{testBodyFormatError}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="json-block">
|
||||||
|
<header class="json-header">
|
||||||
|
<span>Headers (JSON object)</span>
|
||||||
|
<button type="button" class="ghost small" onclick={formatTestHeaders}>
|
||||||
|
Format
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<CodeEditor bind:value={testHeaders} language="json" minHeight="6rem" />
|
||||||
|
{#if testHeadersFormatError}
|
||||||
|
<div class="error inline">{testHeadersFormatError}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" onclick={invoke} disabled={testInProgress}>
|
<button type="button" onclick={invoke} disabled={testInProgress}>
|
||||||
{testInProgress ? 'Running…' : 'Send'}
|
{testInProgress ? 'Running…' : 'Send'}
|
||||||
@@ -727,6 +796,32 @@
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
border: 1px solid #334155;
|
border: 1px solid #334155;
|
||||||
}
|
}
|
||||||
|
button.small {
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.json-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.editor-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
button.link {
|
button.link {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
@@ -798,7 +893,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea,
|
|
||||||
input,
|
input,
|
||||||
select {
|
select {
|
||||||
background: #0b1220;
|
background: #0b1220;
|
||||||
@@ -811,12 +905,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
textarea {
|
|
||||||
font-family:
|
|
||||||
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
input:disabled,
|
input:disabled,
|
||||||
select:disabled {
|
select:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
15
dashboard/vitest.config.ts
Normal file
15
dashboard/vitest.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Vitest config for unit-testing the Rhai parser / symbol table /
|
||||||
|
// formatter. Kept separate from vite.config.ts because the dev/build
|
||||||
|
// pipeline doesn't depend on a test runner.
|
||||||
|
//
|
||||||
|
// Tests use explicit `import { describe, it, expect } from 'vitest'`
|
||||||
|
// to keep globals out of the type environment.
|
||||||
|
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['src/lib/rhai/**/*.test.ts'],
|
||||||
|
environment: 'node'
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -93,12 +93,14 @@ A versioning scheme without enforcement decays in months. Five cheap mechanical
|
|||||||
2. **Runtime self-report.** `GET /version` returns every surface version. Dashboards, monitoring, inter-service handshakes, and humans all read from one source. `/healthz` stays a plain `"ok"` string for k8s probes — version negotiation is a separate concern.
|
2. **Runtime self-report.** `GET /version` returns every surface version. Dashboards, monitoring, inter-service handshakes, and humans all read from one source. `/healthz` stays a plain `"ok"` string for k8s probes — version negotiation is a separate concern.
|
||||||
3. **Golden SDK contract tests.** `tests/sdk_contract/` Rhai scripts exercise every SDK surface and must pass on every commit. The contract is the test.
|
3. **Golden SDK contract tests.** `tests/sdk_contract/` Rhai scripts exercise every SDK surface and must pass on every commit. The contract is the test.
|
||||||
4. **Migration replay test.** An integration test that boots a fresh Postgres, applies every migration in order, and asserts the resulting schema. Catches the most common mistake (edited-not-added migration).
|
4. **Migration replay test.** An integration test that boots a fresh Postgres, applies every migration in order, and asserts the resulting schema. Catches the most common mistake (edited-not-added migration).
|
||||||
5. **CI guardrail script.** A small diff-aware check that:
|
5. **CI guardrail script.** [`scripts/check-versioning.sh`](../scripts/check-versioning.sh) — runs the structural checks that don't need git history:
|
||||||
- Fails if `SDK_VERSION`'s major changed without a `CHANGELOG.md` breaking-change entry
|
- Migration files are numbered sequentially from `0001_*.sql` with no gaps.
|
||||||
- Fails if a new file appeared in `migrations/` that isn't the next sequential number
|
- `SDK_VERSION` parses as `MAJOR.MINOR` (numeric, no extra components).
|
||||||
- Fails if a route handler removed or retyped a public field without a `BREAKING:` line in the commit message
|
- `[workspace.package].version` parses as `MAJOR.MINOR.PATCH`.
|
||||||
|
|
||||||
(3) through (5) are wired in over the next few PRs; (1) and (2) land in the same commit as this document.
|
Run manually as `bash scripts/check-versioning.sh`. Wires into CI when CI exists. Deferred to the same future PR that introduces CI: SDK-major-bump-needs-CHANGELOG and `BREAKING:` commit-message annotation (both need git history + a CHANGELOG file that doesn't exist yet).
|
||||||
|
|
||||||
|
(3) and (4) are now in place: [`crates/executor-core/tests/sdk_contract.rs`](../crates/executor-core/tests/sdk_contract.rs) holds the SDK contract suite; [`crates/manager-core/tests/schema_snapshot.rs`](../crates/manager-core/tests/schema_snapshot.rs) holds the schema snapshot guard.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -124,7 +126,7 @@ A surface can hit its own `1.0` independently of the product. The SDK in particu
|
|||||||
|
|
||||||
| | Version |
|
| | Version |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Product | `0.5.0` |
|
| Product | `0.5.1` |
|
||||||
| SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) |
|
| SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) |
|
||||||
| API | `1` |
|
| API | `1` |
|
||||||
| Schema | `3` (matches `migrations/0003_routes.sql`) |
|
| Schema | `3` (matches `migrations/0003_routes.sql`) |
|
||||||
|
|||||||
126
scripts/check-versioning.sh
Executable file
126
scripts/check-versioning.sh
Executable file
@@ -0,0 +1,126 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Versioning guardrail — runs the structural checks from
|
||||||
|
# docs/versioning.md that don't need git history. Designed to be
|
||||||
|
# called from a CI job (once we have one) and/or as a pre-commit
|
||||||
|
# step. Exits 0 if everything is in shape, non-zero on the first
|
||||||
|
# failure with a precise message.
|
||||||
|
#
|
||||||
|
# What this DOES check:
|
||||||
|
# * Migration filenames are sequential `0001_*.sql`, `0002_*.sql`,
|
||||||
|
# ... starting from 0001 with no gaps and no duplicates.
|
||||||
|
# * SDK_VERSION in shared::version parses as MAJOR.MINOR (numeric).
|
||||||
|
# * Workspace product version in Cargo.toml parses as
|
||||||
|
# MAJOR.MINOR.PATCH (numeric).
|
||||||
|
#
|
||||||
|
# What this does NOT check (deferred until we have CI + a CHANGELOG
|
||||||
|
# file):
|
||||||
|
# * Whether an SDK major bump was paired with a CHANGELOG entry.
|
||||||
|
# * Whether commits that retype public fields carry a `BREAKING:`
|
||||||
|
# annotation in the commit message.
|
||||||
|
#
|
||||||
|
# Usage: bash scripts/check-versioning.sh
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Resolve repo root from this script's location so the checks run no
|
||||||
|
# matter what working directory the caller is in.
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'check-versioning: FAIL — %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 1. Migration filenames sequential
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
MIGRATIONS_DIR="$REPO_ROOT/crates/manager-core/migrations"
|
||||||
|
[ -d "$MIGRATIONS_DIR" ] || fail "migrations dir not found at $MIGRATIONS_DIR"
|
||||||
|
|
||||||
|
i=1
|
||||||
|
for file in "$MIGRATIONS_DIR"/*.sql; do
|
||||||
|
[ -e "$file" ] || fail "no migration files found in $MIGRATIONS_DIR"
|
||||||
|
base="$(basename "$file")"
|
||||||
|
expected_prefix="$(printf '%04d_' "$i")"
|
||||||
|
case "$base" in
|
||||||
|
"$expected_prefix"*)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "migration $base is not next-in-sequence (expected ${expected_prefix}<name>.sql); migrations must be added with strictly increasing 4-digit numbers"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
printf 'check-versioning: OK — %d migration(s) numbered sequentially\n' "$((i - 1))"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 2. SDK_VERSION format
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
SDK_FILE="$REPO_ROOT/crates/shared/src/version.rs"
|
||||||
|
[ -f "$SDK_FILE" ] || fail "shared::version not found at $SDK_FILE"
|
||||||
|
|
||||||
|
SDK_VERSION="$(
|
||||||
|
awk '/^pub const SDK_VERSION/ { match($0, /"[^"]+"/); print substr($0, RSTART+1, RLENGTH-2); exit }' "$SDK_FILE"
|
||||||
|
)"
|
||||||
|
[ -n "$SDK_VERSION" ] || fail "could not parse SDK_VERSION from $SDK_FILE"
|
||||||
|
|
||||||
|
case "$SDK_VERSION" in
|
||||||
|
[0-9]*"."[0-9]*)
|
||||||
|
# Reject things like "1.2.3" or "v1.2" or empty parts.
|
||||||
|
major="${SDK_VERSION%%.*}"
|
||||||
|
minor="${SDK_VERSION#*.}"
|
||||||
|
case "$major" in
|
||||||
|
''|*[!0-9]*) fail "SDK_VERSION '$SDK_VERSION' major is not numeric" ;;
|
||||||
|
esac
|
||||||
|
case "$minor" in
|
||||||
|
''|*[!0-9]*) fail "SDK_VERSION '$SDK_VERSION' minor is not numeric (extra components?)" ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "SDK_VERSION '$SDK_VERSION' is not MAJOR.MINOR (expected e.g. '1.1')"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf 'check-versioning: OK — SDK_VERSION = %s\n' "$SDK_VERSION"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 3. Workspace product version (semver MAJOR.MINOR.PATCH)
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
ROOT_CARGO="$REPO_ROOT/Cargo.toml"
|
||||||
|
[ -f "$ROOT_CARGO" ] || fail "workspace Cargo.toml not found"
|
||||||
|
|
||||||
|
PRODUCT_VERSION="$(
|
||||||
|
awk '
|
||||||
|
/^\[workspace\.package\]/ { in_section = 1; next }
|
||||||
|
/^\[/ { in_section = 0 }
|
||||||
|
in_section && /^version *= */ {
|
||||||
|
match($0, /"[^"]+"/)
|
||||||
|
print substr($0, RSTART+1, RLENGTH-2)
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
' "$ROOT_CARGO"
|
||||||
|
)"
|
||||||
|
[ -n "$PRODUCT_VERSION" ] || fail "could not parse [workspace.package].version from $ROOT_CARGO"
|
||||||
|
|
||||||
|
case "$PRODUCT_VERSION" in
|
||||||
|
[0-9]*"."[0-9]*"."[0-9]*)
|
||||||
|
major="${PRODUCT_VERSION%%.*}"
|
||||||
|
rest="${PRODUCT_VERSION#*.}"
|
||||||
|
minor="${rest%%.*}"
|
||||||
|
patch="${rest#*.}"
|
||||||
|
for part_name in major minor patch; do
|
||||||
|
eval "part=\$$part_name"
|
||||||
|
case "$part" in
|
||||||
|
''|*[!0-9]*)
|
||||||
|
fail "product version '$PRODUCT_VERSION' has non-numeric $part_name component"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "product version '$PRODUCT_VERSION' is not MAJOR.MINOR.PATCH"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf 'check-versioning: OK — product version = %s\n' "$PRODUCT_VERSION"
|
||||||
|
|
||||||
|
printf '\ncheck-versioning: all checks passed.\n'
|
||||||
@@ -732,68 +732,353 @@ volumes:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 11.4 Admin Auth (Phase 3a) — Shipped
|
||||||
|
|
||||||
|
**Status**: shipped. Implementation lives in `crates/manager-core/src/{auth,auth_*,admin_user_repo,admin_session_repo,admin_users_api}.rs`; migration `0004_admin_auth.sql`.
|
||||||
|
|
||||||
|
**Purpose**: gate the admin API (`/api/v1/admin/*`) and dashboard (`/admin/*`) behind per-user authentication. Before this phase the surface was open — anyone reaching the bound port could create, edit, and delete scripts.
|
||||||
|
|
||||||
|
**Why per-user, not a shared secret**: shared admin passwords get shared between humans, leave no audit trail, and can't be revoked per-person. Per-user accounts solve all three. The initial cut deliberately stops there — no roles, no per-app permissions — because that scope is small enough to ship in a single phase without blocking Phase 3b. Roles + per-app permissions are queued for v1.3+.
|
||||||
|
|
||||||
|
### Naming: `admin_users` vs `users`
|
||||||
|
|
||||||
|
We reserve the unqualified **`users`** table for the v1.1+ Rhai SDK feature (script-level end users — see §8.4). Platform-operator accounts live in **`admin_users`**. They are different concepts and never share rows, even when a PiCloud install hosts apps that themselves run user management.
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE admin_users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL, -- Argon2id (PHC string)
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_login_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE admin_sessions (
|
||||||
|
token_hash TEXT PRIMARY KEY, -- SHA-256(hex) of the bearer token; raw token only exists in the login response + cookie
|
||||||
|
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX admin_sessions_user_idx ON admin_sessions (user_id);
|
||||||
|
CREATE INDEX admin_sessions_expiry_idx ON admin_sessions (expires_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
`is_active` was added to the shipped cut so admins can be deactivated (login rejected, sessions wiped) without losing audit history; deletion still cascades sessions through the FK.
|
||||||
|
|
||||||
|
**Password hashing**: Argon2id with default OWASP parameters. This also resolves the v1.1+ open question about user-password hashing (§10) — the platform settles on Argon2id once, here.
|
||||||
|
|
||||||
|
### Bootstrap
|
||||||
|
|
||||||
|
On startup, if `admin_users` is empty, the manager reads `PICLOUD_ADMIN_USERNAME` plus a password from env (or a config file) and inserts the row. Two password env vars are accepted, in this precedence:
|
||||||
|
|
||||||
|
1. **`PICLOUD_ADMIN_PASSWORD_HASH`** (recommended) — pre-computed Argon2id PHC-format hash. The platform validates the string parses, then inserts it as-is. This avoids the raw password ever being written into env/compose files or process listings.
|
||||||
|
2. **`PICLOUD_ADMIN_PASSWORD`** (fallback) — raw password. The platform hashes it with Argon2id defaults and discards the raw value. Simpler for first-time setup; less ideal for committed configs.
|
||||||
|
|
||||||
|
If both are set, the hash wins and the raw value is ignored (with a warning logged). If neither is set on a fresh install, startup fails with a clear error pointing at the env vars.
|
||||||
|
|
||||||
|
**Once that bootstrap row exists, the env vars become inert** — restarting with different values does not change the password. This is deliberate: the env var is a one-time setup hatch, not a recovery backdoor (a backdoor would let anyone with systemd-unit or compose-file access override any admin's password).
|
||||||
|
|
||||||
|
Recovery is a separate manual flow:
|
||||||
|
```sh
|
||||||
|
picloud admin reset-password <username>
|
||||||
|
```
|
||||||
|
This requires shell access on the host (and therefore implies the operator already controls the box).
|
||||||
|
|
||||||
|
### Login & Session
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/admin/auth/login
|
||||||
|
{ "username": "...", "password": "..." }
|
||||||
|
|
||||||
|
→ 200 OK
|
||||||
|
Set-Cookie: picloud_session=<token>; HttpOnly; Secure; SameSite=Lax; Path=/
|
||||||
|
{ "user": { "id": "...", "username": "..." }, "token": "<token>", "expires_at": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
Token format: opaque random string (32 bytes base64). Stored hashed; the raw value lives only in the login response and the session cookie. The same token works as a bearer credential for non-browser clients:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
One token system serves both dashboard and CLI/CI clients — no separate "API token" concept. Personal long-lived API tokens can be added later as a distinct `admin_api_tokens` table if demand appears.
|
||||||
|
|
||||||
|
**Session TTL** is a **24-hour sliding window**: each authenticated request bumps `expires_at` to `now + ttl` and `last_used_at` to `now`. The TTL itself is configurable per deploy via `PICLOUD_SESSION_TTL_HOURS` (default `24`). A separate background sweep deletes rows where `expires_at < now()`; until that sweep runs, expired rows are also rejected at auth-check time (so a stuck sweep can't extend session lifetime past expiry).
|
||||||
|
|
||||||
|
Companion endpoints:
|
||||||
|
- `POST /api/v1/admin/auth/logout` — deletes the session row.
|
||||||
|
- `GET /api/v1/admin/auth/me` — returns the current authenticated user.
|
||||||
|
|
||||||
|
### Admin User Management
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/admin/admins — list
|
||||||
|
POST /api/v1/admin/admins — create ({ username, password })
|
||||||
|
GET /api/v1/admin/admins/{id} — get
|
||||||
|
PATCH /api/v1/admin/admins/{id} — update ({ username?, password?, is_active? })
|
||||||
|
DELETE /api/v1/admin/admins/{id} — delete
|
||||||
|
```
|
||||||
|
|
||||||
|
Initial cut: every authenticated admin can call all of these. No self-elevation concerns because there are no privilege levels yet. The PATCH and DELETE handlers both refuse to leave the system with zero active admins (`422 Unprocessable Entity` with a clear message); PATCH that transitions `is_active` from true to false also wipes that user's sessions immediately.
|
||||||
|
|
||||||
|
Validation: username `^[a-z0-9._-]{2,32}$`, password minimum 8 characters (no complexity rules — follows NIST 800-63B guidance).
|
||||||
|
|
||||||
|
Dashboard surface: `/admin/login` (unauthed), `/admin/admins` (user list with add / change-password / deactivate / reactivate / delete actions per row). The top-bar shows the logged-in admin and a logout button. Token is held in a Svelte store with a localStorage echo so a page refresh doesn't sign you out; cookie-based auth works in parallel for non-SPA browser hits.
|
||||||
|
|
||||||
|
### Forward Compatibility
|
||||||
|
|
||||||
|
Schema is intentionally simple so role/permission tables can be added without touching `admin_users`. Illustrative future shape:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE admin_roles (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE -- e.g., 'super_admin', 'app_editor', 'app_viewer'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE admin_user_roles (
|
||||||
|
admin_user_id UUID REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
role_id UUID REFERENCES admin_roles(id) ON DELETE RESTRICT,
|
||||||
|
app_id UUID REFERENCES apps(id) ON DELETE CASCADE, -- nullable for global roles
|
||||||
|
PRIMARY KEY (admin_user_id, role_id, app_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Permission checks land in middleware that initially only enforces "authenticated"; the same middleware is the seam where role checks slot in later. Don't pre-build the role tables — but keep the middleware shape such that adding them is a localized change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11.5 App Scoping (v1.x)
|
||||||
|
|
||||||
|
**Purpose**: PiCloud hosts multiple independent applications on one platform. Each app is the isolation boundary for scripts, routes, domains, and (later) data — App A cannot see or modify App B's resources except through HTTP calls between them.
|
||||||
|
|
||||||
|
**Why this slot**: pulled forward from the original v1.3+ "multi-user / project namespacing" bullet. Adding the `app_id` scoping dimension to schemas while the surface is small is cheap; retrofitting it after KV, docs, users, etc. ship is a multi-table migration on populated data.
|
||||||
|
|
||||||
|
### Apps Own Scripts
|
||||||
|
|
||||||
|
Every script belongs to exactly one app (`scripts.app_id`, non-null). Script IDs remain globally unique UUIDs — the API operates on script IDs directly without needing `app_id` in the URL. The dashboard nests scripts under their app in URLs (see "Dashboard URL Layout" below) but the script ID alone is still enough to resolve them server-side.
|
||||||
|
|
||||||
|
Cross-app script reuse is not done by linking. A future **duplicate-to-app** feature may copy a script's content and config into another app under a new ID, with **snapshot semantics**: the copy is independent, and changes to the original do not propagate. Genuine cross-app integration goes through HTTP calls (and, much later, an explicit export/import model for shared data).
|
||||||
|
|
||||||
|
### Apps Own Domains
|
||||||
|
|
||||||
|
Routes can no longer claim arbitrary hostnames freely. Each app declares a set of **domain claims**:
|
||||||
|
|
||||||
|
| Form | Example | Matches |
|
||||||
|
|---|---|---|
|
||||||
|
| Exact host | `app.example.com` | only that exact host |
|
||||||
|
| Single-label wildcard | `*.example.com` | one label deep: `foo.example.com`, not `a.b.example.com` |
|
||||||
|
| Parameterized | `{tenant}.example.com` | same shape as wildcard; binds `tenant` into request context |
|
||||||
|
|
||||||
|
**Syntax convention**: domain parameters use `{name}` (curly braces); route-path parameters use `:name` (colon). These are deliberately distinct so docs and conflict messages never confuse the two.
|
||||||
|
|
||||||
|
Every app also implicitly carries the reserved claim `__internal__`, granting access to `/api/v1/execute/{id}/*` for that app's scripts. An app with no public domain still works for execute-by-id (and, later, cron triggers, queue triggers, etc.).
|
||||||
|
|
||||||
|
When a route is created, its host must match one of the parent app's domain claims. The dashboard's route-creation UI offers a selector populated from the app's claims rather than a free-text host field.
|
||||||
|
|
||||||
|
### Conflict Rules — Checked at Claim Time
|
||||||
|
|
||||||
|
Domain-claim collisions are detected when a domain is added to an app, not when requests arrive:
|
||||||
|
|
||||||
|
- **Exact vs identical exact** → reject ("domain already claimed").
|
||||||
|
- **Exact vs wildcard** → allowed. `foo.example.com` (App A) coexists with `*.example.com` (App B); at request time the more-specific match wins, so A handles `foo.example.com`, B handles every other subdomain.
|
||||||
|
- **Wildcard vs wildcard at the same shape** → reject. Two apps cannot both claim `*.example.com`. `{tenant}.example.com` has the same shape as `*.example.com` for this check — the parameter name is a binding, not a discriminator.
|
||||||
|
|
||||||
|
Route-conflict errors are strictly **intra-app**. A user creating a route inside App A never sees an error that references App B. The only cross-app surface is "this domain is already claimed" at domain-claim time, which is honest and unavoidable.
|
||||||
|
|
||||||
|
### Runtime Dispatch
|
||||||
|
|
||||||
|
Request handling becomes a two-phase lookup:
|
||||||
|
|
||||||
|
1. **Host → app**: pick the app whose claim most-specifically matches the request's `Host` header (exact beats wildcard; ties are impossible by the claim rules above).
|
||||||
|
2. **Path → route**: run that app's route trie unchanged using the existing matcher.
|
||||||
|
|
||||||
|
The orchestrator's route matcher does not learn about apps — it just operates on whichever app's table was selected in step 1. This keeps the existing conflict-detection logic intact.
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
On `localhost`, `localhost` is treated as a regular domain claimed by exactly one app, defaulting to a bootstrap "default" app installed at first run. Dev and prod use the same dispatch model — no second mental model.
|
||||||
|
|
||||||
|
### Cross-App Data Sharing — Deferred
|
||||||
|
|
||||||
|
Per-app isolation is the **default and only mode** in the initial cut. KV collection `users` in App A is distinct from KV collection `users` in App B; App B cannot read App A's data without an HTTP endpoint that App A explicitly exposes.
|
||||||
|
|
||||||
|
A formal export/import model — where App B exports a collection under a public name and admin grants App A read or read-write access — is a future addition. Until it ships, the escape hatch is function-to-function HTTP calls. Sharing is easier to add than to retract; isolation comes first.
|
||||||
|
|
||||||
|
### Schema Sketch
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE apps (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
slug TEXT NOT NULL UNIQUE, -- URL-safe; used in dashboard paths
|
||||||
|
name TEXT NOT NULL, -- display name; can be edited freely
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE app_domains (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
pattern TEXT NOT NULL, -- 'app.example.com' | '*.example.com' | '{tenant}.example.com'
|
||||||
|
shape TEXT NOT NULL, -- 'exact' | 'wildcard' | 'parameterized'
|
||||||
|
shape_key TEXT NOT NULL, -- normalized form for collision check (parameterized → wildcard form)
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
|
||||||
|
UNIQUE (shape_key) -- two apps cannot share the same shape-key
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE scripts ADD COLUMN app_id UUID NOT NULL REFERENCES apps(id) ON DELETE RESTRICT;
|
||||||
|
ALTER TABLE routes ADD COLUMN app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Existing route uniqueness checks remain unchanged; they are now scoped within an app.
|
||||||
|
```
|
||||||
|
|
||||||
|
The `UNIQUE (shape_key)` constraint enforces the "same shape" rule at the DB level. Exact-vs-wildcard coexistence is allowed because exact hosts produce a different `shape_key` from wildcards.
|
||||||
|
|
||||||
|
### Bootstrap & Migration
|
||||||
|
|
||||||
|
The migration's behavior **depends on whether the install already has user content**:
|
||||||
|
|
||||||
|
- **Fresh install** (no pre-existing scripts or routes): seed a **"Hello World"** app with `localhost` as its sole domain claim, a `hello.rhai` script that returns a greeting, and a `/hello` GET route. This serves as the reference example for new users — they can hit `http://localhost:<port>/hello` immediately after first boot and see something work. The seed is intentionally minimal; future iterations may flesh it out.
|
||||||
|
- **Upgrading install** (pre-existing scripts or routes): create a **"default"** app with `slug = 'default'`, `localhost` as its sole domain claim, and assign every existing script and route to it. The Hello World seed is **not** added in this case — adding it would pollute the user's existing content.
|
||||||
|
|
||||||
|
The branch point is detected by inspecting whether `scripts` had any rows before the migration ran.
|
||||||
|
|
||||||
|
### Dashboard URL Layout
|
||||||
|
|
||||||
|
The dashboard is **app-hierarchical**, using the app's `slug` for human-readable URLs:
|
||||||
|
|
||||||
|
```
|
||||||
|
/admin/apps — app list
|
||||||
|
/admin/apps/new — create app
|
||||||
|
/admin/apps/{slug} — app overview
|
||||||
|
/admin/apps/{slug}/scripts — scripts in this app
|
||||||
|
/admin/apps/{slug}/scripts/{id} — script detail (script ID still globally unique; slug is for breadcrumbs)
|
||||||
|
/admin/apps/{slug}/routes — routes in this app
|
||||||
|
/admin/apps/{slug}/domains — domain claims for this app
|
||||||
|
/admin/apps/{slug}/settings — app settings
|
||||||
|
```
|
||||||
|
|
||||||
|
Renaming an app changes its `slug`. The previous slug stays as a **permanent redirect** to the renamed app, persisting until another app (a new app or another rename) tries to claim that retired slug. When such a collision happens, the dashboard shows a warning before letting the operator proceed: *"`old-slug` currently redirects to app `bar` — using it here will break any external links that still target the old slug."* If the operator confirms, the redirect row is dropped and the slug is reused.
|
||||||
|
|
||||||
|
Implementation sketch:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE app_slug_history (
|
||||||
|
slug TEXT PRIMARY KEY, -- the retired slug
|
||||||
|
current_app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
retired_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Slug lookup order:
|
||||||
|
1. `apps.slug = {slug}` → render the page directly.
|
||||||
|
2. `app_slug_history.slug = {slug}` → `301` redirect to `/admin/apps/{current_app.slug}/<rest>`.
|
||||||
|
3. Neither → `404`.
|
||||||
|
|
||||||
|
Slug claim order (create or rename to a slug `S`):
|
||||||
|
1. If `S` matches a current app's slug → reject as a conflict (the usual unique-constraint error).
|
||||||
|
2. If `S` matches a row in `app_slug_history` → return a "needs confirmation" response. Dashboard surfaces the warning; on confirm, delete the history row inside the same transaction as the create/rename.
|
||||||
|
3. Otherwise → proceed normally; if this was a rename, insert the old slug into `app_slug_history`.
|
||||||
|
|
||||||
|
A rename back to an app's own retired slug is a special case: just delete the row from `app_slug_history` and don't warn.
|
||||||
|
|
||||||
|
### API URL Layout
|
||||||
|
|
||||||
|
The HTTP API stays **flat**:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/admin/apps — list apps
|
||||||
|
POST /api/v1/admin/apps — create app
|
||||||
|
GET /api/v1/admin/apps/{id_or_slug} — get app
|
||||||
|
PATCH /api/v1/admin/apps/{id_or_slug} — update app
|
||||||
|
DELETE /api/v1/admin/apps/{id_or_slug} — delete app
|
||||||
|
GET /api/v1/admin/apps/{id_or_slug}/domains — list/manage domain claims
|
||||||
|
POST /api/v1/admin/apps/{id_or_slug}/domains
|
||||||
|
DELETE /api/v1/admin/apps/{id_or_slug}/domains/{domain_id}
|
||||||
|
|
||||||
|
GET /api/v1/admin/scripts — list scripts (now supports ?app={id_or_slug} filter)
|
||||||
|
GET /api/v1/admin/scripts/{id} — unchanged; script IDs are globally unique
|
||||||
|
... (rest of scripts/routes endpoints unchanged)
|
||||||
|
```
|
||||||
|
|
||||||
|
The scripts and routes endpoints keep their existing shape — this avoids forcing API consumers to a v2 migration. The new app-management endpoints are additive. Clients that want app context can use the `?app=` filter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 12. Development Roadmap
|
## 12. Development Roadmap
|
||||||
|
|
||||||
### Phase 1: MVP ✓ (Current)
|
### Phase 1: MVP ✓ (Shipped)
|
||||||
- [x] Orchestrator: REST API for script CRUD + execute
|
- [x] Manager: REST API for script CRUD + executions log
|
||||||
- [x] Executor image: load + run Rhai script
|
- [x] Orchestrator: HTTP ingress, route resolution, dispatch
|
||||||
- [x] Dashboard: upload script, deploy, delete
|
- [x] Executor: embedded Rhai engine with sandbox limits (replaces the original Docker-per-execution model — embedded gives better latency and less infra)
|
||||||
- [x] PostgreSQL: script storage + execution logs
|
- [x] Dashboard (SvelteKit): script upload, edit, routing config, execution log viewer
|
||||||
- [ ] **Timeline**: 4-6 weeks
|
- [x] PostgreSQL: scripts, routes, execution_logs; embedded migrations
|
||||||
|
- [x] Caddy reverse proxy in front of everything
|
||||||
|
|
||||||
**Deliverables:**
|
**Delivered beyond original MVP scope:** custom routing (exact / prefix / param + host-aware) with conflict detection, per-script Rhai sandbox config, four-tab dashboard detail UI, structured versioning scheme (product + SDK + API + schema + wire) with `/version` self-report, Rhai editor with autocomplete / goto / find-usages / formatter, SDK contract + schema snapshot + integration test suites.
|
||||||
- Docker image for executor
|
|
||||||
- Rust binary (Orchestrator)
|
|
||||||
- Static HTML + Alpine.js dashboard
|
|
||||||
- docker-compose.yml for local/prod deployment
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 2: v1.0 (Polish & Usability)
|
### Phase 2: v1.0 (Polish & Usability) ✓ (Shipped)
|
||||||
- Script versioning + rollback
|
- [x] Execution history dashboard
|
||||||
- Execution history dashboard (view logs, timings, errors)
|
- [x] Better error messages (Rhai parse errors, sandbox limits, timeouts)
|
||||||
- Better error messages (script parse errors, timeouts)
|
- [x] Timeout / resource-limit enforcement (per-script sandbox config)
|
||||||
- Timeout/resource limit enforcement
|
- [x] Rhai SDK docs current through SDK 1.1
|
||||||
- Container cleanup/GC
|
|
||||||
- Rhai SDK: `request()` function fully documented
|
|
||||||
|
|
||||||
**Timeline**: 2-3 weeks
|
(Script versioning + rollback remains deferred — see Phase 6.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 3: v1.1 (Expand Capabilities & Services)
|
### Phase 3: v1.0.x — Foundations (Current focus)
|
||||||
- Queue-based triggers (RabbitMQ / Redis)
|
|
||||||
- Scheduled jobs (cron syntax)
|
|
||||||
- Secrets management (encrypted env vars)
|
|
||||||
- **Rhai SDK: KV Store** (`kv.get()`, `kv.set()`, `kv.delete()` with collections)
|
|
||||||
- **Rhai SDK: Document Store** (`docs.create()`, `docs.find()`, `docs.update()`, `docs.delete()` with schema validation)
|
|
||||||
- **Rhai SDK: User Management** (auth, CRUD, roles, permissions, invitations, password reset)
|
|
||||||
- **Rhai SDK: Email** (`email.send(to, subject, body)` via SMTP)
|
|
||||||
- Rhai SDK: `s3.*`, `queue.*`, `invoke()`, `retry.*()`
|
|
||||||
- External HTTP calls from scripts (`http.get()`, `http.post()`)
|
|
||||||
- Script versioning with automatic rollback on error
|
|
||||||
|
|
||||||
**Timeline**: 8-10 weeks
|
Two foundation pieces that must land before the v1.1 service expansion, because retrofitting them later is expensive.
|
||||||
|
|
||||||
|
**3a. Admin auth** — ✓ shipped. See section 11.4. Per-user `admin_users` (not a shared secret), Argon2id passwords, env-var bootstrap of the first admin, session-token doubling as bearer token for API. No roles in this cut; schema is forward-compatible with later RBAC.
|
||||||
|
|
||||||
|
**3b. Multi-app scoping** — see section 11.5. Introduce `apps`, `app_domains`, and `app_id` columns on `scripts` and `routes`. Migration assigns existing data to a `default` app (or seeds a `Hello World` app on fresh installs). Orchestrator dispatch becomes two-phase (Host → app → route). Reserved internal domain (`__internal__`) keeps `/api/v1/execute/{id}/*` working for app scripts without requiring a public hostname. Dashboard becomes app-hierarchical (`/admin/apps/{slug}/...`); API keeps its existing flat shape with new app-management endpoints under `/api/v1/admin/apps/*`.
|
||||||
|
|
||||||
|
**Why both before v1.1**: every v1.1 service (KV, docs, users, etc.) needs an `app_id` scoping key in its schema. Adding it now, with one small migration on existing tables, is cheap. Adding it after those services ship is several migrations on populated data.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 4: v1.2 (Advanced Workflows & Hierarchies)
|
### Phase 4: v1.1 (Expand Capabilities & Services)
|
||||||
|
Ordered roughly by foundation value: each row enables the rows below it.
|
||||||
|
|
||||||
|
1. **Rhai SDK: KV Store** (`kv.get/set/delete/has` with collections, scoped per app)
|
||||||
|
2. **Rhai SDK: Document Store** (`docs.create/find/update/delete/list/query`, scoped per app)
|
||||||
|
3. **Rhai SDK: HTTP** (`http.get/post/put/delete` with SSRF deny-list)
|
||||||
|
4. **Cron triggers** (manager scheduler skeleton already exists; needs schedules table + `FOR UPDATE SKIP LOCKED` dispatch)
|
||||||
|
5. **Rhai SDK: Email** (`email.send` via SMTP; needs per-deploy config)
|
||||||
|
6. **Rhai SDK: User Management** (auth, CRUD, roles, permissions, invitations, password reset; depends on email for invites; scoped per app)
|
||||||
|
7. **Queue triggers** (start with Postgres LISTEN/NOTIFY; RabbitMQ/Redis later if needed)
|
||||||
|
8. **`invoke()` + `retry::*`** (function-to-function calls; execution_logs gain `parent_execution_id`)
|
||||||
|
9. **Secrets management** (encrypted env vars, per app)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: v1.2 (Advanced Workflows & Hierarchies)
|
||||||
- Function workflows (DAG execution, conditional branching, error handling)
|
- Function workflows (DAG execution, conditional branching, error handling)
|
||||||
- Function hierarchy (parent/child invocation, sync/async calls)
|
|
||||||
- Nested workflows
|
- Nested workflows
|
||||||
- Call graph visualization + execution tracing
|
- Call graph visualization + execution tracing
|
||||||
- Advanced query support for document store (`docs.query()` with filters)
|
- Advanced query support for document store (`docs.query()` with filters: `$gt`, `$or`, etc.)
|
||||||
|
- Service interceptors (see section 9.4)
|
||||||
**Timeline**: 6-8 weeks
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 5: v1.3+ (Scaling, Security, Observability)
|
### Phase 6: v1.3+ (Scaling, Security, Observability)
|
||||||
- Multi-user / project namespacing
|
- Cluster mode (split-process manager + per-node orchestrator + executor); cluster-mode wire protocol versioning
|
||||||
|
- Cross-app data sharing (explicit export/import model — see section 11.5)
|
||||||
|
- Script versioning + rollback (keep N historical versions in a side table; rollback endpoint)
|
||||||
- Rate limiting on endpoints
|
- Rate limiting on endpoints
|
||||||
- Auth (API keys, dashboard login)
|
- Auth (richer model: API keys, OAuth, etc.)
|
||||||
- Metrics + monitoring dashboard
|
- Metrics + monitoring dashboard
|
||||||
- Container pooling / warm starts
|
|
||||||
- Distributed tracing (OpenTelemetry)
|
- Distributed tracing (OpenTelemetry)
|
||||||
- Webhooks for execution events
|
- Webhooks for execution events
|
||||||
- S3 integration (object storage reads/writes)
|
- S3 integration (object storage reads/writes)
|
||||||
|
|||||||
Reference in New Issue
Block a user