Compare commits

..

10 Commits

Author SHA1 Message Date
MechaCat02
6891496589 feat(manager-core): admin auth gate (Phase 3a)
Closes the regression risk of the admin API and dashboard being open
to anyone reaching the bound port. Required foundation before v1.1
data-plane services land.

Per-user accounts (admin_users), Argon2id passwords, env-var bootstrap
of the first admin that becomes inert once any admin exists, opaque
32-byte session token doubling as bearer credential, 24h sliding TTL
configurable via PICLOUD_SESSION_TTL_HOURS. is_active column lets
admins be deactivated without losing audit history; last-active-admin
guard on DELETE and on PATCH that flips is_active to false (sessions
also wiped on deactivation).

require_admin middleware fronts every /api/v1/admin/* route. The data
plane (/api/v1/execute/{id}), /healthz, /version, and user routes
stay open. picloud admin reset-password <username> subcommand handles
recovery without going through HTTP.

Dashboard gains /admin/login and /admin/admins surfaces, a top-bar
user menu, and a token store with a localStorage echo so refreshes
don't sign you out. Cookie-based auth works in parallel for non-SPA
clients.

Forward compatibility: future RBAC tables (admin_roles,
admin_user_roles) join on admin_users.id; the auth middleware is the
seam where role checks slot in. Email, 2FA, passkeys, and personal
API tokens are all additive without touching admin_users.

Blueprint §11.4 updated to reflect what actually shipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:30:25 +02:00
MechaCat02
646bd55174 docs: design Phase 3 admin auth and multi-app scoping
Adds blueprint sections 11.4 (admin auth) and 11.5 (app scoping) and
restructures the section 12 roadmap to put both ahead of v1.1, since
retrofitting app_id into KV/docs/users schemas after they ship is far
more expensive than adding it now.

Admin auth: per-user admin_users (not a shared secret), Argon2id,
env-var bootstrap that becomes inert after first admin exists, session
token doubling as bearer token, 24h sliding TTL. Schema designed
forward-compatible with later RBAC.

App scoping: apps own scripts/routes/domains. Domain claims at app
level (exact / wildcard / {param} parameterized) with collision check
at claim time, so route-conflict errors stay strictly intra-app.
Two-phase orchestrator dispatch (Host → app → route trie). Slug rename
keeps the old slug as a permanent redirect until another app claims
it. Fresh-install migration seeds a Hello World app; upgrades go into
a default app instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:58:37 +02:00
MechaCat02
56de652f7a fix(dashboard): keep selection visible against active-line tint
CodeMirror layers the active-line background above the selection layer,
so the previous opaque active-line color hid selections on the current
line. Bumps selection alpha and switches active-line to a subtle sky
tint, with the brighter gutter line number as the primary cue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:58:27 +02:00
MechaCat02
3d4c7b160b fix(dashboard): preserve blank lines and improve Rhai parser errors
Two follow-ups on the Rhai formatter shipped in 0.5.1.

* Formatter no longer collapses user-intent blank lines between
  statements. The lexer now records a side-channel list of offsets
  where the source contained two-or-more consecutive newlines; the
  formatter consults it and emits a single blank in the same spot
  (rustfmt's `blank_lines_upper_bound = 1` policy applied strictly —
  the prior forced blank between top-level `fn` decls is dropped, so
  the formatter never *adds* a blank the user didn't write).
* Parse errors now read like Rhai's own diagnostics. `expect()` takes
  an optional `role` hint and each call site supplies a domain phrase
  (`name of a variable`, `function name in function declaration`,
  `'{' to begin a block`, `name of a property`, …). End-of-input is
  reported as `script is incomplete`. The dashboard banner renders
  `Parse error: {message} (line L, position C)` with 1-based
  coordinates, matching Rhai's format exactly.

The FormatError payload also keeps the byte `offset` so callers that
want to drive the editor cursor (CodeMirror works in offsets) still
have it.

Also folds the workspace Cargo.lock version bumps for 0.5.1 — the
lock-file rewrite that should have travelled with the prior commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:26:42 +02:00
MechaCat02
267c40f59c feat(dashboard): Rhai source formatter with Format button
AST-based pretty-printer: tab-indented, 100-col print width, normalized
operator spacing, predictable reflow of long argument lists, comments
preserved verbatim. Refuses to emit on a parse failure and returns the
first error, so the Edit-tab button mirrors the JSON Format UX —
inline `.error.inline` banner; doc untouched on failure.

Patch bump to `0.5.1` across Cargo.toml workspace.package, the
dashboard package.json, and the docs/versioning.md Current versions
table.

Bundle delta versus the previous build: +6 KB raw, +1.5 KB gzipped.
Cumulative since the start of this work: +28 KB raw, +7.3 KB gzipped —
well under the +100 KB budget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:51:19 +02:00
MechaCat02
1dc53a0226 feat(dashboard): go-to-definition, Ctrl+Click, and find-usages panel
`F12` jumps the cursor to the declaration of the identifier under the
caret; `Shift+F12` opens a CodeMirror panel listing every range that
resolves to the same declaration (declaration site plus all usages),
with line-number snippets that click to jump. `Ctrl+Click` (Cmd+Click
on macOS) on an identifier is wired to the same goto path. `Esc`
closes the panel.

All three features read from `rhaiAnalysisField`, so they automatically
follow the cached parse + symbol table. The panel's styling lives in a
CodeMirror `baseTheme` keyed to the dashboard's slate palette.

Bundle delta: +3 KB raw, +1 KB gzipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:46:30 +02:00
MechaCat02
6cdb1244b8 feat(dashboard): scope-aware autocomplete for user-defined symbols
Adds a second CompletionSource that reads the Rhai parser's symbol
table. On a plain word it surfaces in-scope `let`/`const`/`fn` names
(with the function signature in the popup's detail line); on `obj.`
it suggests the field names of an object-map literal that initialized
`obj`. Composes with the existing static `ctx.*` / `log::*` source via
`autocompletion({ override: [scopeCompletionSource, rhaiCompletions] })`,
which CodeMirror merges. The static source now bows out on generic
`name.` rather than flooding the popup with keywords.

A new StateField caches one parse + symbol-table per editor state and
rebuilds on doc change. Bundle delta: +18 KB raw, +4.7 KB gzipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:44:41 +02:00
MechaCat02
bc8b512b56 feat(dashboard): hand-rolled Rhai parser + symbol table + Vitest
Foundation for upcoming editor features (scope-aware autocomplete,
goto-def / find-usages, source formatter). Hand-rolled recursive
descent in TypeScript with Pratt precedence climbing for expressions,
error-tolerant so partial trees stay usable while the user is typing.
Symbol table walks the AST to produce per-scope declarations, usage
sites, and object-literal field maps. Vitest added as a dev-only
runner; no editor wiring in this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:38:15 +02:00
MechaCat02
a80e6d1ca4 feat(dashboard): CodeMirror editors for Rhai source + JSON
Replaces the four <textarea> usages with a CodeMirror 6 editor that
brings, just by being a real editor: syntax highlighting, line
numbers, bracket matching, multi-cursor, proper undo/redo, and
search/replace (Ctrl+F / Ctrl+H). Plus a Rhai-aware autocomplete and
a "Format JSON" button on the test-invoke panels.

Per discussion, deliberately did NOT add: LSP, go-to-definition,
Rhai formatter (none exists), or anything else IDE-shaped. The
existing CodeEditor component is wired so swapping the language
extension later is a one-line change.

Lay of the land (from the research pass):
  * No CodeMirror Rhai package exists on npm.
  * No Rhai formatter exists anywhere.
  * The Rhai authors publish a TextMate grammar at
    rhaiscript/vscode-rhai (MPL-2.0). We don't load the full
    grammar (would cost ~250KB of vscode-textmate + oniguruma);
    we cite it as the source-of-truth for our keyword/operator
    lists in a small custom StreamLanguage.
  * rhaiscript/lsp exists but is experimental + unmaintained
    since 2023; skipped.

Files:
  * dashboard/src/lib/editor-theme.ts — CodeMirror theme +
    HighlightStyle wired to the existing slate/sky palette so the
    editor blends into the cards instead of looking transplanted.
  * dashboard/src/lib/rhai-mode.ts — StreamLanguage tokenizer for
    Rhai with the upstream grammar's keyword/operator lists, plus
    a completion source pulling ctx.* / log::* from our SDK
    contract suite (the authoritative list).
  * dashboard/src/lib/CodeEditor.svelte — wraps EditorView with
    two-way $bindable() value, language picker ('rhai' | 'json'),
    placeholder, minHeight props. Guards against the update
    listener echoing parent-driven changes back as edits.
  * Replaces textareas in:
      routes/+page.svelte                 — create form source
      routes/scripts/[id]/+page.svelte    — Edit tab source +
                                            Test invoke body +
                                            headers
  * Format buttons next to the body/headers editors run
    JSON.stringify(JSON.parse(value), null, 2); errors surface
    inline next to the button without trashing the field.

Bundle:
  * +~430KB to the CodeMirror chunk in dashboard build (~150KB
    gzipped on the wire). Lazy-loaded — only fetched when a route
    that uses CodeEditor renders.
  * `npm install` clean, 0 vulnerabilities, `npm run check`
    clean, `npm run build` clean.

No backend / API / SDK / schema / wire changes. No version bumps.
2026-05-23 22:52:07 +02:00
MechaCat02
0eaf4aee69 chore: versioning guardrail script for the structural checks
scripts/check-versioning.sh — POSIX sh, no dependencies, runs in
under a second. Three structural checks that don't need git
history (the parts that do need it stay deferred until we have CI
and a CHANGELOG file):

  1. Migration filenames are sequential 0001_*.sql, 0002_*.sql, ...
     with no gaps or duplicates. Catches "added migration with
     the wrong number" before it reaches review.
  2. SDK_VERSION in shared::version parses as MAJOR.MINOR
     (numeric, no extra components). Catches accidental
     PATCH-style bumps like "1.1.0" that the SemVer-for-SDKs
     rule in docs/versioning.md forbids.
  3. [workspace.package].version parses as MAJOR.MINOR.PATCH
     (numeric). Catches typos in the product version bump
     that would silently downgrade everywhere.

Each check prints a precise FAIL message identifying the
offending file/value when it trips. Verified by deliberately
breaking each one and confirming exit=1.

Run manually as `bash scripts/check-versioning.sh` for now; wires
into CI as soon as we have one. Docs/versioning.md updated to
reflect that items (3) and (4) are now in place and (5) is partly
implemented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:21:37 +02:00
44 changed files with 7742 additions and 108 deletions

View File

@@ -8,6 +8,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
**Current focus (Phase 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
The platform splits into three logical services, each backed by a `*-core` library crate so the same logic runs in single-process MVP mode and split-process cluster mode:
@@ -26,7 +28,7 @@ In MVP, all three run in one process (`picloud` binary). In cluster mode, each r
Versioned API surfaces live under `/api/v{N}/...`. See [docs/versioning.md](docs/versioning.md) for the full scheme.
- `/api/v1/admin/*` — manager (control plane: script CRUD, routes CRUD + check + match, logs, config)
- `/api/v1/admin/*` — manager (control plane: script CRUD, routes CRUD + check + match, logs, config; apps CRUD once Phase 3b lands)
- `/api/v1/execute/{id}` — orchestrator (data plane: invoke a script by ID, always-available bypass)
- `/admin/*` — dashboard SPA (SvelteKit, `paths.base = '/admin'`)
- `/healthz` — liveness (string `"ok"`)
@@ -37,6 +39,10 @@ Reserved path prefixes (rejected at route creation): `/api/`, `/admin/`, `/healt
Caddy fronts everything. Same Caddyfile shape works for single-node and cluster — only upstream targets change.
**Param syntax convention:** route paths use `:name` (e.g., `/users/:id`); domains (once apps land) use `{name}` (e.g., `{tenant}.example.com`). These are deliberately distinct — never use `:` in a domain context or `{}` in a route-path context.
**Two-phase dispatch (Phase 3b onward):** the orchestrator first resolves `Host` → app (most-specific domain claim wins), then runs that app's route trie. The route matcher itself is unchanged and never sees other apps' routes.
## Tech Stack
- **Rust 1.92+** workspace, pinned via `rust-toolchain.toml`
@@ -102,4 +108,6 @@ docs/
## Out of MVP
Queue triggers, cron triggers, SMTP ingress, KV / docs / email / users / HTTP SDKs in scripts, interceptors, workflows, function-to-function `invoke()`, auth, multi-tenancy, secrets, metrics dashboard. All deferred to v1.1+ per the blueprint. Don't pre-build for them — but don't make decisions that close the door on them either.
Queue triggers, cron triggers, SMTP ingress, KV / docs / email / users / HTTP SDKs in scripts, interceptors, workflows, function-to-function `invoke()`, secrets, metrics dashboard. All deferred to v1.1+ per the blueprint. Don't pre-build for them — but don't make decisions that close the door on them either.
**Pulled forward to Phase 3 (pre-v1.1):** admin auth, multi-app scoping. Cross-app data sharing (export/import) stays at v1.3+; the initial cut enforces strict isolation. See blueprint §11.5.

53
Cargo.lock generated
View File

@@ -46,6 +46,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "assert-json-diff"
version = "2.0.2"
@@ -206,6 +218,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -1233,6 +1254,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "pear"
version = "0.2.9"
@@ -1273,7 +1305,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "picloud"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"anyhow",
"async-trait",
@@ -1297,7 +1329,7 @@ dependencies = [
[[package]]
name = "picloud-executor"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"anyhow",
"picloud-executor-core",
@@ -1309,7 +1341,7 @@ dependencies = [
[[package]]
name = "picloud-executor-core"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"chrono",
"picloud-shared",
@@ -1323,7 +1355,7 @@ dependencies = [
[[package]]
name = "picloud-manager"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"anyhow",
"picloud-manager-core",
@@ -1335,17 +1367,22 @@ dependencies = [
[[package]]
name = "picloud-manager-core"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"argon2",
"async-trait",
"axum",
"base64",
"chrono",
"picloud-orchestrator-core",
"picloud-shared",
"rand 0.8.6",
"serde",
"serde_json",
"sha2",
"sqlx",
"thiserror 1.0.69",
"tokio",
"tracing",
"url",
"uuid",
@@ -1353,7 +1390,7 @@ dependencies = [
[[package]]
name = "picloud-orchestrator"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"anyhow",
"picloud-orchestrator-core",
@@ -1365,7 +1402,7 @@ dependencies = [
[[package]]
name = "picloud-orchestrator-core"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"async-trait",
"axum",
@@ -1384,7 +1421,7 @@ dependencies = [
[[package]]
name = "picloud-shared"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"async-trait",
"chrono",

View File

@@ -12,7 +12,7 @@ members = [
]
[workspace.package]
version = "0.5.0"
version = "0.5.1"
edition = "2021"
rust-version = "1.92"
license = "MIT OR Apache-2.0"
@@ -66,6 +66,12 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
url = "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]
unsafe_code = "forbid"

View File

@@ -22,3 +22,11 @@ uuid.workspace = true
chrono.workspace = true
sqlx.workspace = true
url.workspace = true
argon2.workspace = true
rand.workspace = true
sha2.workspace = true
base64.workspace = true
[dev-dependencies]
tokio.workspace = true

View 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);

View 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())
}
}

View 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,
}
}
}

View 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());
}
}

View 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");
}
}

View 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()
}

View 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));
}
}

View 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);
}
}

View File

@@ -4,7 +4,14 @@
//! the same DB for now; once we add caching and per-node ingress, the
//! manager will publish change events.
pub mod admin_session_repo;
pub mod admin_user_repo;
pub mod admin_users_api;
pub mod api;
pub mod auth;
pub mod auth_api;
pub mod auth_bootstrap;
pub mod auth_middleware;
pub mod log_sink;
pub mod migrations;
pub mod repo;
@@ -13,7 +20,21 @@ pub mod route_repo;
pub mod sandbox;
pub mod scheduler;
pub use admin_session_repo::{
AdminSessionLookup, AdminSessionRepository, AdminSessionRepositoryError,
PostgresAdminSessionRepository,
};
pub use admin_user_repo::{
AdminUserCredentials, AdminUserRepository, AdminUserRepositoryError, AdminUserRow,
PostgresAdminUserRepository,
};
pub use admin_users_api::{admins_router, AdminsState};
pub use api::{admin_router, AdminState};
pub use 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 repo::{
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,

View File

@@ -6,10 +6,13 @@
use std::sync::Arc;
use std::time::Duration;
use axum::middleware::from_fn_with_state;
use axum::{routing::get, Json, Router};
use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{
admin_router, compile_routes, migrations, route_admin_router, AdminState,
admin_router, admins_router, auth_router, compile_routes, migrations, require_admin,
route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
AuthState, PostgresAdminSessionRepository, PostgresAdminUserRepository,
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
};
@@ -24,6 +27,38 @@ use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use tower_http::trace::TraceLayer;
/// Default session TTL when `PICLOUD_SESSION_TTL_HOURS` isn't set.
const DEFAULT_SESSION_TTL_HOURS: u64 = 24;
/// Bundles the auth-related dependencies that both `build_app` and the
/// startup bootstrap need. Built once in `main.rs` from the shared pool.
pub struct AuthDeps {
pub users: Arc<dyn AdminUserRepository>,
pub sessions: Arc<dyn AdminSessionRepository>,
pub 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
/// 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
/// falls through to the user-route table — user scripts can bind to
/// arbitrary paths (subject to the reserved-prefix list).
pub async fn build_app(pool: PgPool) -> anyhow::Result<Router> {
///
/// `auth` carries the admin user/session repositories and the
/// configured session TTL. The manager-side admin endpoints
/// (`/api/v1/admin/scripts/*`, `/api/v1/admin/routes/*`,
/// `/api/v1/admin/admins/*`, `/api/v1/admin/auth/me`) are guarded by
/// the `require_admin` middleware. The data plane
/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`,
/// `/version`) stays open — it's the public ingress for user scripts.
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
let engine = Arc::new(Engine::new(Limits::default()));
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
@@ -68,9 +111,29 @@ pub async fn build_app(pool: PgPool) -> anyhow::Result<Router> {
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()
.nest("/admin", admin_router(admin))
.nest("/admin", route_admin_router(route_admin))
.nest("/admin", auth_router(auth_state))
.nest("/admin", guarded_admin)
.merge(data_plane_router(data_plane.clone()));
Ok(Router::new()

View File

@@ -1,17 +1,36 @@
//! PiCloud all-in-one binary — see `lib.rs` for the actual app
//! composition; this file is only the runtime shell (env config,
//! logger, migrations, listener).
//! logger, migrations, listener) plus the small `admin` CLI subcommand
//! used for out-of-band password recovery.
use std::io::{BufRead, Write};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use picloud::{build_app, init_db};
use picloud_manager_core::migrations;
use picloud::{build_app, init_db, AuthDeps};
use picloud_manager_core::{
auth::{hash_password, validate_password_hash},
bootstrap_first_admin, migrations, AdminSessionRepository, AdminUserRepository,
};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
init_tracing();
// Subcommand dispatch — `picloud admin reset-password <username>`.
// Kept handwritten to avoid pulling clap in just for one verb. Falls
// through to the server when no subcommand is given.
let args: Vec<String> = std::env::args().collect();
if args.get(1).map(String::as_str) == Some("admin") {
return run_admin_cli(&args[2..]).await;
}
run_server().await
}
async fn run_server() -> anyhow::Result<()> {
let addr: SocketAddr = std::env::var("PICLOUD_BIND")
.unwrap_or_else(|_| "0.0.0.0:8080".into())
.parse()?;
@@ -22,7 +41,15 @@ async fn main() -> anyhow::Result<()> {
migrations::run(&pool).await?;
tracing::info!("migrations applied");
let app = build_app(pool).await?;
let auth = AuthDeps::from_pool(pool.clone());
bootstrap_first_admin(&*auth.users).await?;
// Background session-prune sweep. Cheap; keeps the table from
// growing unbounded. Expired rows are also rejected at lookup time,
// so a delayed sweep can't extend session lifetimes.
spawn_session_pruner(auth.sessions.clone());
let app = build_app(pool, auth).await?;
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(%addr, "picloud all-in-one listening");
@@ -33,6 +60,112 @@ async fn main() -> anyhow::Result<()> {
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() {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))

View File

@@ -17,9 +17,35 @@ use axum_test::TestServer;
use serde_json::{json, Value};
use sqlx::PgPool;
/// Build the all-in-one app over the test pool, seed a single admin
/// directly through the repo (bypassing the env-var bootstrap path so
/// tests don't contaminate the process environment), log in, and bake
/// the bearer token into the TestServer as a default header so every
/// request in the test passes the `require_admin` middleware.
async fn server(pool: PgPool) -> TestServer {
let app = picloud::build_app(pool).await.expect("build_app");
TestServer::new(app).expect("TestServer should build")
use picloud_manager_core::auth::hash_password;
let auth = picloud::AuthDeps::from_pool(pool.clone());
let hash = hash_password("test-pw").expect("hash");
auth.users
.create("test-admin", &hash)
.await
.expect("seed admin");
let app = picloud::build_app(pool, auth).await.expect("build_app");
let mut server = TestServer::new(app).expect("TestServer should build");
let resp = server
.post("/api/v1/admin/auth/login")
.json(&json!({ "username": "test-admin", "password": "test-pw" }))
.await;
resp.assert_status_ok();
let token = resp.json::<Value>()["token"]
.as_str()
.expect("login should return token")
.to_string();
server.add_header("authorization", format!("Bearer {token}"));
server
}
// ============================================================================
@@ -705,7 +731,7 @@ async fn version_includes_public_base_url(pool: PgPool) {
let v: Value = r.json();
assert!(v["public_base_url"].is_string());
assert_eq!(v["api"], 1);
assert_eq!(v["schema"], 3);
assert_eq!(v["schema"], 4);
assert_eq!(v["sdk"], "1.1");
}

View File

@@ -50,3 +50,4 @@ macro_rules! id_type {
id_type!(ScriptId);
id_type!(ExecutionId);
id_type!(RequestId);
id_type!(AdminUserId);

View File

@@ -16,7 +16,7 @@ pub mod version;
pub use error::Error;
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 route::{HostKind, PathKind, Route};
pub use sandbox::ScriptSandbox;

View File

@@ -1,12 +1,23 @@
{
"name": "picloud-dashboard",
"version": "0.1.0",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"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": {
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-static": "^3.0.8",
@@ -23,7 +34,99 @@
"svelte-check": "^4.1.4",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0",
"vite": "^6.0.7"
"vite": "^6.0.7",
"vitest": "^3.0.5"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.2",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz",
"integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.6",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz",
"integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz",
"integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -741,6 +844,47 @@
"@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": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -1209,6 +1353,17 @@
"vite": "^6.0.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -1216,6 +1371,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1532,6 +1694,121 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.4",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^4.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -1606,6 +1883,16 @@
"node": ">= 0.4"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -1634,6 +1921,16 @@
"concat-map": "0.0.1"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -1644,6 +1941,23 @@
"node": ">=6"
}
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1661,6 +1975,16 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -1687,6 +2011,21 @@
"node": ">=6"
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1724,6 +2063,12 @@
"node": ">= 0.6"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1770,6 +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": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1794,6 +2149,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -2082,6 +2444,16 @@
"node": ">=4.0"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -2092,6 +2464,16 @@
"node": ">=0.10.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2310,6 +2692,13 @@
"dev": true,
"license": "ISC"
},
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@@ -2425,6 +2814,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2584,6 +2980,23 @@
"node": ">=8"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2923,6 +3336,13 @@
"node": ">=8"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@@ -2948,6 +3368,20 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -2961,6 +3395,25 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -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": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -3075,6 +3542,36 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -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": {
"version": "1.1.3",
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -3286,6 +3885,23 @@
"node": ">= 8"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "picloud-dashboard",
"version": "0.5.0",
"version": "0.5.1",
"private": true,
"type": "module",
"scripts": {
@@ -10,14 +10,15 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
"lint": "prettier --check . && eslint .",
"test": "vitest run"
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-static": "^3.0.8",
"@types/node": "^22.10.5",
"@sveltejs/kit": "^2.17.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/node": "^22.10.5",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
@@ -28,9 +29,21 @@
"svelte-check": "^4.1.4",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0",
"vite": "^6.0.7"
"vite": "^6.0.7",
"vitest": "^3.0.5"
},
"overrides": {
"cookie": "^0.7.2"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.2",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.7.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.0",
"@lezer/highlight": "^1.2.3",
"codemirror": "^6.0.2"
}
}

View 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>

View File

@@ -5,6 +5,11 @@
// the same Caddy upstream so the "Test invoke" panel can hit it
// without any cross-origin gymnastics.
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { browser } from '$app/environment';
import { clearSession, getToken, setSession, type AdminUser } from './auth';
export interface ScriptSandbox {
max_operations?: number;
max_string_size?: number;
@@ -134,12 +139,26 @@ export class ApiError extends Error {
}
async function adminRequest<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) }
});
const headers: Record<string, string> = {
'content-type': 'application/json',
...((init?.headers as Record<string, string>) ?? {})
};
const tok = getToken();
if (tok && !headers['authorization']) {
headers['authorization'] = `Bearer ${tok}`;
}
const res = await fetch(path, { ...init, headers });
const text = await res.text();
const parsed: unknown = text ? safeJson(text) : null;
if (res.status === 401) {
// Token gone stale or never present. Drop any cached session
// and bounce to login — unless we're already on it, in which
// case throw and let the login form render the error.
clearSession();
if (browser && !window.location.pathname.endsWith('/login')) {
void goto(`${base}/login`);
}
}
if (!res.ok) {
const message =
(parsed && typeof parsed === 'object' && 'error' in parsed
@@ -158,11 +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 = {
health: () => fetch('/healthz').then((r) => r.text()),
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: {
listForScript: (scriptId: string) =>
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),

60
dashboard/src/lib/auth.ts Normal file
View 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);
}

View 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);

View 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 (20200 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);
}

View 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[];
}

View 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);
});
});

View 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;
}

View 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';

View 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');
});
});

View 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, 20200 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);
}

View 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();
});
});

View 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: '' };
}
}

View 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);
});
});

View 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);
}

View File

@@ -1,6 +1,44 @@
<script lang="ts">
import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { page } from '$app/state';
import { api } from '$lib/api';
import { currentUser, getToken } from '$lib/auth';
let { children } = $props();
let booting = $state(true);
const user = $derived($currentUser);
const isLoginRoute = $derived(page.url.pathname.endsWith('/login'));
onMount(async () => {
// Hydrate the session: if there's a token, ask the server who we
// are. On 401 the fetch wrapper already redirects to /login and
// clears state; on success we land in the SPA fully signed in.
const tok = getToken();
if (!tok) {
if (!isLoginRoute) {
await goto(`${base}/login`);
}
booting = false;
return;
}
try {
const me = await api.auth.me();
currentUser.set(me);
} catch {
// adminRequest handles 401 redirects. For other errors fall
// through — the page will surface its own error state.
}
booting = false;
});
async function handleLogout() {
await api.auth.logout();
await goto(`${base}/login`);
}
</script>
<div class="shell">
@@ -8,10 +46,22 @@
<a href={base + '/'} class="brand">PiCloud</a>
<nav>
<a href={base + '/'}>Scripts</a>
<a href={base + '/admins'}>Admins</a>
</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>
<main>
{#if booting}
<p class="boot">Loading…</p>
{:else}
{@render children?.()}
{/if}
</main>
</div>
@@ -45,6 +95,11 @@
text-decoration: none;
}
nav {
display: flex;
gap: 1.5rem;
}
nav a {
color: #94a3b8;
text-decoration: none;
@@ -55,6 +110,36 @@
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 {
flex: 1;
padding: 2rem;
@@ -63,4 +148,8 @@
margin: 0 auto;
box-sizing: border-box;
}
.boot {
color: #64748b;
}
</style>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { base } from '$app/paths';
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}';
@@ -80,7 +81,7 @@
</div>
<label class="full">
<span>Source (Rhai)</span>
<textarea bind:value={createSource} rows="10" spellcheck="false"></textarea>
<CodeEditor bind:value={createSource} language="rhai" minHeight="14rem" />
</label>
{#if createError}
<div class="error">{createError}</div>
@@ -191,8 +192,7 @@
grid-column: 1 / -1;
}
.create-form input,
.create-form textarea {
.create-form input {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
@@ -201,13 +201,6 @@
font: inherit;
}
.create-form textarea {
font-family:
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
min-height: 8rem;
resize: vertical;
}
.actions {
display: flex;
justify-content: flex-end;

View 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>

View 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 &lt;username&gt;</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>

View File

@@ -13,6 +13,19 @@
} from '$lib/api';
import { logLevelColor, statusColor } from '$lib/styles';
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.
let id = $derived(page.params.id ?? '');
@@ -55,6 +68,17 @@
let editableSource = $state('');
let savingSource = $state(false);
let saveSourceError = $state<string | null>(null);
let rhaiFormatError = $state<string | null>(null);
function formatRhaiSource() {
const r = formatRhai(editableSource);
if (r.ok) {
editableSource = r.text;
rhaiFormatError = null;
} else {
rhaiFormatError = `Parse error: ${r.error.message} (line ${r.error.line}, position ${r.error.column})`;
}
}
async function saveSource() {
if (!script) return;
@@ -72,7 +96,28 @@
let testBody = $state('{}');
let testHeaders = $state('{}');
let testBodyFormatError = $state<string | null>(null);
let testHeadersFormatError = $state<string | null>(null);
let testInProgress = $state(false);
function formatTestBody() {
const r = formatJson(testBody);
if (r.ok) {
testBody = r.text;
testBodyFormatError = null;
} else {
testBodyFormatError = r.error;
}
}
function formatTestHeaders() {
const r = formatJson(testHeaders);
if (r.ok) {
testHeaders = r.text;
testHeadersFormatError = null;
} else {
testHeadersFormatError = r.error;
}
}
let testResult = $state<{
status: number;
headers: Record<string, string>;
@@ -351,8 +396,16 @@
{#if tab === 'edit'}
<div class="grid">
<section class="card">
<header class="editor-header">
<h2>Source</h2>
<textarea bind:value={editableSource} rows="14" spellcheck="false"></textarea>
<button type="button" class="ghost small" onclick={formatRhaiSource}>
Format
</button>
</header>
<CodeEditor bind:value={editableSource} language="rhai" minHeight="22rem" />
{#if rhaiFormatError}
<div class="error inline">{rhaiFormatError}</div>
{/if}
{#if saveSourceError}
<div class="error inline">{saveSourceError}</div>
{/if}
@@ -369,14 +422,30 @@
<section class="card">
<h2>Test invoke</h2>
<label>
<div class="json-block">
<header class="json-header">
<span>Request body (JSON)</span>
<textarea bind:value={testBody} rows="5" spellcheck="false"></textarea>
</label>
<label>
<button type="button" class="ghost small" onclick={formatTestBody}>
Format
</button>
</header>
<CodeEditor bind:value={testBody} language="json" minHeight="9rem" />
{#if testBodyFormatError}
<div class="error inline">{testBodyFormatError}</div>
{/if}
</div>
<div class="json-block">
<header class="json-header">
<span>Headers (JSON object)</span>
<textarea bind:value={testHeaders} rows="3" spellcheck="false"></textarea>
</label>
<button type="button" class="ghost small" onclick={formatTestHeaders}>
Format
</button>
</header>
<CodeEditor bind:value={testHeaders} language="json" minHeight="6rem" />
{#if testHeadersFormatError}
<div class="error inline">{testHeadersFormatError}</div>
{/if}
</div>
<div class="actions">
<button type="button" onclick={invoke} disabled={testInProgress}>
{testInProgress ? 'Running…' : 'Send'}
@@ -727,6 +796,32 @@
color: #94a3b8;
border: 1px solid #334155;
}
button.small {
padding: 0.2rem 0.6rem;
font-size: 0.75rem;
font-weight: 500;
}
.json-block {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.json-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
color: #cbd5e1;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.editor-header h2 {
margin: 0;
}
button.link {
background: transparent;
color: #94a3b8;
@@ -798,7 +893,6 @@
align-items: center;
}
textarea,
input,
select {
background: #0b1220;
@@ -811,12 +905,6 @@
width: 100%;
box-sizing: border-box;
}
textarea {
font-family:
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
font-size: 0.85rem;
resize: vertical;
}
input:disabled,
select:disabled {
opacity: 0.5;

View 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'
}
});

View File

@@ -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.
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).
5. **CI guardrail script.** A small diff-aware check that:
- Fails if `SDK_VERSION`'s major changed without a `CHANGELOG.md` breaking-change entry
- Fails if a new file appeared in `migrations/` that isn't the next sequential number
- Fails if a route handler removed or retyped a public field without a `BREAKING:` line in the commit message
5. **CI guardrail script.** [`scripts/check-versioning.sh`](../scripts/check-versioning.sh) — runs the structural checks that don't need git history:
- Migration files are numbered sequentially from `0001_*.sql` with no gaps.
- `SDK_VERSION` parses as `MAJOR.MINOR` (numeric, no extra components).
- `[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 |
|---|---|
| Product | `0.5.0` |
| Product | `0.5.1` |
| SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) |
| API | `1` |
| Schema | `3` (matches `migrations/0003_routes.sql`) |

126
scripts/check-versioning.sh Executable file
View 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'

View File

@@ -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
### Phase 1: MVP ✓ (Current)
- [x] Orchestrator: REST API for script CRUD + execute
- [x] Executor image: load + run Rhai script
- [x] Dashboard: upload script, deploy, delete
- [x] PostgreSQL: script storage + execution logs
- [ ] **Timeline**: 4-6 weeks
### Phase 1: MVP ✓ (Shipped)
- [x] Manager: REST API for script CRUD + executions log
- [x] Orchestrator: HTTP ingress, route resolution, dispatch
- [x] Executor: embedded Rhai engine with sandbox limits (replaces the original Docker-per-execution model — embedded gives better latency and less infra)
- [x] Dashboard (SvelteKit): script upload, edit, routing config, execution log viewer
- [x] PostgreSQL: scripts, routes, execution_logs; embedded migrations
- [x] Caddy reverse proxy in front of everything
**Deliverables:**
- Docker image for executor
- Rust binary (Orchestrator)
- Static HTML + Alpine.js dashboard
- docker-compose.yml for local/prod deployment
**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.
---
### Phase 2: v1.0 (Polish & Usability)
- Script versioning + rollback
- Execution history dashboard (view logs, timings, errors)
- Better error messages (script parse errors, timeouts)
- Timeout/resource limit enforcement
- Container cleanup/GC
- Rhai SDK: `request()` function fully documented
### Phase 2: v1.0 (Polish & Usability) ✓ (Shipped)
- [x] Execution history dashboard
- [x] Better error messages (Rhai parse errors, sandbox limits, timeouts)
- [x] Timeout / resource-limit enforcement (per-script sandbox config)
- [x] Rhai SDK docs current through SDK 1.1
**Timeline**: 2-3 weeks
(Script versioning + rollback remains deferred — see Phase 6.)
---
### Phase 3: v1.1 (Expand Capabilities & Services)
- 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
### Phase 3: v1.0.x — Foundations (Current focus)
**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 hierarchy (parent/child invocation, sync/async calls)
- Nested workflows
- Call graph visualization + execution tracing
- Advanced query support for document store (`docs.query()` with filters)
**Timeline**: 6-8 weeks
- Advanced query support for document store (`docs.query()` with filters: `$gt`, `$or`, etc.)
- Service interceptors (see section 9.4)
---
### Phase 5: v1.3+ (Scaling, Security, Observability)
- Multi-user / project namespacing
### Phase 6: v1.3+ (Scaling, Security, Observability)
- 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
- Auth (API keys, dashboard login)
- Auth (richer model: API keys, OAuth, etc.)
- Metrics + monitoring dashboard
- Container pooling / warm starts
- Distributed tracing (OpenTelemetry)
- Webhooks for execution events
- S3 integration (object storage reads/writes)