14 Commits

Author SHA1 Message Date
MechaCat02
a393f11344 feat(dashboard): auto-slug app names and infer route host kind from input
Two related polish passes on forms the operator hits most.

App create form: the slug field used to come before the name field and
demanded the operator hand-roll a valid slug. Now the name field comes
first and the slug is derived from it live, GitLab-style — Unicode
NFKD-decomposed, combining marks stripped (so `Café` → `cafe`), `ß`
mapped to `ss`, non-`[a-z0-9]` runs collapsed to `-`, trimmed and capped
at the backend's 63-char limit. The auto-sync releases as soon as the
operator edits the slug manually, and re-engages if they clear it. The
slug input itself runs every keystroke and paste through the same
normalizer, so dirty input never reaches the form state.

Route create form: the three-way host-kind `<select>` plus a sometimes-
disabled input was confusing — operators routinely picked the wrong
kind, typed a host the app didn't claim, and only saw the error after
hitting Create. Replace with a single text input that infers the kind
from what's there (`*` → any, `*.foo.com` → wildcard, `foo.com` →
strict), shows the detected kind as a colored chip beside the field, and
suggests the app's existing domain claims via a `<datalist>`. The same
matching logic the backend runs in `validate_route_host_against_app`
now lives in `route-utils.ts` so the form can surface a soft "not
covered by any claim" warning *before* submit. Path also pre-fills to
`/` so the most common case is one click away.

Lockfile drift from `npm install` (pre-existing 0.5.0 → 0.5.1 version
sync, npm metadata cleanup) is folded in here since it surfaced during
this work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:01:20 +02:00
MechaCat02
ad5492a4bd feat(manager-core,dashboard): cascading app delete with styled confirmation modal
Deleting an app used to require zero scripts and zero domain claims —
practical for empty apps, painful for anything else. Add an opt-in
cascade so the operator can wipe an app in one click while keeping the
safe default for the no-flag case.

Backend: `DELETE /api/v1/admin/apps/{id}?force=true` runs a single
transaction that removes every script in the app (routes and execution
logs cascade via `script_id` FK), then deletes the app row (domains and
slug-history cascade off it). Without `?force=true` the handler still
returns the same `409 HasScripts { script_count }` payload it always did.

Frontend: a new `ConfirmModal.svelte` replaces the bare `window.confirm`
on this page. It's reusable — danger/neutral variants, optional
GitHub-style "type the slug to confirm" gate, ESC/backdrop cancel,
busy state, and a generic body slot — so future destructive actions can
adopt the same pattern instead of growing more browser dialogs. The app
delete confirmation now spells out exactly what disappears (script
count, domain claim list, "all routes & logs") and only enables the red
button once the slug is retyped. The domain-claim delete is also
wired through the modal so this page no longer uses `window.confirm`
anywhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:01:05 +02:00
MechaCat02
ee0dbc428f chore(compose): require bootstrap admin env vars instead of defaulting to admin/admin
The previous interpolation used `${PICLOUD_ADMIN_USERNAME:-admin}` and
`${PICLOUD_ADMIN_PASSWORD:-admin}`, which made docker compose silently
bootstrap a production stack with `admin`/`admin` whenever the operator
forgot to set them. Flip to `${VAR:?…}` so an unset value aborts
`docker compose up` with a clear "set this var" message; dev still gets
the convenient default through the gitignored `.env` (documented in
`.env.example`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:00:52 +02:00
MechaCat02
4c41374db4 feat(manager-core,orchestrator-core): multi-app scoping (Phase 3b)
Apps become the isolation boundary for scripts, routes, domains, and
later data. Doing this now — while the surface is small — avoids
several migrations on populated tables once v1.1 data-plane services
ship.

Schema (migration 0005_apps.sql):
- New tables: apps, app_domains (with shape_key UNIQUE for collision
  detection), app_slug_history (for permanent slug-rename redirects).
- app_id added to scripts, routes, execution_logs (non-null, cascading
  rules per row).
- Script-name uniqueness becomes per-app; the route unique index is
  swapped for an app-scoped version.
- The "default" app is seeded unconditionally with a localhost claim;
  existing scripts/routes backfill into it. Fresh installs additionally
  get the Hello World seed via seed_hello_world_if_fresh after
  migrations run (idempotent — only fires when the default app has no
  scripts).

Orchestrator dispatch is two-phase: AppDomainTable resolves Host →
app_id (most-specific match wins, exact beats wildcard), then the
existing route matcher runs against that app's partitioned slice via
RouteTable. Unknown hosts return 404 at the app layer with a clear
message; /api/v1/execute/{id} still works as the implicit
__internal__ claim, decoupled from any public domain.

Manager API: full CRUD for /api/v1/admin/apps/* and
/api/v1/admin/apps/{id_or_slug}/domains/*, with slug:check + force
takeover semantics implementing the rename-history flow (two-step
check → confirm, never a single endpoint). Script create requires
app_id; list accepts ?app= filter. Route create validates host
against the parent app's claims; conflict detection stays strictly
intra-app.

Dashboard: /admin/apps and /admin/apps/{slug} (overview + scripts +
domains + settings tabs, with slug-history-aware redirects). Root
path redirects to the apps list. Script detail page gains an app
breadcrumb and threads app_id into the route preview.

Deferred per design: per-app admin roles. The require_admin middleware
remains the seam where role checks will slot in later.

Blueprint §11.5 and roadmap updated to reflect what shipped; docs/
versioning.md notes the schema 3 → 5 bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:03:05 +02:00
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
74 changed files with 12316 additions and 597 deletions

View File

@@ -29,3 +29,11 @@ RUST_LOG=info,picloud=debug
# Public base URL the dashboard uses to render full URLs for user routes. # Public base URL the dashboard uses to render full URLs for user routes.
# Set to the host:port (and scheme) users actually reach in their browser. # Set to the host:port (and scheme) users actually reach in their browser.
PICLOUD_PUBLIC_BASE_URL=http://localhost:8000 PICLOUD_PUBLIC_BASE_URL=http://localhost:8000
# ---------- Bootstrap admin ----------
# Required. Used once on first startup to seed the admin_users table.
# Ignored on subsequent boots if the table is non-empty. For prod,
# prefer PICLOUD_ADMIN_PASSWORD_HASH (pre-computed Argon2id PHC) so the
# raw password never lands in env or compose files; see blueprint §11.5.
PICLOUD_ADMIN_USERNAME=admin
PICLOUD_ADMIN_PASSWORD=admin

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

53
Cargo.lock generated
View File

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

View File

@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.5.0" version = "0.5.1"
edition = "2021" edition = "2021"
rust-version = "1.92" rust-version = "1.92"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -66,6 +66,12 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
url = "2" url = "2"
urlencoding = "2" urlencoding = "2"
# Auth (admin users + sessions)
argon2 = "0.5"
rand = { version = "0.8", features = ["getrandom"] }
sha2 = "0.10"
base64 = "0.22"
[workspace.lints.rust] [workspace.lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"

View File

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

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,117 @@
-- Phase 3b multi-app scoping — see blueprint §11.5.
--
-- Apps are the top-level isolation boundary for scripts, routes, domain
-- claims and (forward) data. The orchestrator dispatches Host → app_id →
-- route trie; cross-app resource access is not possible.
--
-- This migration is unconditional:
-- 1. Creates the three new tables (apps, app_domains, app_slug_history).
-- 2. Always inserts a "default" app claiming `localhost` so existing
-- installs get a usable home for their pre-existing scripts/routes.
-- 3. Backfills app_id on scripts, routes, execution_logs from the
-- default app row, then promotes the columns to NOT NULL + FK.
--
-- Fresh installs get the same "default" app row; an in-Rust bootstrap
-- step (manager-core::app_bootstrap) decides whether to seed a Hello
-- World script into it. Doing the seed in Rust keeps it testable and
-- lets the script source live in a real .rhai file.
CREATE TABLE apps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- URL-safe identifier; mutable via the rename flow which records
-- the prior slug in app_slug_history for permanent 301 redirects.
-- Format validation (`^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
-- check) lives in Rust handlers, not SQL.
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Domain claims. Most-specific wins at request time; same-shape
-- collisions are rejected at claim time via the UNIQUE(shape_key).
-- shape_key encoding:
-- exact:<lowercased-host> for shape='exact'
-- wildcard:<lowercased-suffix> for shape='wildcard' AND 'parameterized'
-- (parameterized is the same shape as wildcard for collision — the
-- parameter name is a binding, not a discriminator. See blueprint §11.5.)
CREATE TABLE app_domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
pattern TEXT NOT NULL,
shape TEXT NOT NULL CHECK (shape IN ('exact', 'wildcard', 'parameterized')),
shape_key TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX app_domains_app_id_idx ON app_domains (app_id);
-- Permanent 301 redirects after a slug rename. A row dies only when
-- another app explicitly claims the retired slug (with confirmation in
-- the UI). On_delete cascade: if the owning app is deleted, its history
-- row goes too (otherwise the redirect would point at a dead app).
CREATE TABLE app_slug_history (
slug TEXT PRIMARY KEY,
current_app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
retired_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Seed the default app + a localhost claim. Used by both upgrade and
-- fresh-install paths; the Rust bootstrap layers Hello World on top
-- only when the install was fresh.
WITH default_app AS (
INSERT INTO apps (slug, name, description)
VALUES ('default', 'Default', 'The default application — assigned to all pre-existing scripts and routes during the multi-app migration.')
RETURNING id
)
INSERT INTO app_domains (app_id, pattern, shape, shape_key)
SELECT id, 'localhost', 'exact', 'exact:localhost' FROM default_app;
-- Add app_id to scripts. The default app already exists (above), so
-- there is exactly one row to look up.
ALTER TABLE scripts ADD COLUMN app_id UUID;
UPDATE scripts SET app_id = (SELECT id FROM apps WHERE slug = 'default');
ALTER TABLE scripts ALTER COLUMN app_id SET NOT NULL;
ALTER TABLE scripts
ADD CONSTRAINT scripts_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT;
-- Per-app name uniqueness. Two apps can each have a script called
-- "echo"; previously they could not.
DROP INDEX scripts_name_uidx;
CREATE UNIQUE INDEX scripts_name_uidx ON scripts (app_id, LOWER(name));
CREATE INDEX scripts_app_id_idx ON scripts (app_id);
-- Add app_id to routes, mirroring the script's app.
ALTER TABLE routes ADD COLUMN app_id UUID;
UPDATE routes
SET app_id = scripts.app_id
FROM scripts
WHERE routes.script_id = scripts.id;
ALTER TABLE routes ALTER COLUMN app_id SET NOT NULL;
ALTER TABLE routes
ADD CONSTRAINT routes_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE;
-- Replace the route uniqueness index so two apps can claim identical
-- (host_kind, host, path_kind, path, method) tuples — they live in
-- separate route trees and never see each other.
DROP INDEX routes_unique_binding_idx;
CREATE UNIQUE INDEX routes_unique_binding_idx
ON routes (app_id, host_kind, host, path_kind, path, COALESCE(method, ''));
CREATE INDEX routes_app_id_idx ON routes (app_id);
-- Add app_id to execution_logs. Materialized at write time so future
-- script-moves (or eventual export/import) don't silently retag history.
ALTER TABLE execution_logs ADD COLUMN app_id UUID;
UPDATE execution_logs
SET app_id = scripts.app_id
FROM scripts
WHERE execution_logs.script_id = scripts.id;
ALTER TABLE execution_logs ALTER COLUMN app_id SET NOT NULL;
ALTER TABLE execution_logs
ADD CONSTRAINT execution_logs_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE;
CREATE INDEX execution_logs_app_id_created_at_idx
ON execution_logs (app_id, created_at DESC);

View File

@@ -0,0 +1,15 @@
// Hello World — the reference example seeded into the default app on
// fresh installs. Bound to GET /hello.
let who = ctx.request.body;
let name = if who != () && type_of(who) == "map" && who.contains("name") {
who.name
} else {
"world"
};
return #{
statusCode: 200,
headers: #{ "Content-Type": "application/json" },
body: #{ message: `Hello, ${name}!` }
};

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

@@ -5,17 +5,18 @@
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::get, routing::get,
Json, Router, Json, Router,
}; };
use picloud_shared::{ use picloud_shared::{
ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError, AppId, ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
}; };
use serde::Deserialize; use serde::Deserialize;
use crate::app_repo::AppRepository;
use crate::repo::{ use crate::repo::{
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError, ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
}; };
@@ -27,6 +28,9 @@ use crate::sandbox::{CeilingError, SandboxCeiling};
pub struct AdminState<R, L> { pub struct AdminState<R, L> {
pub repo: Arc<R>, pub repo: Arc<R>,
pub logs: Arc<L>, pub logs: Arc<L>,
/// App lookups: validates `app_id` on create, resolves `?app=<slug>`
/// filter on list. Trait-object so apps_repo can stay separate.
pub apps: Arc<dyn AppRepository>,
pub validator: Arc<dyn ScriptValidator>, pub validator: Arc<dyn ScriptValidator>,
pub sandbox_ceiling: SandboxCeiling, pub sandbox_ceiling: SandboxCeiling,
} }
@@ -36,6 +40,7 @@ impl<R, L> Clone for AdminState<R, L> {
Self { Self {
repo: self.repo.clone(), repo: self.repo.clone(),
logs: self.logs.clone(), logs: self.logs.clone(),
apps: self.apps.clone(),
validator: self.validator.clone(), validator: self.validator.clone(),
sandbox_ceiling: self.sandbox_ceiling, sandbox_ceiling: self.sandbox_ceiling,
} }
@@ -70,6 +75,9 @@ where
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CreateScriptRequest { pub struct CreateScriptRequest {
/// Owning app. Required since Phase 3b — scripts cannot exist
/// outside an app. Use `/api/v1/admin/apps` to list known ids.
pub app_id: AppId,
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
pub source: String, pub source: String,
@@ -82,6 +90,14 @@ pub struct CreateScriptRequest {
pub sandbox: ScriptSandbox, pub sandbox: ScriptSandbox,
} }
#[derive(Debug, Deserialize)]
pub struct ListScriptsQuery {
/// Optional filter: list scripts belonging to a single app, by id
/// or slug. Absent = all scripts across all apps (admin-global view).
#[serde(default)]
pub app: Option<String>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateScriptRequest { pub struct UpdateScriptRequest {
pub name: Option<String>, pub name: Option<String>,
@@ -113,8 +129,32 @@ where
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>( async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>, State(state): State<AdminState<R, L>>,
Query(q): Query<ListScriptsQuery>,
) -> Result<Json<Vec<Script>>, ApiError> { ) -> Result<Json<Vec<Script>>, ApiError> {
Ok(Json(state.repo.list().await?)) if let Some(ident) = q.app {
let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
Ok(Json(state.repo.list_for_app(app).await?))
} else {
Ok(Json(state.repo.list().await?))
}
}
/// Accept `?app=<uuid>` OR `?app=<slug>`. Slugs route through history
/// for redirects, but here we just need the live current id; if a
/// retired slug is given, we follow it to the current app silently.
async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApiError> {
if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
let id = AppId::from(uuid);
apps.get_by_id(id)
.await?
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
return Ok(id);
}
let lookup = apps
.get_by_slug_or_history(ident)
.await?
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
Ok(lookup.app.id)
} }
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>( async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
@@ -135,9 +175,15 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
) -> Result<(StatusCode, Json<Script>), ApiError> { ) -> Result<(StatusCode, Json<Script>), ApiError> {
state.validator.validate(&input.source)?; state.validator.validate(&input.source)?;
state.sandbox_ceiling.check(&input.sandbox)?; state.sandbox_ceiling.check(&input.sandbox)?;
// Refuse early if the app_id doesn't exist — a clean 422 beats a
// raw FK violation surfacing as 500.
if state.apps.get_by_id(input.app_id).await?.is_none() {
return Err(ApiError::AppNotFound(input.app_id.to_string()));
}
let created = state let created = state
.repo .repo
.create(NewScript { .create(NewScript {
app_id: input.app_id,
name: input.name, name: input.name,
description: input.description, description: input.description,
source: input.source, source: input.source,
@@ -223,6 +269,9 @@ pub enum ApiError {
#[error("script not found: {0}")] #[error("script not found: {0}")]
NotFound(ScriptId), NotFound(ScriptId),
#[error("app not found: {0}")]
AppNotFound(String),
#[error("conflict: {0}")] #[error("conflict: {0}")]
Conflict(String), Conflict(String),
@@ -240,6 +289,7 @@ impl IntoResponse for ApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, message) = match &self { let (status, message) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
Self::AppNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()), Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
Self::Invalid(_) | Self::Ceiling(_) => { Self::Invalid(_) | Self::Ceiling(_) => {
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string()) (StatusCode::UNPROCESSABLE_ENTITY, self.to_string())

View File

@@ -0,0 +1,92 @@
//! Hello-World seed for fresh installs.
//!
//! Idempotent. Runs after migrations and after admin bootstrap. Only
//! seeds when the default app is empty (no scripts, no routes); on
//! upgrades it does nothing so existing content isn't polluted.
use std::sync::Arc;
use picloud_shared::{App, AppId, HostKind, PathKind};
use crate::app_repo::AppRepository;
use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError};
use crate::route_repo::{NewRoute, RouteRepository};
const HELLO_RHAI_SOURCE: &str = include_str!("../seeds/hello.rhai");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HelloWorldOutcome {
/// Default app already has scripts (or doesn't exist) — left alone.
SkippedExisting,
/// Inserted the hello.rhai script and the `/hello` route.
Seeded,
}
#[derive(Debug, thiserror::Error)]
pub enum SeedError {
#[error("default app not found — did the migration run?")]
MissingDefaultApp,
#[error("repository error: {0}")]
Repo(#[from] ScriptRepositoryError),
}
pub async fn seed_hello_world_if_fresh(
apps: Arc<dyn AppRepository>,
scripts: Arc<dyn ScriptRepository>,
routes: Arc<dyn RouteRepository>,
) -> Result<HelloWorldOutcome, SeedError> {
let default = apps
.get_by_slug("default")
.await?
.ok_or(SeedError::MissingDefaultApp)?;
// Idempotence: only seed when both scripts AND routes are empty.
// (Either alone is suspicious enough to skip — the operator may have
// already started shaping the default app.)
let existing_scripts = scripts.list_for_app(default.id).await?;
let existing_routes = routes.list_for_app(default.id).await?;
if !existing_scripts.is_empty() || !existing_routes.is_empty() {
return Ok(HelloWorldOutcome::SkippedExisting);
}
seed_into(&*scripts, &*routes, &default).await?;
Ok(HelloWorldOutcome::Seeded)
}
async fn seed_into(
scripts: &dyn ScriptRepository,
routes: &dyn RouteRepository,
default: &App,
) -> Result<(), ScriptRepositoryError> {
let script = scripts
.create(NewScript {
app_id: default.id,
name: "hello".to_string(),
description: Some("Reference example: returns a greeting at GET /hello.".to_string()),
source: HELLO_RHAI_SOURCE.to_string(),
timeout_seconds: Some(5),
memory_limit_mb: None,
sandbox: None,
})
.await?;
routes
.create(NewRoute {
app_id: default.id,
script_id: script.id,
host_kind: HostKind::Any,
host: String::new(),
host_param_name: None,
path_kind: PathKind::Exact,
path: "/hello".to_string(),
// Accept any method so both `curl /hello` and
// `curl -d '{"name":"X"}' /hello` work out of the box.
method: None,
})
.await?;
Ok(())
}
#[allow(dead_code)]
fn _typecheck(_id: AppId) {} // suppress unused-import warnings if reshuffled

View File

@@ -0,0 +1,152 @@
//! CRUD over the `app_domains` table.
//!
//! Parsing + shape_key derivation live in `orchestrator-core`'s
//! `routing::pattern::parse_app_domain` — this repo just stores what
//! the API handler hands it. Same-shape collisions surface as a unique
//! constraint violation on `shape_key`, mapped here to a clean error.
use async_trait::async_trait;
use picloud_shared::{AppDomain, AppId, DomainShape};
use sqlx::PgPool;
use uuid::Uuid;
use crate::repo::ScriptRepositoryError;
#[derive(Debug, Clone)]
pub struct NewAppDomain {
pub app_id: AppId,
pub pattern: String,
pub shape: DomainShape,
pub shape_key: String,
}
#[async_trait]
pub trait AppDomainRepository: Send + Sync {
/// All domain claims across all apps — used by the orchestrator's
/// `AppDomainTable` to build its lookup cache at startup and after
/// every write.
async fn list_all(&self) -> Result<Vec<AppDomain>, ScriptRepositoryError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<AppDomain>, ScriptRepositoryError>;
async fn get(&self, domain_id: Uuid) -> Result<Option<AppDomain>, ScriptRepositoryError>;
async fn create(&self, input: NewAppDomain) -> Result<AppDomain, ScriptRepositoryError>;
async fn delete(&self, domain_id: Uuid) -> Result<(), ScriptRepositoryError>;
}
pub struct PostgresAppDomainRepository {
pool: PgPool,
}
impl PostgresAppDomainRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl AppDomainRepository for PostgresAppDomainRepository {
async fn list_all(&self) -> Result<Vec<AppDomain>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, DomainRow>(
"SELECT id, app_id, pattern, shape, shape_key, created_at \
FROM app_domains ORDER BY pattern",
)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<AppDomain>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, DomainRow>(
"SELECT id, app_id, pattern, shape, shape_key, created_at \
FROM app_domains WHERE app_id = $1 ORDER BY pattern",
)
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn get(&self, domain_id: Uuid) -> Result<Option<AppDomain>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, DomainRow>(
"SELECT id, app_id, pattern, shape, shape_key, created_at \
FROM app_domains WHERE id = $1",
)
.bind(domain_id)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn create(&self, input: NewAppDomain) -> Result<AppDomain, ScriptRepositoryError> {
let res = sqlx::query_as::<_, DomainRow>(
"INSERT INTO app_domains (app_id, pattern, shape, shape_key) \
VALUES ($1, $2, $3, $4) \
RETURNING id, app_id, pattern, shape, shape_key, created_at",
)
.bind(input.app_id.into_inner())
.bind(&input.pattern)
.bind(shape_str(input.shape))
.bind(&input.shape_key)
.fetch_one(&self.pool)
.await;
match res {
Ok(row) => Ok(row.into()),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
Err(ScriptRepositoryError::Conflict(format!(
"domain {:?} (or another claim of the same shape) is already claimed",
input.pattern
)))
}
Err(e) => Err(e.into()),
}
}
async fn delete(&self, domain_id: Uuid) -> Result<(), ScriptRepositoryError> {
let res = sqlx::query("DELETE FROM app_domains WHERE id = $1")
.bind(domain_id)
.execute(&self.pool)
.await?;
if res.rows_affected() == 0 {
return Err(ScriptRepositoryError::Conflict(format!(
"domain {domain_id} not found"
)));
}
Ok(())
}
}
const fn shape_str(s: DomainShape) -> &'static str {
match s {
DomainShape::Exact => "exact",
DomainShape::Wildcard => "wildcard",
DomainShape::Parameterized => "parameterized",
}
}
#[derive(sqlx::FromRow)]
struct DomainRow {
id: Uuid,
app_id: Uuid,
pattern: String,
shape: String,
shape_key: String,
created_at: chrono::DateTime<chrono::Utc>,
}
impl From<DomainRow> for AppDomain {
fn from(r: DomainRow) -> Self {
Self {
id: r.id,
app_id: r.app_id.into(),
pattern: r.pattern,
shape: match r.shape.as_str() {
"wildcard" => DomainShape::Wildcard,
"parameterized" => DomainShape::Parameterized,
_ => DomainShape::Exact,
},
shape_key: r.shape_key,
created_at: r.created_at,
}
}
}

View File

@@ -0,0 +1,404 @@
//! CRUD over the `apps` and `app_slug_history` tables.
//!
//! Slug validation (regex, reserved-word check) lives in the API
//! handler; this repo enforces only what Postgres enforces (uniqueness,
//! FK). The slug-rename flow is exposed as a single `rename_slug` call
//! that writes the history row in the same transaction.
use async_trait::async_trait;
use picloud_shared::{App, AppId};
use sqlx::PgPool;
use crate::repo::ScriptRepositoryError;
/// Result of looking up an app by slug or via the redirect history.
#[derive(Debug, Clone)]
pub struct AppLookup {
pub app: App,
/// `true` when the slug was found in `app_slug_history` rather than
/// directly on `apps`. Dashboards should issue a redirect.
pub redirected: bool,
}
#[async_trait]
pub trait AppRepository: Send + Sync {
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
async fn get_by_slug_or_history(
&self,
slug: &str,
) -> Result<Option<AppLookup>, ScriptRepositoryError>;
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
async fn create(
&self,
slug: &str,
name: &str,
description: Option<&str>,
) -> Result<App, ScriptRepositoryError>;
/// Create that also consumes a matching `app_slug_history` row, if
/// any. Used after the operator has confirmed they want to break old
/// redirects.
async fn create_with_takeover(
&self,
slug: &str,
name: &str,
description: Option<&str>,
) -> Result<App, ScriptRepositoryError>;
async fn update(
&self,
id: AppId,
name: Option<&str>,
description: Option<Option<&str>>,
) -> Result<App, ScriptRepositoryError>;
/// Rename and record the old slug in `app_slug_history` (so
/// retired URLs keep redirecting). If `take_over_history` is true,
/// any existing history row for `new_slug` is consumed.
async fn rename_slug(
&self,
id: AppId,
new_slug: &str,
take_over_history: bool,
) -> Result<App, ScriptRepositoryError>;
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
/// Delete the app along with all its scripts (which in turn cascades
/// routes and execution logs via their `script_id` FK). Domains and
/// app-slug-history rows cascade off the app row itself. Runs in a
/// single transaction so a partial delete cannot be observed.
async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError>;
}
pub struct PostgresAppRepository {
pool: PgPool,
}
impl PostgresAppRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl AppRepository for PostgresAppRepository {
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, created_at, updated_at \
FROM apps ORDER BY name",
)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, created_at, updated_at \
FROM apps WHERE id = $1",
)
.bind(id.into_inner())
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, created_at, updated_at \
FROM apps WHERE slug = $1",
)
.bind(slug)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn get_by_slug_or_history(
&self,
slug: &str,
) -> Result<Option<AppLookup>, ScriptRepositoryError> {
if let Some(app) = self.get_by_slug(slug).await? {
return Ok(Some(AppLookup {
app,
redirected: false,
}));
}
if let Some(app) = self.slug_in_history(slug).await? {
return Ok(Some(AppLookup {
app,
redirected: true,
}));
}
Ok(None)
}
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, AppRow>(
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
FROM app_slug_history h \
JOIN apps a ON a.id = h.current_app_id \
WHERE h.slug = $1",
)
.bind(slug)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn create(
&self,
slug: &str,
name: &str,
description: Option<&str>,
) -> Result<App, ScriptRepositoryError> {
let res = sqlx::query_as::<_, AppRow>(
"INSERT INTO apps (slug, name, description) \
VALUES ($1, $2, $3) \
RETURNING id, slug, name, description, created_at, updated_at",
)
.bind(slug)
.bind(name)
.bind(description)
.fetch_one(&self.pool)
.await;
match res {
Ok(row) => Ok(row.into()),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
ScriptRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
),
Err(e) => Err(e.into()),
}
}
async fn create_with_takeover(
&self,
slug: &str,
name: &str,
description: Option<&str>,
) -> Result<App, ScriptRepositoryError> {
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
.bind(slug)
.execute(&mut *tx)
.await?;
let row = sqlx::query_as::<_, AppRow>(
"INSERT INTO apps (slug, name, description) \
VALUES ($1, $2, $3) \
RETURNING id, slug, name, description, created_at, updated_at",
)
.bind(slug)
.bind(name)
.bind(description)
.fetch_one(&mut *tx)
.await;
let row = match row {
Ok(r) => r,
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
return Err(ScriptRepositoryError::Conflict(format!(
"slug {slug:?} is already in use"
)));
}
Err(e) => return Err(e.into()),
};
tx.commit().await?;
Ok(row.into())
}
async fn update(
&self,
id: AppId,
name: Option<&str>,
description: Option<Option<&str>>,
) -> Result<App, ScriptRepositoryError> {
let row = sqlx::query_as::<_, AppRow>(
"UPDATE apps SET \
name = COALESCE($2, name), \
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
updated_at = NOW() \
WHERE id = $1 \
RETURNING id, slug, name, description, created_at, updated_at",
)
.bind(id.into_inner())
.bind(name)
.bind(description.is_some())
.bind(description.and_then(|d| d))
.fetch_optional(&self.pool)
.await?;
row.map(Into::into)
.ok_or_else(|| ScriptRepositoryError::Conflict(format!("app {id} not found")))
}
async fn rename_slug(
&self,
id: AppId,
new_slug: &str,
take_over_history: bool,
) -> Result<App, ScriptRepositoryError> {
let mut tx = self.pool.begin().await?;
// 1. Read the current slug (so we can record it in history).
let current: Option<(String,)> = sqlx::query_as("SELECT slug FROM apps WHERE id = $1")
.bind(id.into_inner())
.fetch_optional(&mut *tx)
.await?;
let Some((current_slug,)) = current else {
return Err(ScriptRepositoryError::Conflict(format!(
"app {id} not found"
)));
};
if current_slug == new_slug {
// No-op rename; just return the row.
let row = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, created_at, updated_at \
FROM apps WHERE id = $1",
)
.bind(id.into_inner())
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
return Ok(row.into());
}
// 2. If renaming back to this app's own retired slug, just
// consume the history row silently (no warning, no takeover
// flag required).
let owns_history: Option<(uuid::Uuid,)> =
sqlx::query_as("SELECT current_app_id FROM app_slug_history WHERE slug = $1")
.bind(new_slug)
.fetch_optional(&mut *tx)
.await?;
match owns_history {
Some((owner,)) if owner == id.into_inner() => {
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
.bind(new_slug)
.execute(&mut *tx)
.await?;
}
Some(_) if take_over_history => {
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
.bind(new_slug)
.execute(&mut *tx)
.await?;
}
Some(_) => {
return Err(ScriptRepositoryError::Conflict(format!(
"slug {new_slug:?} is in history; rename with takeover to claim it"
)));
}
None => {}
}
// 3. Record the current slug in history (replacing any older
// entry — the same slug can pass through history multiple
// times across many renames).
sqlx::query(
"INSERT INTO app_slug_history (slug, current_app_id) \
VALUES ($1, $2) \
ON CONFLICT (slug) DO UPDATE SET current_app_id = EXCLUDED.current_app_id, \
retired_at = NOW()",
)
.bind(&current_slug)
.bind(id.into_inner())
.execute(&mut *tx)
.await?;
// 4. Apply the rename. Unique violation = another live app
// already holds this slug.
let row = sqlx::query_as::<_, AppRow>(
"UPDATE apps SET slug = $2, updated_at = NOW() \
WHERE id = $1 \
RETURNING id, slug, name, description, created_at, updated_at",
)
.bind(id.into_inner())
.bind(new_slug)
.fetch_one(&mut *tx)
.await;
let row = match row {
Ok(r) => r,
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
return Err(ScriptRepositoryError::Conflict(format!(
"slug {new_slug:?} is already in use by another app"
)));
}
Err(e) => return Err(e.into()),
};
tx.commit().await?;
Ok(row.into())
}
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
let res = sqlx::query("DELETE FROM apps WHERE id = $1")
.bind(id.into_inner())
.execute(&self.pool)
.await;
match res {
Ok(r) if r.rows_affected() == 0 => Err(ScriptRepositoryError::Conflict(format!(
"app {id} not found"
))),
Ok(_) => Ok(()),
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
// ON DELETE RESTRICT on scripts.app_id — surface a clean
// "has dependents" error rather than a raw SQL message.
Err(ScriptRepositoryError::Conflict(
"app still contains scripts; delete or move them first".into(),
))
}
Err(e) => Err(e.into()),
}
}
async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM scripts WHERE app_id = $1")
.bind(id.into_inner())
.execute(&mut *tx)
.await?;
let res = sqlx::query("DELETE FROM apps WHERE id = $1")
.bind(id.into_inner())
.execute(&mut *tx)
.await?;
if res.rows_affected() == 0 {
return Err(ScriptRepositoryError::Conflict(format!(
"app {id} not found"
)));
}
tx.commit().await?;
Ok(())
}
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError> {
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scripts WHERE app_id = $1")
.bind(id.into_inner())
.fetch_one(&self.pool)
.await?;
Ok(count.0)
}
}
#[derive(sqlx::FromRow)]
struct AppRow {
id: uuid::Uuid,
slug: String,
name: String,
description: Option<String>,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<AppRow> for App {
fn from(r: AppRow) -> Self {
Self {
id: r.id.into(),
slug: r.slug,
name: r.name,
description: r.description,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
}

View File

@@ -0,0 +1,524 @@
//! `/api/v1/admin/apps/*` — app + domain claim CRUD.
//!
//! All endpoints are guarded by `require_admin`. Per-app permissions
//! are deferred (every authenticated admin can act on every app); the
//! middleware seam exists for when that lands.
//!
//! Slug validation: regex `^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
//! list rejected. Slug renames record the old slug in
//! `app_slug_history` for permanent 301 redirects; reclaiming a
//! historical slug requires `"force_takeover": true` in the request.
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get, post};
use axum::Router;
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
use picloud_shared::{App, AppDomain, AppId};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
use crate::app_repo::AppRepository;
use crate::repo::ScriptRepositoryError;
use crate::route_repo::RouteRepository;
const SLUG_MIN: usize = 1;
const SLUG_MAX: usize = 63;
const RESERVED_SLUGS: &[&str] = &[
"new", "api", "admin", "admins", "healthz", "version", "login", "logout", "apps",
];
#[derive(Clone)]
pub struct AppsState {
pub apps: Arc<dyn AppRepository>,
pub domains: Arc<dyn AppDomainRepository>,
pub routes: Arc<dyn RouteRepository>,
/// Cached host → app_id lookup; replaced after every domain CRUD
/// operation so the orchestrator sees changes immediately.
pub domain_table: Arc<AppDomainTable>,
}
pub fn apps_router(state: AppsState) -> Router {
Router::new()
.route("/apps", get(list_apps).post(create_app))
.route(
"/apps/{id_or_slug}",
get(get_app).patch(patch_app).delete(delete_app),
)
.route("/apps/{id_or_slug}/slug:check", post(slug_check))
.route(
"/apps/{id_or_slug}/domains",
get(list_domains).post(create_domain),
)
.route(
"/apps/{id_or_slug}/domains/{domain_id}",
delete(delete_domain),
)
.with_state(state)
}
// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------
#[derive(Debug, Serialize)]
pub struct AppDto {
#[serde(flatten)]
pub app: App,
}
#[derive(Debug, Deserialize)]
pub struct CreateAppRequest {
pub slug: String,
pub name: String,
pub description: Option<String>,
/// Set to `true` to consume an existing `app_slug_history` row for
/// the requested slug (breaking old redirects).
#[serde(default)]
pub force_takeover: bool,
}
#[derive(Debug, Deserialize)]
pub struct PatchAppRequest {
pub name: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_optional")]
#[allow(clippy::option_option)]
pub description: Option<Option<String>>,
pub slug: Option<String>,
#[serde(default)]
pub force_takeover: bool,
}
#[allow(clippy::option_option)]
fn deserialize_optional_optional<'de, D>(d: D) -> Result<Option<Option<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<String>::deserialize(d).map(Some)
}
#[derive(Debug, Deserialize)]
pub struct SlugCheckRequest {
pub new_slug: String,
}
#[derive(Debug, Serialize)]
pub struct SlugCheckResponse {
pub ok: bool,
pub conflict_kind: Option<&'static str>,
pub current_app: Option<App>,
pub reason: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreateDomainRequest {
pub pattern: String,
}
/// Query params for `DELETE /apps/{id_or_slug}`. `force=true` opts into
/// a cascading delete that also removes every script in the app (and
/// thereby their routes and execution logs). Without it the request is
/// rejected when the app still contains scripts.
#[derive(Debug, Default, Deserialize)]
pub struct DeleteAppQuery {
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Serialize)]
pub struct AppLookupResponse {
#[serde(flatten)]
pub app: App,
/// When the operator hits the API with a retired slug, this points
/// at the live slug so dashboards can redirect.
#[serde(skip_serializing_if = "Option::is_none")]
pub redirect_to: Option<String>,
}
// ----------------------------------------------------------------------------
// Handlers
// ----------------------------------------------------------------------------
async fn list_apps(State(s): State<AppsState>) -> Result<Json<Vec<App>>, AppsApiError> {
Ok(Json(s.apps.list().await?))
}
async fn create_app(
State(s): State<AppsState>,
Json(input): Json<CreateAppRequest>,
) -> Result<(StatusCode, Json<App>), AppsApiError> {
validate_slug(&input.slug)?;
// Historical-slug check before insert: if the slug is in history
// and the caller hasn't asked to force takeover, surface a clean
// 409 so the dashboard can present a "this will break old links"
// confirmation.
if !input.force_takeover {
if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
return Err(AppsApiError::SlugInHistory(current));
}
}
let created = if input.force_takeover {
s.apps
.create_with_takeover(&input.slug, &input.name, input.description.as_deref())
.await?
} else {
s.apps
.create(&input.slug, &input.name, input.description.as_deref())
.await?
};
Ok((StatusCode::CREATED, Json(created)))
}
async fn get_app(
State(s): State<AppsState>,
Path(id_or_slug): Path<String>,
) -> Result<Json<AppLookupResponse>, AppsApiError> {
let lookup = resolve_app(&*s.apps, &id_or_slug).await?;
let redirect_to = if lookup.redirected {
Some(lookup.app.slug.clone())
} else {
None
};
Ok(Json(AppLookupResponse {
app: lookup.app,
redirect_to,
}))
}
async fn patch_app(
State(s): State<AppsState>,
Path(id_or_slug): Path<String>,
Json(input): Json<PatchAppRequest>,
) -> Result<Json<App>, AppsApiError> {
let current = resolve_app(&*s.apps, &id_or_slug).await?.app;
// Edits to name/description go first (separate from rename so we
// don't conflate the two errors).
let after_meta = if input.name.is_some() || input.description.is_some() {
s.apps
.update(
current.id,
input.name.as_deref(),
input.description.as_ref().map(|d| d.as_deref()),
)
.await?
} else {
current
};
// Slug rename is a separate operation; the rename method does its
// own history bookkeeping in a transaction.
let after_rename = if let Some(new_slug) = input.slug.as_deref() {
validate_slug(new_slug)?;
match s
.apps
.rename_slug(after_meta.id, new_slug, input.force_takeover)
.await
{
Ok(app) => app,
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
if let Some(current) = s.apps.slug_in_history(new_slug).await? {
return Err(AppsApiError::SlugInHistory(current));
}
return Err(AppsApiError::Conflict(msg));
}
Err(e) => return Err(e.into()),
}
} else {
after_meta
};
Ok(Json(after_rename))
}
async fn delete_app(
State(s): State<AppsState>,
Path(id_or_slug): Path<String>,
Query(q): Query<DeleteAppQuery>,
) -> Result<StatusCode, AppsApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
if q.force {
s.apps.delete_cascade(app.id).await?;
} else {
// Soft pre-check for a clean error; the DB FK is the real guard
// (ON DELETE RESTRICT on scripts.app_id).
let n_scripts = s.apps.count_scripts_in_app(app.id).await?;
if n_scripts > 0 {
return Err(AppsApiError::HasScripts(n_scripts));
}
s.apps.delete(app.id).await?;
}
refresh_domain_cache(&s).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn slug_check(
State(s): State<AppsState>,
Path(_id_or_slug): Path<String>,
Json(input): Json<SlugCheckRequest>,
) -> Result<Json<SlugCheckResponse>, AppsApiError> {
match validate_slug(&input.new_slug) {
Err(AppsApiError::InvalidSlug(reason)) => {
return Ok(Json(SlugCheckResponse {
ok: false,
conflict_kind: Some("invalid"),
current_app: None,
reason: Some(reason),
}));
}
Err(other) => return Err(other),
Ok(()) => {}
}
if let Some(app) = s.apps.get_by_slug(&input.new_slug).await? {
return Ok(Json(SlugCheckResponse {
ok: false,
conflict_kind: Some("current"),
current_app: Some(app),
reason: Some("another app currently uses this slug".into()),
}));
}
if let Some(app) = s.apps.slug_in_history(&input.new_slug).await? {
return Ok(Json(SlugCheckResponse {
ok: false,
conflict_kind: Some("historical"),
current_app: Some(app),
reason: Some("slug is a retired redirect; using it will break old links".into()),
}));
}
Ok(Json(SlugCheckResponse {
ok: true,
conflict_kind: None,
current_app: None,
reason: None,
}))
}
async fn list_domains(
State(s): State<AppsState>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<AppDomain>>, AppsApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
Ok(Json(s.domains.list_for_app(app.id).await?))
}
async fn create_domain(
State(s): State<AppsState>,
Path(id_or_slug): Path<String>,
Json(input): Json<CreateDomainRequest>,
) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
let parsed = pattern::parse_app_domain(&input.pattern)?;
let created = s
.domains
.create(NewAppDomain {
app_id: app.id,
pattern: input.pattern,
shape: parsed.shape,
shape_key: parsed.shape_key,
})
.await?;
refresh_domain_cache(&s).await?;
Ok((StatusCode::CREATED, Json(created)))
}
async fn delete_domain(
State(s): State<AppsState>,
Path((id_or_slug, domain_id)): Path<(String, Uuid)>,
) -> Result<StatusCode, AppsApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
let Some(domain) = s.domains.get(domain_id).await? else {
return Err(AppsApiError::DomainNotFound(domain_id));
};
if domain.app_id != app.id {
return Err(AppsApiError::DomainNotFound(domain_id));
}
// Guard: routes inside this app may reference this exact host
// pattern. The host-kind on the route is `strict` or `wildcard`
// (Any routes don't pin a specific host). We block deletion in
// either case and let the operator clean up first.
let strict = s
.routes
.count_for_app_host(app.id, picloud_shared::HostKind::Strict, &domain.pattern)
.await?;
let wild_suffix = domain
.pattern
.split_once('.')
.map(|(_, s)| s.to_string())
.unwrap_or_default();
let wild = if wild_suffix.is_empty() {
0
} else {
s.routes
.count_for_app_host(app.id, picloud_shared::HostKind::Wildcard, &wild_suffix)
.await?
};
if strict + wild > 0 {
return Err(AppsApiError::DomainHasRoutes(strict + wild));
}
s.domains.delete(domain_id).await?;
refresh_domain_cache(&s).await?;
Ok(StatusCode::NO_CONTENT)
}
// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------
async fn resolve_app(
apps: &dyn AppRepository,
ident: &str,
) -> Result<crate::app_repo::AppLookup, AppsApiError> {
if let Ok(uuid) = ident.parse::<Uuid>() {
if let Some(app) = apps.get_by_id(AppId::from(uuid)).await? {
return Ok(crate::app_repo::AppLookup {
app,
redirected: false,
});
}
return Err(AppsApiError::AppNotFound(ident.to_string()));
}
apps.get_by_slug_or_history(ident)
.await?
.ok_or_else(|| AppsApiError::AppNotFound(ident.to_string()))
}
fn validate_slug(slug: &str) -> Result<(), AppsApiError> {
if slug.len() < SLUG_MIN || slug.len() > SLUG_MAX {
return Err(AppsApiError::InvalidSlug(format!(
"slug length must be between {SLUG_MIN} and {SLUG_MAX}"
)));
}
if !slug
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric())
{
return Err(AppsApiError::InvalidSlug(
"slug must start with [a-z0-9]".into(),
));
}
for c in slug.chars() {
if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
return Err(AppsApiError::InvalidSlug(
"slug may only contain lowercase letters, digits, and '-'".into(),
));
}
}
if RESERVED_SLUGS.contains(&slug) {
return Err(AppsApiError::InvalidSlug(format!(
"slug {slug:?} is reserved for system use"
)));
}
Ok(())
}
/// Rebuild the in-memory host → app_id cache used by the orchestrator.
/// Called after every domain CRUD operation.
pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError> {
let all = state.domains.list_all().await?;
let compiled = all
.into_iter()
.filter_map(|d| {
// Parse the stored pattern; skip on parse error rather than
// poisoning the entire cache. The handlers reject bad input,
// so this is purely defensive against a future migration
// that loosens the constraints.
pattern::parse_app_domain(&d.pattern)
.ok()
.map(|p| CompiledAppDomain {
app_id: d.app_id,
pattern: p.pattern,
shape_key: p.shape_key,
})
})
.collect();
state.domain_table.replace(compiled);
Ok(())
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum AppsApiError {
#[error("app not found: {0}")]
AppNotFound(String),
#[error("domain not found: {0}")]
DomainNotFound(Uuid),
#[error("invalid slug: {0}")]
InvalidSlug(String),
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
SlugInHistory(App),
#[error("app still contains {0} script(s); delete or move them first")]
HasScripts(i64),
#[error("domain has {0} route(s) bound to it; delete the routes first")]
DomainHasRoutes(i64),
#[error("invalid pattern: {0}")]
Pattern(#[from] pattern::ParseError),
#[error("conflict: {0}")]
Conflict(String),
#[error("repository error: {0}")]
Repo(#[from] ScriptRepositoryError),
}
impl IntoResponse for AppsApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound(_)
| Self::DomainNotFound(_)
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::InvalidSlug(_) | Self::Pattern(_) => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::SlugInHistory(current) => (
StatusCode::CONFLICT,
json!({
"error": self.to_string(),
"conflict_kind": "historical",
"current_app": current,
}),
),
Self::HasScripts(n) => (
StatusCode::CONFLICT,
json!({ "error": self.to_string(), "script_count": n }),
),
Self::DomainHasRoutes(n) => (
StatusCode::CONFLICT,
json!({ "error": self.to_string(), "route_count": n }),
),
Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => {
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
}
Self::Repo(ScriptRepositoryError::Db(e)) => {
tracing::error!(error = %e, "apps api db error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

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,18 @@
//! the same DB for now; once we add caching and per-node ingress, the //! the same DB for now; once we add caching and per-node ingress, the
//! manager will publish change events. //! manager will publish change events.
pub mod admin_session_repo;
pub mod admin_user_repo;
pub mod admin_users_api;
pub mod api; pub mod api;
pub mod app_bootstrap;
pub mod app_domain_repo;
pub mod app_repo;
pub mod apps_api;
pub mod auth;
pub mod auth_api;
pub mod auth_bootstrap;
pub mod auth_middleware;
pub mod log_sink; pub mod log_sink;
pub mod migrations; pub mod migrations;
pub mod repo; pub mod repo;
@@ -13,7 +24,25 @@ pub mod route_repo;
pub mod sandbox; pub mod sandbox;
pub mod scheduler; pub mod scheduler;
pub use admin_session_repo::{
AdminSessionLookup, AdminSessionRepository, AdminSessionRepositoryError,
PostgresAdminSessionRepository,
};
pub use admin_user_repo::{
AdminUserCredentials, AdminUserRepository, AdminUserRepositoryError, AdminUserRow,
PostgresAdminUserRepository,
};
pub use admin_users_api::{admins_router, AdminsState};
pub use api::{admin_router, AdminState}; pub use api::{admin_router, AdminState};
pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
pub use app_repo::{AppLookup, AppRepository, PostgresAppRepository};
pub use apps_api::{apps_router, AppsState};
pub use auth_api::auth_router;
pub use auth_bootstrap::{
bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError,
};
pub use auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE};
pub use log_sink::PostgresExecutionLogSink; pub use log_sink::PostgresExecutionLogSink;
pub use repo::{ pub use repo::{
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository, ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,

View File

@@ -28,15 +28,16 @@ impl ExecutionLogSink for PostgresExecutionLogSink {
sqlx::query( sqlx::query(
"INSERT INTO execution_logs ( \ "INSERT INTO execution_logs ( \
id, script_id, request_id, \ id, app_id, script_id, request_id, \
request_path, request_headers, request_body, \ request_path, request_headers, request_body, \
response_code, response_body, \ response_code, response_body, \
logs, duration_ms, status, created_at \ logs, duration_ms, status, created_at \
) VALUES ( \ ) VALUES ( \
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 \ $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 \
)", )",
) )
.bind(log.id) .bind(log.id)
.bind(log.app_id.into_inner())
.bind(log.script_id.into_inner()) .bind(log.script_id.into_inner())
.bind(log.request_id.into_inner()) .bind(log.request_id.into_inner())
.bind(&log.request_path) .bind(&log.request_path)

View File

@@ -2,7 +2,9 @@ use std::collections::BTreeMap;
use async_trait::async_trait; use async_trait::async_trait;
use picloud_orchestrator_core::{ResolverError, ScriptResolver}; use picloud_orchestrator_core::{ResolverError, ScriptResolver};
use picloud_shared::{ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox}; use picloud_shared::{
AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
};
use sqlx::PgPool; use sqlx::PgPool;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -21,7 +23,10 @@ pub enum ScriptRepositoryError {
#[async_trait] #[async_trait]
pub trait ScriptRepository: Send + Sync { pub trait ScriptRepository: Send + Sync {
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError>; async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError>;
/// Every script across all apps. Mostly for tests and admin
/// "global" views; the dashboard reaches scripts via `list_for_app`.
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>; async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>; async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
async fn update( async fn update(
&self, &self,
@@ -35,6 +40,7 @@ pub trait ScriptRepository: Send + Sync {
/// constraints; the repo enforces them in the DB regardless. /// constraints; the repo enforces them in the DB regardless.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NewScript { pub struct NewScript {
pub app_id: AppId,
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
pub source: String, pub source: String,
@@ -78,7 +84,7 @@ impl PostgresScriptRepository {
impl ScriptRepository for PostgresScriptRepository { impl ScriptRepository for PostgresScriptRepository {
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> { async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, ScriptRow>( let row = sqlx::query_as::<_, ScriptRow>(
"SELECT id, name, description, version, source, \ "SELECT id, app_id, name, description, version, source, \
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \ timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
FROM scripts WHERE id = $1", FROM scripts WHERE id = $1",
) )
@@ -90,7 +96,7 @@ impl ScriptRepository for PostgresScriptRepository {
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> { async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, ScriptRow>( let rows = sqlx::query_as::<_, ScriptRow>(
"SELECT id, name, description, version, source, \ "SELECT id, app_id, name, description, version, source, \
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \ timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
FROM scripts ORDER BY name", FROM scripts ORDER BY name",
) )
@@ -99,17 +105,30 @@ impl ScriptRepository for PostgresScriptRepository {
Ok(rows.into_iter().map(Into::into).collect()) Ok(rows.into_iter().map(Into::into).collect())
} }
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, ScriptRow>(
"SELECT id, app_id, name, description, version, source, \
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
FROM scripts WHERE app_id = $1 ORDER BY name",
)
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> { async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default()) let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
.unwrap_or_else(|_| serde_json::json!({})); .unwrap_or_else(|_| serde_json::json!({}));
let res = sqlx::query_as::<_, ScriptRow>( let res = sqlx::query_as::<_, ScriptRow>(
"INSERT INTO scripts ( \ "INSERT INTO scripts ( \
name, description, source, \ app_id, name, description, source, \
timeout_seconds, memory_limit_mb, sandbox \ timeout_seconds, memory_limit_mb, sandbox \
) VALUES ($1, $2, $3, COALESCE($4, 30), COALESCE($5, 256), $6) \ ) VALUES ($1, $2, $3, $4, COALESCE($5, 30), COALESCE($6, 256), $7) \
RETURNING id, name, description, version, source, \ RETURNING id, app_id, name, description, version, source, \
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at", timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
) )
.bind(input.app_id.into_inner())
.bind(&input.name) .bind(&input.name)
.bind(input.description.as_deref()) .bind(input.description.as_deref())
.bind(&input.source) .bind(&input.source)
@@ -123,7 +142,7 @@ impl ScriptRepository for PostgresScriptRepository {
Ok(row) => Ok(row.into()), Ok(row) => Ok(row.into()),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => { Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
Err(ScriptRepositoryError::Conflict(format!( Err(ScriptRepositoryError::Conflict(format!(
"a script named {:?} already exists", "a script named {:?} already exists in this app",
input.name input.name
))) )))
} }
@@ -141,12 +160,13 @@ impl ScriptRepository for PostgresScriptRepository {
// explicitly set it to NULL (Some(None)) vs leave it alone (None). // explicitly set it to NULL (Some(None)) vs leave it alone (None).
// Sandbox is replaced wholesale when present; per-field merging // Sandbox is replaced wholesale when present; per-field merging
// happens in the API layer (clearer semantics for a "PUT a new // happens in the API layer (clearer semantics for a "PUT a new
// sandbox config" call). // sandbox config" call). app_id is immutable — moving a script
// to another app is a copy-and-delete, not an in-place edit.
let sandbox_json = patch let sandbox_json = patch
.sandbox .sandbox
.as_ref() .as_ref()
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({}))); .map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
let row = sqlx::query_as::<_, ScriptRow>( let res = sqlx::query_as::<_, ScriptRow>(
"UPDATE scripts SET \ "UPDATE scripts SET \
name = COALESCE($2, name), \ name = COALESCE($2, name), \
description = CASE WHEN $3::bool THEN $4 ELSE description END, \ description = CASE WHEN $3::bool THEN $4 ELSE description END, \
@@ -157,7 +177,7 @@ impl ScriptRepository for PostgresScriptRepository {
version = version + 1, \ version = version + 1, \
updated_at = NOW() \ updated_at = NOW() \
WHERE id = $1 \ WHERE id = $1 \
RETURNING id, name, description, version, source, \ RETURNING id, app_id, name, description, version, source, \
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at", timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
) )
.bind(id.into_inner()) .bind(id.into_inner())
@@ -169,10 +189,18 @@ impl ScriptRepository for PostgresScriptRepository {
.bind(patch.memory_limit_mb) .bind(patch.memory_limit_mb)
.bind(sandbox_json) .bind(sandbox_json)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await?; .await;
row.map(Into::into) match res {
.ok_or(ScriptRepositoryError::NotFound(id)) Ok(Some(row)) => Ok(row.into()),
Ok(None) => Err(ScriptRepositoryError::NotFound(id)),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
Err(ScriptRepositoryError::Conflict(
"a script with that name already exists in this app".into(),
))
}
Err(e) => Err(e.into()),
}
} }
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError> { async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError> {
@@ -191,6 +219,7 @@ impl ScriptRepository for PostgresScriptRepository {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct ScriptRow { struct ScriptRow {
id: uuid::Uuid, id: uuid::Uuid,
app_id: uuid::Uuid,
name: String, name: String,
description: Option<String>, description: Option<String>,
version: i32, version: i32,
@@ -211,6 +240,7 @@ impl From<ScriptRow> for Script {
let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default(); let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default();
Self { Self {
id: r.id.into(), id: r.id.into(),
app_id: r.app_id.into(),
name: r.name, name: r.name,
description: r.description, description: r.description,
version: r.version, version: r.version,
@@ -284,7 +314,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
offset: i64, offset: i64,
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> { ) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, ExecutionLogRow>( let rows = sqlx::query_as::<_, ExecutionLogRow>(
"SELECT id, script_id, request_id, \ "SELECT id, app_id, script_id, request_id, \
request_path, request_headers, request_body, \ request_path, request_headers, request_body, \
response_code, response_body, \ response_code, response_body, \
logs, duration_ms, status, created_at \ logs, duration_ms, status, created_at \
@@ -306,6 +336,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct ExecutionLogRow { struct ExecutionLogRow {
id: uuid::Uuid, id: uuid::Uuid,
app_id: uuid::Uuid,
script_id: uuid::Uuid, script_id: uuid::Uuid,
request_id: uuid::Uuid, request_id: uuid::Uuid,
request_path: Option<String>, request_path: Option<String>,
@@ -331,6 +362,7 @@ impl From<ExecutionLogRow> for ExecutionLog {
}; };
Self { Self {
id: r.id, id: r.id,
app_id: r.app_id.into(),
script_id: r.script_id.into(), script_id: r.script_id.into(),
request_id: RequestId::from(r.request_id), request_id: RequestId::from(r.request_id),
request_path: r.request_path.unwrap_or_default(), request_path: r.request_path.unwrap_or_default(),

View File

@@ -13,39 +13,49 @@ use axum::{
Json, Router, Json, Router,
}; };
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable}; use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
use picloud_shared::{HostKind, PathKind, Route, ScriptId}; use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::repo::ScriptRepositoryError; use crate::app_domain_repo::AppDomainRepository;
use crate::repo::{ScriptRepository, ScriptRepositoryError};
use crate::route_repo::{NewRoute, RouteRepository}; use crate::route_repo::{NewRoute, RouteRepository};
pub struct RouteAdminState<RR> { pub struct RouteAdminState<RR, SR> {
pub routes: Arc<RR>, pub routes: Arc<RR>,
/// Used to resolve `script_id → app_id` when creating routes (the
/// route inherits the script's app) and to scope conflict checks.
pub scripts: Arc<SR>,
/// Used to validate the route's host against the parent app's
/// declared domain claims.
pub domains: Arc<dyn AppDomainRepository>,
pub table: Arc<RouteTable>, pub table: Arc<RouteTable>,
} }
impl<RR> Clone for RouteAdminState<RR> { impl<RR, SR> Clone for RouteAdminState<RR, SR> {
fn clone(&self) -> Self { fn clone(&self) -> Self {
Self { Self {
routes: self.routes.clone(), routes: self.routes.clone(),
scripts: self.scripts.clone(),
domains: self.domains.clone(),
table: self.table.clone(), table: self.table.clone(),
} }
} }
} }
pub fn route_admin_router<RR>(state: RouteAdminState<RR>) -> Router pub fn route_admin_router<RR, SR>(state: RouteAdminState<RR, SR>) -> Router
where where
RR: RouteRepository + 'static, RR: RouteRepository + 'static,
SR: ScriptRepository + 'static,
{ {
Router::new() Router::new()
.route( .route(
"/scripts/{id}/routes", "/scripts/{id}/routes",
get(list_routes::<RR>).post(create_route::<RR>), get(list_routes::<RR, SR>).post(create_route::<RR, SR>),
) )
.route("/routes/{route_id}", delete(delete_route::<RR>)) .route("/routes/{route_id}", delete(delete_route::<RR, SR>))
.route("/routes:check", post(check_route::<RR>)) .route("/routes:check", post(check_route::<RR, SR>))
.route("/routes:match", post(match_route::<RR>)) .route("/routes:match", post(match_route::<RR, SR>))
.with_state(state) .with_state(state)
} }
@@ -67,6 +77,10 @@ pub struct CreateRouteRequest {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CheckRouteRequest { pub struct CheckRouteRequest {
/// Required: which app's route table this hypothetical route would
/// join. Conflict checks are strictly intra-app (cross-app route
/// errors would leak tenant info — see blueprint §11.5).
pub app_id: AppId,
pub host_kind: HostKind, pub host_kind: HostKind,
#[serde(default)] #[serde(default)]
pub host: String, pub host: String,
@@ -84,6 +98,9 @@ pub struct CheckRouteResponse {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct MatchRouteRequest { pub struct MatchRouteRequest {
/// Which app's route table to dispatch against. The dashboard's
/// route-preview tester always knows the current app context.
pub app_id: AppId,
pub url: String, pub url: String,
#[serde(default = "default_method")] #[serde(default = "default_method")]
pub method: String, pub method: String,
@@ -111,15 +128,15 @@ pub struct MatchedRoute {
// Handlers // Handlers
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
async fn list_routes<RR: RouteRepository>( async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR>>, State(state): State<RouteAdminState<RR, SR>>,
Path(script_id): Path<ScriptId>, Path(script_id): Path<ScriptId>,
) -> Result<Json<Vec<Route>>, RouteApiError> { ) -> Result<Json<Vec<Route>>, RouteApiError> {
Ok(Json(state.routes.list_for_script(script_id).await?)) Ok(Json(state.routes.list_for_script(script_id).await?))
} }
async fn create_route<RR: RouteRepository>( async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR>>, State(state): State<RouteAdminState<RR, SR>>,
Path(script_id): Path<ScriptId>, Path(script_id): Path<ScriptId>,
Json(input): Json<CreateRouteRequest>, Json(input): Json<CreateRouteRequest>,
) -> Result<(StatusCode, Json<Route>), RouteApiError> { ) -> Result<(StatusCode, Json<Route>), RouteApiError> {
@@ -130,8 +147,22 @@ async fn create_route<RR: RouteRepository>(
input.host_param_name.as_deref(), input.host_param_name.as_deref(),
)?; )?;
// Within-kind conflict check against existing routes. // Look up the script's owning app — every route inherits it.
let existing = state.routes.list_all().await?; let script = state
.scripts
.get(script_id)
.await?
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
let app_id = script.app_id;
// Validate the route's host is consistent with one of the app's
// domain claims. `HostKind::Any` is always permitted (catches every
// host the app already owns). Specific hosts must match a claim.
validate_route_host_against_app(state.domains.as_ref(), app_id, input.host_kind, &input.host)
.await?;
// Within-app conflict check (cross-app is impossible by construction).
let existing = state.routes.list_for_app(app_id).await?;
if let Some((conflicting, reason)) = first_conflict( if let Some((conflicting, reason)) = first_conflict(
&existing, &existing,
input.host_kind, input.host_kind,
@@ -149,6 +180,7 @@ async fn create_route<RR: RouteRepository>(
let created = state let created = state
.routes .routes
.create(NewRoute { .create(NewRoute {
app_id,
script_id, script_id,
host_kind: input.host_kind, host_kind: input.host_kind,
host: input.host, host: input.host,
@@ -162,8 +194,8 @@ async fn create_route<RR: RouteRepository>(
Ok((StatusCode::CREATED, Json(created))) Ok((StatusCode::CREATED, Json(created)))
} }
async fn delete_route<RR: RouteRepository>( async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR>>, State(state): State<RouteAdminState<RR, SR>>,
Path(route_id): Path<Uuid>, Path(route_id): Path<Uuid>,
) -> Result<StatusCode, RouteApiError> { ) -> Result<StatusCode, RouteApiError> {
state.routes.delete(route_id).await?; state.routes.delete(route_id).await?;
@@ -171,14 +203,14 @@ async fn delete_route<RR: RouteRepository>(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
async fn check_route<RR: RouteRepository>( async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR>>, State(state): State<RouteAdminState<RR, SR>>,
Json(input): Json<CheckRouteRequest>, Json(input): Json<CheckRouteRequest>,
) -> Result<Json<CheckRouteResponse>, RouteApiError> { ) -> Result<Json<CheckRouteResponse>, RouteApiError> {
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?; let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
pattern::parse_host(input.host_kind, &input.host, None)?; pattern::parse_host(input.host_kind, &input.host, None)?;
let existing = state.routes.list_all().await?; let existing = state.routes.list_for_app(input.app_id).await?;
let conflict = first_conflict( let conflict = first_conflict(
&existing, &existing,
input.host_kind, input.host_kind,
@@ -201,8 +233,8 @@ async fn check_route<RR: RouteRepository>(
})) }))
} }
async fn match_route<RR: RouteRepository>( async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR>>, State(state): State<RouteAdminState<RR, SR>>,
Json(input): Json<MatchRouteRequest>, Json(input): Json<MatchRouteRequest>,
) -> Result<Json<MatchRouteResponse>, RouteApiError> { ) -> Result<Json<MatchRouteResponse>, RouteApiError> {
let parsed = url::Url::parse(&input.url) let parsed = url::Url::parse(&input.url)
@@ -210,7 +242,9 @@ async fn match_route<RR: RouteRepository>(
let host = parsed.host_str().unwrap_or("").to_string(); let host = parsed.host_str().unwrap_or("").to_string();
let path = parsed.path().to_string(); let path = parsed.path().to_string();
let result = state.table.match_request(&host, &input.method, &path); let result = state
.table
.match_request_for_app(input.app_id, &host, &input.method, &path);
Ok(Json(MatchRouteResponse { Ok(Json(MatchRouteResponse {
matched: result.map(|r| MatchedRoute { matched: result.map(|r| MatchedRoute {
route_id: r.matched.route_id, route_id: r.matched.route_id,
@@ -263,12 +297,12 @@ fn first_conflict(
Ok(None) Ok(None)
} }
async fn refresh_table<RR: RouteRepository>( async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
state: &RouteAdminState<RR>, state: &RouteAdminState<RR, SR>,
) -> Result<(), RouteApiError> { ) -> Result<(), RouteApiError> {
let rows = state.routes.list_all().await?; let rows = state.routes.list_all().await?;
let compiled = compile_routes(&rows)?; let compiled = compile_routes(&rows)?;
state.table.replace(compiled); state.table.replace_all(compiled);
Ok(()) Ok(())
} }
@@ -277,6 +311,7 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
.map(|r| { .map(|r| {
Ok(CompiledRoute { Ok(CompiledRoute {
route_id: r.id, route_id: r.id,
app_id: r.app_id,
script_id: r.script_id, script_id: r.script_id,
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?, host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
path: pattern::parse_path(r.path_kind, &r.path)?, path: pattern::parse_path(r.path_kind, &r.path)?,
@@ -286,6 +321,79 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
.collect() .collect()
} }
/// Validate that a new route's (host_kind, host) is consistent with at
/// least one of the parent app's domain claims. `HostKind::Any` is
/// always permitted — it catches every host the app already owns.
async fn validate_route_host_against_app(
domains: &dyn AppDomainRepository,
app_id: AppId,
host_kind: HostKind,
host: &str,
) -> Result<(), RouteApiError> {
if matches!(host_kind, HostKind::Any) {
return Ok(());
}
let claims = domains.list_for_app(app_id).await?;
if claims.is_empty() {
return Err(RouteApiError::HostNotClaimed {
host: host.to_string(),
available_claims: vec![],
});
}
let host_lower = host.to_ascii_lowercase();
for claim in &claims {
let claim_lower = claim.pattern.to_ascii_lowercase();
match (host_kind, claim.shape) {
// Strict route under exact claim: must match exactly.
(HostKind::Strict, picloud_shared::DomainShape::Exact) => {
if host_lower == claim_lower {
return Ok(());
}
}
// Strict route under wildcard/parameterized: must end with
// ".<suffix>" where the claim's suffix is the part after
// `*.` or `{...}.`.
(
HostKind::Strict,
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
) => {
let suffix = claim_lower
.split_once('.')
.map(|(_, s)| s.to_string())
.unwrap_or_default();
let needle = format!(".{suffix}");
if !suffix.is_empty() && host_lower.ends_with(&needle) {
return Ok(());
}
}
// Wildcard route: must match a wildcard or parameterized
// claim with identical suffix.
(
HostKind::Wildcard,
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
) => {
let claim_suffix = claim_lower
.split_once('.')
.map(|(_, s)| s.to_string())
.unwrap_or_default();
if claim_suffix == host_lower {
return Ok(());
}
}
// Wildcard route under exact claim: not allowed (would
// shadow other apps' subdomains the operator didn't claim).
(HostKind::Wildcard, picloud_shared::DomainShape::Exact) => {}
(HostKind::Any, _) => unreachable!("handled above"),
}
}
Err(RouteApiError::HostNotClaimed {
host: host.to_string(),
available_claims: claims.into_iter().map(|c| c.pattern).collect(),
})
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Errors // Errors
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -304,6 +412,15 @@ pub enum RouteApiError {
#[error("bad request: {0}")] #[error("bad request: {0}")]
BadRequest(String), BadRequest(String),
#[error("script not found: {0}")]
ScriptNotFound(ScriptId),
#[error("host {host:?} is not claimed by this app")]
HostNotClaimed {
host: String,
available_claims: Vec<String>,
},
#[error("repository error: {0}")] #[error("repository error: {0}")]
Repo(#[from] ScriptRepositoryError), Repo(#[from] ScriptRepositoryError),
} }
@@ -326,10 +443,21 @@ impl IntoResponse for RouteApiError {
StatusCode::UNPROCESSABLE_ENTITY, StatusCode::UNPROCESSABLE_ENTITY,
serde_json::json!({ "error": self.to_string() }), serde_json::json!({ "error": self.to_string() }),
), ),
Self::Repo(ScriptRepositoryError::NotFound(_)) => ( Self::ScriptNotFound(_) | Self::Repo(ScriptRepositoryError::NotFound(_)) => (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
serde_json::json!({ "error": self.to_string() }), serde_json::json!({ "error": self.to_string() }),
), ),
Self::HostNotClaimed {
host,
available_claims,
} => (
StatusCode::UNPROCESSABLE_ENTITY,
serde_json::json!({
"error": self.to_string(),
"host": host,
"available_claims": available_claims,
}),
),
Self::Repo(ScriptRepositoryError::Conflict(_)) => ( Self::Repo(ScriptRepositoryError::Conflict(_)) => (
StatusCode::CONFLICT, StatusCode::CONFLICT,
serde_json::json!({ "error": self.to_string() }), serde_json::json!({ "error": self.to_string() }),

View File

@@ -1,10 +1,10 @@
//! CRUD over the `routes` table. //! CRUD over the `routes` table.
//! //!
//! The orchestrator's `RouteTable` is repopulated from this repo after //! The orchestrator's `AppRouteTables` is repopulated from this repo
//! every write — see the route_admin module for the binding. //! after every write — see the route_admin module for the binding.
use async_trait::async_trait; use async_trait::async_trait;
use picloud_shared::{HostKind, PathKind, Route, ScriptId}; use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
@@ -12,6 +12,7 @@ use crate::repo::ScriptRepositoryError;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NewRoute { pub struct NewRoute {
pub app_id: AppId,
pub script_id: ScriptId, pub script_id: ScriptId,
pub host_kind: HostKind, pub host_kind: HostKind,
pub host: String, pub host: String,
@@ -24,12 +25,21 @@ pub struct NewRoute {
#[async_trait] #[async_trait]
pub trait RouteRepository: Send + Sync { pub trait RouteRepository: Send + Sync {
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>; async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError>;
async fn list_for_script( async fn list_for_script(
&self, &self,
script_id: ScriptId, script_id: ScriptId,
) -> Result<Vec<Route>, ScriptRepositoryError>; ) -> Result<Vec<Route>, ScriptRepositoryError>;
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError>; async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError>;
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError>; async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError>;
/// Count routes whose host_kind/host pair matches a pattern in
/// `app_id`. Used by the domain-claim delete guard.
async fn count_for_app_host(
&self,
app_id: AppId,
host_kind: HostKind,
host: &str,
) -> Result<i64, ScriptRepositoryError>;
} }
pub struct PostgresRouteRepository { pub struct PostgresRouteRepository {
@@ -47,7 +57,7 @@ impl PostgresRouteRepository {
impl RouteRepository for PostgresRouteRepository { impl RouteRepository for PostgresRouteRepository {
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> { async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>( let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, script_id, host_kind, host, host_param_name, \ "SELECT id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, created_at \ path_kind, path, method, created_at \
FROM routes ORDER BY created_at", FROM routes ORDER BY created_at",
) )
@@ -56,12 +66,24 @@ impl RouteRepository for PostgresRouteRepository {
Ok(rows.into_iter().map(Into::into).collect()) Ok(rows.into_iter().map(Into::into).collect())
} }
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, created_at \
FROM routes WHERE app_id = $1 ORDER BY created_at",
)
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_for_script( async fn list_for_script(
&self, &self,
script_id: ScriptId, script_id: ScriptId,
) -> Result<Vec<Route>, ScriptRepositoryError> { ) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>( let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, script_id, host_kind, host, host_param_name, \ "SELECT id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, created_at \ path_kind, path, method, created_at \
FROM routes WHERE script_id = $1 ORDER BY created_at", FROM routes WHERE script_id = $1 ORDER BY created_at",
) )
@@ -74,12 +96,13 @@ impl RouteRepository for PostgresRouteRepository {
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> { async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
let res = sqlx::query_as::<_, RouteRow>( let res = sqlx::query_as::<_, RouteRow>(
"INSERT INTO routes ( \ "INSERT INTO routes ( \
script_id, host_kind, host, host_param_name, \ app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method \ path_kind, path, method \
) VALUES ($1, $2, $3, $4, $5, $6, $7) \ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
RETURNING id, script_id, host_kind, host, host_param_name, \ RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, created_at", path_kind, path, method, created_at",
) )
.bind(input.app_id.into_inner())
.bind(input.script_id.into_inner()) .bind(input.script_id.into_inner())
.bind(host_kind_str(input.host_kind)) .bind(host_kind_str(input.host_kind))
.bind(&input.host) .bind(&input.host)
@@ -112,6 +135,24 @@ impl RouteRepository for PostgresRouteRepository {
} }
Ok(()) Ok(())
} }
async fn count_for_app_host(
&self,
app_id: AppId,
host_kind: HostKind,
host: &str,
) -> Result<i64, ScriptRepositoryError> {
let count: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM routes \
WHERE app_id = $1 AND host_kind = $2 AND host = $3",
)
.bind(app_id.into_inner())
.bind(host_kind_str(host_kind))
.bind(host)
.fetch_one(&self.pool)
.await?;
Ok(count.0)
}
} }
const fn host_kind_str(k: HostKind) -> &'static str { const fn host_kind_str(k: HostKind) -> &'static str {
@@ -133,6 +174,7 @@ const fn path_kind_str(k: PathKind) -> &'static str {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct RouteRow { struct RouteRow {
id: Uuid, id: Uuid,
app_id: Uuid,
script_id: Uuid, script_id: Uuid,
host_kind: String, host_kind: String,
host: String, host: String,
@@ -147,6 +189,7 @@ impl From<RouteRow> for Route {
fn from(r: RouteRow) -> Self { fn from(r: RouteRow) -> Self {
Self { Self {
id: r.id, id: r.id,
app_id: r.app_id.into(),
script_id: r.script_id.into(), script_id: r.script_id.into(),
host_kind: match r.host_kind.as_str() { host_kind: match r.host_kind.as_str() {
"strict" => HostKind::Strict, "strict" => HostKind::Strict,

View File

@@ -3,6 +3,43 @@
## tables ## tables
table: admin_sessions
token_hash: text NOT NULL
user_id: uuid NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
expires_at: timestamp with time zone NOT NULL
last_used_at: timestamp with time zone NOT NULL default=now()
table: admin_users
id: uuid NOT NULL default=gen_random_uuid()
username: text NOT NULL
password_hash: text NOT NULL
is_active: boolean NOT NULL default=true
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
last_login_at: timestamp with time zone NULL
table: app_domains
id: uuid NOT NULL default=gen_random_uuid()
app_id: uuid NOT NULL
pattern: text NOT NULL
shape: text NOT NULL
shape_key: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
table: app_slug_history
slug: text NOT NULL
current_app_id: uuid NOT NULL
retired_at: timestamp with time zone NOT NULL default=now()
table: apps
id: uuid NOT NULL default=gen_random_uuid()
slug: text NOT NULL
name: text NOT NULL
description: text NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: execution_logs table: execution_logs
id: uuid NOT NULL default=gen_random_uuid() id: uuid NOT NULL default=gen_random_uuid()
script_id: uuid NOT NULL script_id: uuid NOT NULL
@@ -16,6 +53,7 @@ table: execution_logs
duration_ms: integer NOT NULL default=0 duration_ms: integer NOT NULL default=0
status: text NOT NULL status: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now() created_at: timestamp with time zone NOT NULL default=now()
app_id: uuid NOT NULL
table: routes table: routes
id: uuid NOT NULL default=gen_random_uuid() id: uuid NOT NULL default=gen_random_uuid()
@@ -27,6 +65,7 @@ table: routes
path: text NOT NULL path: text NOT NULL
method: text NULL method: text NULL
created_at: timestamp with time zone NOT NULL default=now() created_at: timestamp with time zone NOT NULL default=now()
app_id: uuid NOT NULL
table: scripts table: scripts
id: uuid NOT NULL default=gen_random_uuid() id: uuid NOT NULL default=gen_random_uuid()
@@ -39,42 +78,94 @@ table: scripts
created_at: timestamp with time zone NOT NULL default=now() created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now() updated_at: timestamp with time zone NOT NULL default=now()
sandbox: jsonb NOT NULL default='{}'::jsonb sandbox: jsonb NOT NULL default='{}'::jsonb
app_id: uuid NOT NULL
## indexes ## indexes
indexes on admin_sessions:
admin_sessions_expiry_idx: public.admin_sessions USING btree (expires_at)
admin_sessions_pkey: public.admin_sessions USING btree (token_hash)
admin_sessions_user_idx: public.admin_sessions USING btree (user_id)
indexes on admin_users:
admin_users_pkey: public.admin_users USING btree (id)
admin_users_username_key: public.admin_users USING btree (username)
indexes on app_domains:
app_domains_app_id_idx: public.app_domains USING btree (app_id)
app_domains_pkey: public.app_domains USING btree (id)
app_domains_shape_key_key: public.app_domains USING btree (shape_key)
indexes on app_slug_history:
app_slug_history_pkey: public.app_slug_history USING btree (slug)
indexes on apps:
apps_pkey: public.apps USING btree (id)
apps_slug_key: public.apps USING btree (slug)
indexes on execution_logs: indexes on execution_logs:
execution_logs_app_id_created_at_idx: public.execution_logs USING btree (app_id, created_at DESC)
execution_logs_pkey: public.execution_logs USING btree (id) execution_logs_pkey: public.execution_logs USING btree (id)
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC) execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
indexes on routes: indexes on routes:
routes_app_id_idx: public.routes USING btree (app_id)
routes_lookup_idx: public.routes USING btree (host_kind, host) routes_lookup_idx: public.routes USING btree (host_kind, host)
routes_pkey: public.routes USING btree (id) routes_pkey: public.routes USING btree (id)
routes_script_id_idx: public.routes USING btree (script_id) routes_script_id_idx: public.routes USING btree (script_id)
routes_unique_binding_idx: public.routes USING btree (host_kind, host, path_kind, path, COALESCE(method, ''::text)) routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text))
indexes on scripts: indexes on scripts:
scripts_name_uidx: public.scripts USING btree (lower(name)) scripts_app_id_idx: public.scripts USING btree (app_id)
scripts_name_uidx: public.scripts USING btree (app_id, lower(name))
scripts_pkey: public.scripts USING btree (id) scripts_pkey: public.scripts USING btree (id)
## constraints ## constraints
constraints on admin_sessions:
[FOREIGN KEY] admin_sessions_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
[PRIMARY KEY] admin_sessions_pkey: PRIMARY KEY (token_hash)
constraints on admin_users:
[PRIMARY KEY] admin_users_pkey: PRIMARY KEY (id)
[UNIQUE] admin_users_username_key: UNIQUE (username)
constraints on app_domains:
[CHECK] app_domains_shape_check: CHECK ((shape = ANY (ARRAY['exact'::text, 'wildcard'::text, 'parameterized'::text])))
[FOREIGN KEY] app_domains_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] app_domains_pkey: PRIMARY KEY (id)
[UNIQUE] app_domains_shape_key_key: UNIQUE (shape_key)
constraints on app_slug_history:
[FOREIGN KEY] app_slug_history_current_app_id_fkey: FOREIGN KEY (current_app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] app_slug_history_pkey: PRIMARY KEY (slug)
constraints on apps:
[PRIMARY KEY] apps_pkey: PRIMARY KEY (id)
[UNIQUE] apps_slug_key: UNIQUE (slug)
constraints on execution_logs: constraints on execution_logs:
[CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text]))) [CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text])))
[FOREIGN KEY] execution_logs_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE [FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id) [PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
constraints on routes: constraints on routes:
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text]))) [CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
[CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text]))) [CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
[FOREIGN KEY] routes_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE [FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id) [PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
constraints on scripts: constraints on scripts:
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048))) [CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300))) [CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id) [PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
## applied migrations ## applied migrations
0001: init 0001: init
0002: sandbox 0002: sandbox
0003: routes 0003: routes
0004: admin auth
0005: apps

View File

@@ -17,22 +17,26 @@ use axum::{
use chrono::Utc; use chrono::Utc;
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType}; use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
use picloud_shared::{ use picloud_shared::{
ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId, AppId, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
}; };
use serde_json::Value as Json_; use serde_json::Value as Json_;
use uuid::Uuid; use uuid::Uuid;
use crate::client::ExecutorClient; use crate::client::ExecutorClient;
use crate::resolver::{ResolverError, ScriptResolver}; use crate::resolver::{ResolverError, ScriptResolver};
use crate::routing::RouteTable; use crate::routing::{AppDomainTable, RouteTable};
/// State shared by data-plane handlers. /// State shared by data-plane handlers.
pub struct DataPlaneState<E, R> { pub struct DataPlaneState<E, R> {
pub executor: Arc<E>, pub executor: Arc<E>,
pub resolver: Arc<R>, pub resolver: Arc<R>,
pub log_sink: Arc<dyn ExecutionLogSink>, pub log_sink: Arc<dyn ExecutionLogSink>,
/// Routing table for user-defined paths. Shared with the manager /// Host → app_id resolver. Run before `routes` to filter to the
/// (admin router writes; this side reads). /// owning app's slice. Shared with the manager (writes invalidate
/// the cache by replacing the table).
pub app_domains: Arc<AppDomainTable>,
/// Routing table for user-defined paths, partitioned per app.
/// Shared with the manager (admin router writes; this side reads).
pub routes: Arc<RouteTable>, pub routes: Arc<RouteTable>,
} }
@@ -42,6 +46,7 @@ impl<E, R> Clone for DataPlaneState<E, R> {
executor: self.executor.clone(), executor: self.executor.clone(),
resolver: self.resolver.clone(), resolver: self.resolver.clone(),
log_sink: self.log_sink.clone(), log_sink: self.log_sink.clone(),
app_domains: self.app_domains.clone(),
routes: self.routes.clone(), routes: self.routes.clone(),
} }
} }
@@ -109,6 +114,7 @@ where
// audit-visible platform — but a sink failure must not mask the // audit-visible platform — but a sink failure must not mask the
// user-facing result, so we only log a warning if it fails. // user-facing result, so we only log a warning if it fails.
let log = build_execution_log( let log = build_execution_log(
script.app_id,
id, id,
request_id, request_id,
request_path, request_path,
@@ -145,7 +151,23 @@ where
.to_string(); .to_string();
let headers = request.headers().clone(); let headers = request.headers().clone();
let Some(matched) = state.routes.match_request(&host, &method, &path) else { // Two-phase dispatch (blueprint §11.5): first resolve Host → app_id,
// then run the existing matcher on that app's slice. No app claims
// this host → flat 404; the path doesn't get the chance to fire.
let Some(app_id) = state.app_domains.resolve_app(&host) else {
return Ok((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": format!("no app claims host {host:?}")
})),
)
.into_response());
};
let Some(matched) = state
.routes
.match_request_for_app(app_id, &host, &method, &path)
else {
return Ok(( return Ok((
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
Json(serde_json::json!({ Json(serde_json::json!({
@@ -191,6 +213,7 @@ where
let finished = Utc::now(); let finished = Utc::now();
let log = build_execution_log( let log = build_execution_log(
script.app_id,
matched.matched.script_id, matched.matched.script_id,
request_id, request_id,
request_path, request_path,
@@ -292,6 +315,7 @@ fn exec_response_to_http(resp: ExecResponse) -> Response {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn build_execution_log( fn build_execution_log(
app_id: AppId,
script_id: ScriptId, script_id: ScriptId,
request_id: RequestId, request_id: RequestId,
request_path: String, request_path: String,
@@ -336,6 +360,7 @@ fn build_execution_log(
ExecutionLog { ExecutionLog {
id: Uuid::new_v4(), id: Uuid::new_v4(),
app_id,
script_id, script_id,
request_id, request_id,
request_path, request_path,

View File

@@ -0,0 +1,165 @@
//! Host → app_id resolver. The first phase of the orchestrator's
//! two-phase dispatch (the second phase is the per-app route matcher
//! in `routing::table::RouteTable`).
//!
//! Cached in memory; the manager rebuilds the table after each
//! domain-claim CRUD operation (same pattern as `RouteTable`).
use std::sync::RwLock;
use picloud_shared::AppId;
use super::pattern::{HostPattern, HostSpecificity};
/// A parsed domain claim ready for runtime matching.
#[derive(Debug, Clone)]
pub struct CompiledAppDomain {
pub app_id: AppId,
pub pattern: HostPattern,
pub shape_key: String,
}
#[derive(Default)]
pub struct AppDomainTable {
inner: RwLock<Vec<CompiledAppDomain>>,
}
impl AppDomainTable {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Atomic full replacement; called at startup and after every
/// domain CRUD operation.
pub fn replace(&self, domains: Vec<CompiledAppDomain>) {
let mut guard = self.inner.write().expect("app domain table poisoned");
*guard = domains;
}
/// Resolve a request's `Host` header to an `AppId`. Most-specific
/// claim wins: exact > longest wildcard > shorter wildcard. Returns
/// `None` when no claim covers `host` (orchestrator should 404).
#[must_use]
pub fn resolve_app(&self, host: &str) -> Option<AppId> {
let host = strip_port(host).to_ascii_lowercase();
let guard = self.inner.read().expect("app domain table poisoned");
let mut best: Option<(HostSpecificity, AppId)> = None;
for claim in guard.iter() {
if let Some(()) = host_matches(&claim.pattern, &host) {
let s = claim.pattern.specificity();
if best.is_none_or(|(prev, _)| s > prev) {
best = Some((s, claim.app_id));
}
}
}
best.map(|(_, app_id)| app_id)
}
#[must_use]
pub fn snapshot(&self) -> Vec<CompiledAppDomain> {
self.inner
.read()
.expect("app domain table poisoned")
.clone()
}
}
fn strip_port(host: &str) -> &str {
host.split(':').next().unwrap_or(host)
}
fn host_matches(pattern: &HostPattern, host: &str) -> Option<()> {
match pattern {
HostPattern::Any => Some(()),
HostPattern::Strict(s) => {
if s.eq_ignore_ascii_case(host) {
Some(())
} else {
None
}
}
HostPattern::Wildcard { suffix, .. } => {
let dotted = format!(".{}", suffix.to_ascii_lowercase());
host.strip_suffix(&dotted)
.filter(|p| !p.is_empty())
.map(|_| ())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::routing::pattern::parse_app_domain;
use uuid::Uuid;
fn id() -> AppId {
AppId::from(Uuid::new_v4())
}
fn compile(app_id: AppId, raw: &str) -> CompiledAppDomain {
let d = parse_app_domain(raw).unwrap();
CompiledAppDomain {
app_id,
pattern: d.pattern,
shape_key: d.shape_key,
}
}
#[test]
fn resolves_exact_over_wildcard() {
let app_a = id();
let app_b = id();
let table = AppDomainTable::new();
table.replace(vec![
compile(app_a, "foo.example.com"),
compile(app_b, "*.example.com"),
]);
assert_eq!(table.resolve_app("foo.example.com"), Some(app_a));
assert_eq!(table.resolve_app("bar.example.com"), Some(app_b));
}
#[test]
fn longer_wildcard_beats_shorter() {
let inner = id();
let outer = id();
let table = AppDomainTable::new();
table.replace(vec![
compile(inner, "*.api.example.com"),
compile(outer, "*.example.com"),
]);
assert_eq!(
table.resolve_app("v1.api.example.com"),
Some(inner),
"more-specific wildcard should win"
);
assert_eq!(table.resolve_app("v1.example.com"), Some(outer));
}
#[test]
fn parameterized_resolves_like_wildcard() {
let app = id();
let table = AppDomainTable::new();
table.replace(vec![compile(app, "{tenant}.example.com")]);
assert_eq!(table.resolve_app("acme.example.com"), Some(app));
assert!(table.resolve_app("example.com").is_none());
}
#[test]
fn returns_none_when_no_claim() {
let app = id();
let table = AppDomainTable::new();
table.replace(vec![compile(app, "foo.example.com")]);
assert!(table.resolve_app("nope.com").is_none());
assert!(table.resolve_app("").is_none());
}
#[test]
fn strips_port() {
let app = id();
let table = AppDomainTable::new();
table.replace(vec![compile(app, "localhost")]);
assert_eq!(table.resolve_app("localhost:18080"), Some(app));
}
}

View File

@@ -40,10 +40,13 @@ pub struct Matched {
pub script_id: picloud_shared::ScriptId, pub script_id: picloud_shared::ScriptId,
} }
/// A single route ready for matching. /// A single route ready for matching. `app_id` is carried so the
/// caller (the orchestrator's `AppRouteTables`) can partition the
/// table; the matcher itself doesn't read it.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CompiledRoute { pub struct CompiledRoute {
pub route_id: uuid::Uuid, pub route_id: uuid::Uuid,
pub app_id: picloud_shared::AppId,
pub script_id: picloud_shared::ScriptId, pub script_id: picloud_shared::ScriptId,
pub host: HostPattern, pub host: HostPattern,
pub path: PathPattern, pub path: PathPattern,
@@ -298,12 +301,13 @@ fn match_param(segs: &[PathSegment], request_path: &str) -> Option<BTreeMap<Stri
mod tests { mod tests {
use super::super::pattern::parse_path; use super::super::pattern::parse_path;
use super::*; use super::*;
use picloud_shared::{PathKind, ScriptId}; use picloud_shared::{AppId, PathKind, ScriptId};
use uuid::Uuid; use uuid::Uuid;
fn route(host: HostPattern, path_kind: PathKind, raw: &str) -> CompiledRoute { fn route(host: HostPattern, path_kind: PathKind, raw: &str) -> CompiledRoute {
CompiledRoute { CompiledRoute {
route_id: Uuid::new_v4(), route_id: Uuid::new_v4(),
app_id: AppId::new(),
script_id: ScriptId::new(), script_id: ScriptId::new(),
host, host,
path: parse_path(path_kind, raw).unwrap(), path: parse_path(path_kind, raw).unwrap(),

View File

@@ -17,12 +17,16 @@
//! * **Host dispatch** — `strict > wildcard > any`; longest matching //! * **Host dispatch** — `strict > wildcard > any`; longest matching
//! wildcard suffix breaks ties between wildcards. //! wildcard suffix breaks ties between wildcards.
pub mod app_domains;
pub mod conflict; pub mod conflict;
pub mod matcher; pub mod matcher;
pub mod pattern; pub mod pattern;
pub mod table; pub mod table;
pub use app_domains::{AppDomainTable, CompiledAppDomain};
pub use conflict::{conflicts, ConflictReason}; pub use conflict::{conflicts, ConflictReason};
pub use matcher::{MatchResult, Matched}; pub use matcher::{MatchResult, Matched};
pub use pattern::{HostPattern, ParseError, PathPattern, PathSegment}; pub use pattern::{
parse_app_domain, HostPattern, ParseError, ParsedAppDomain, PathPattern, PathSegment,
};
pub use table::RouteTable; pub use table::RouteTable;

View File

@@ -251,6 +251,106 @@ pub fn parse_host(
} }
} }
// ----------------------------------------------------------------------------
// App-domain patterns
// ----------------------------------------------------------------------------
use picloud_shared::DomainShape;
/// Result of parsing a user-supplied app domain claim. Carries the
/// host pattern (used at request time), the shape (used at write time
/// for collision checks), and the normalized shape_key.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedAppDomain {
pub pattern: HostPattern,
pub shape: DomainShape,
/// Collision key: `"exact:<host>"` for exact; `"wildcard:<suffix>"`
/// for both wildcard AND parameterized — they share a shape per
/// blueprint §11.5 ("`{tenant}` has the same shape as `*` for this
/// check").
pub shape_key: String,
/// Captured binding name for parameterized claims, e.g., `Some("tenant")`
/// for `{tenant}.example.com`. Currently informational; the binding
/// is surfaced into request context in a future iteration.
pub binding: Option<String>,
}
/// Parse a user-supplied app domain claim. Accepts:
/// * `app.example.com` — exact host
/// * `*.example.com` — wildcard suffix
/// * `{tenant}.example.com` — parameterized; same shape as wildcard
///
/// Distinct from `parse_host` (which is for route host fields): the
/// route parser still rejects `{...}` syntax — see
/// `ParseError::ReservedHostBraceSyntax`.
pub fn parse_app_domain(raw: &str) -> Result<ParsedAppDomain, ParseError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(ParseError::EmptyHost);
}
let lowered = trimmed.to_ascii_lowercase();
// Wildcard: starts with "*."
if let Some(suffix) = lowered.strip_prefix("*.") {
if suffix.is_empty() {
return Err(ParseError::EmptyWildcardSuffix);
}
return Ok(ParsedAppDomain {
pattern: HostPattern::Wildcard {
suffix: suffix.to_string(),
capture: None,
},
shape: DomainShape::Wildcard,
shape_key: format!("wildcard:{suffix}"),
binding: None,
});
}
// Parameterized: starts with "{name}." where `name` is an ident.
if let Some(stripped) = lowered.strip_prefix('{') {
let (binding, rest) = stripped
.split_once('}')
.ok_or(ParseError::ReservedHostBraceSyntax)?;
if binding.is_empty()
|| !binding
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|| !binding.chars().next().unwrap().is_ascii_alphabetic()
{
return Err(ParseError::InvalidParamName(binding.to_string()));
}
let suffix = rest
.strip_prefix('.')
.ok_or(ParseError::ReservedHostBraceSyntax)?;
if suffix.is_empty() || suffix.contains('{') || suffix.contains('}') {
return Err(ParseError::ReservedHostBraceSyntax);
}
return Ok(ParsedAppDomain {
pattern: HostPattern::Wildcard {
suffix: suffix.to_string(),
capture: Some(binding.to_string()),
},
shape: DomainShape::Parameterized,
// Same shape_key as the equivalent wildcard — parameter
// name is a binding, not a discriminator.
shape_key: format!("wildcard:{suffix}"),
binding: Some(binding.to_string()),
});
}
// Anything else: exact host. Reject braces anywhere in the body
// (they'd be a malformed parameterized form).
if lowered.contains('{') || lowered.contains('}') {
return Err(ParseError::ReservedHostBraceSyntax);
}
Ok(ParsedAppDomain {
pattern: HostPattern::Strict(lowered.clone()),
shape: DomainShape::Exact,
shape_key: format!("exact:{lowered}"),
binding: None,
})
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Tests // Tests
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -393,6 +493,49 @@ mod tests {
assert_eq!(e, ParseError::ReservedHostBraceSyntax); assert_eq!(e, ParseError::ReservedHostBraceSyntax);
} }
#[test]
fn parse_app_domain_exact() {
let d = parse_app_domain("App.Example.COM").unwrap();
assert_eq!(d.shape, DomainShape::Exact);
assert_eq!(d.shape_key, "exact:app.example.com");
assert_eq!(d.pattern, HostPattern::Strict("app.example.com".into()));
assert!(d.binding.is_none());
}
#[test]
fn parse_app_domain_wildcard_and_parameterized_share_shape_key() {
let w = parse_app_domain("*.example.com").unwrap();
let p = parse_app_domain("{tenant}.example.com").unwrap();
assert_eq!(w.shape, DomainShape::Wildcard);
assert_eq!(p.shape, DomainShape::Parameterized);
// Same shape_key — they collide at claim time (blueprint §11.5).
assert_eq!(w.shape_key, "wildcard:example.com");
assert_eq!(p.shape_key, "wildcard:example.com");
assert_eq!(p.binding.as_deref(), Some("tenant"));
}
#[test]
fn parse_app_domain_rejects_garbage() {
assert!(matches!(parse_app_domain(""), Err(ParseError::EmptyHost)));
assert!(matches!(
parse_app_domain("*."),
Err(ParseError::EmptyWildcardSuffix)
));
assert!(matches!(
parse_app_domain("{}.example.com"),
Err(ParseError::InvalidParamName(_))
));
assert!(matches!(
parse_app_domain("{1tenant}.example.com"),
Err(ParseError::InvalidParamName(_))
));
// Mid-host braces — disallowed.
assert!(matches!(
parse_app_domain("foo.{tenant}.example.com"),
Err(ParseError::ReservedHostBraceSyntax)
));
}
#[test] #[test]
fn leading_literal_count_works() { fn leading_literal_count_works() {
let exact = parse_path(PathKind::Exact, "/foo/users").unwrap(); let exact = parse_path(PathKind::Exact, "/foo/users").unwrap();

View File

@@ -1,17 +1,22 @@
//! In-memory snapshot of compiled routes, shared by manager (writes) //! In-memory snapshot of compiled routes, partitioned by `app_id`.
//! and orchestrator (reads).
//! //!
//! Holds an `arc-swap`-style lock-free hand-off so the dispatcher can //! The orchestrator looks up the app's slice by id after `AppDomainTable`
//! read without contending against the writer; in MVP-single-process //! has resolved Host → app_id, then runs the existing matcher on that
//! we just use `RwLock` and accept the cheap contention. //! slice. The matcher is unchanged; this type is just a per-app bucket.
use std::collections::HashMap;
use std::sync::RwLock; use std::sync::RwLock;
use picloud_shared::AppId;
use super::matcher::{r#match, CompiledRoute, MatchResult}; use super::matcher::{r#match, CompiledRoute, MatchResult};
/// Per-app compiled-route tables. Single MVP-mode writer (the manager,
/// via `replace_all`); contention against readers is minimal so a plain
/// `RwLock` is fine.
#[derive(Default)] #[derive(Default)]
pub struct RouteTable { pub struct RouteTable {
inner: RwLock<Vec<CompiledRoute>>, inner: RwLock<HashMap<AppId, Vec<CompiledRoute>>>,
} }
impl RouteTable { impl RouteTable {
@@ -20,24 +25,54 @@ impl RouteTable {
Self::default() Self::default()
} }
/// Replace the whole table atomically. The manager calls this after /// Replace every per-app slice atomically. The manager calls this
/// each successful route CRUD operation (by re-reading from DB). /// after each successful route CRUD operation; in cluster mode the
pub fn replace(&self, routes: Vec<CompiledRoute>) { /// orchestrator's HTTP-fed receiver will too.
pub fn replace_all(&self, routes: Vec<CompiledRoute>) {
let mut by_app: HashMap<AppId, Vec<CompiledRoute>> = HashMap::new();
for r in routes {
by_app.entry(r.app_id).or_default().push(r);
}
let mut guard = self.inner.write().expect("route table poisoned"); let mut guard = self.inner.write().expect("route table poisoned");
*guard = routes; *guard = by_app;
} }
/// Dispatch a request to a matching route, or `None`. /// Dispatch a request to a matching route within `app_id`, or
/// `None`. Returns `None` when the app has no routes at all.
#[must_use] #[must_use]
pub fn match_request(&self, host: &str, method: &str, path: &str) -> Option<MatchResult> { pub fn match_request_for_app(
&self,
app_id: AppId,
host: &str,
method: &str,
path: &str,
) -> Option<MatchResult> {
let guard = self.inner.read().expect("route table poisoned"); let guard = self.inner.read().expect("route table poisoned");
r#match(guard.iter(), host, method, path) let slice = guard.get(&app_id)?;
r#match(slice.iter(), host, method, path)
} }
/// Returns a clone of the currently compiled routes; intended for /// Returns a clone of the currently compiled routes for `app_id`;
/// the dashboard's "list routes" admin endpoint. /// intended for admin endpoints like "list this app's routes".
#[must_use] #[must_use]
pub fn snapshot(&self) -> Vec<CompiledRoute> { pub fn snapshot_for_app(&self, app_id: AppId) -> Vec<CompiledRoute> {
self.inner.read().expect("route table poisoned").clone() self.inner
.read()
.expect("route table poisoned")
.get(&app_id)
.cloned()
.unwrap_or_default()
}
/// All compiled routes across all apps. Used by tests and the
/// global admin "every route on this install" view.
#[must_use]
pub fn snapshot_all(&self) -> Vec<CompiledRoute> {
self.inner
.read()
.expect("route table poisoned")
.values()
.flat_map(|v| v.iter().cloned())
.collect()
} }
} }

View File

@@ -6,14 +6,19 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use axum::middleware::from_fn_with_state;
use axum::{routing::get, Json, Router}; use axum::{routing::get, Json, Router};
use picloud_executor_core::{Engine, Limits}; use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{ use picloud_manager_core::{
admin_router, compile_routes, migrations, route_admin_router, AdminState, admin_router, admins_router, apps_api, apps_router, auth_router, compile_routes, migrations,
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository, require_admin, route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository,
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling, AdminsState, AppDomainRepository, AppRepository, AppsState, AuthState,
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresAppDomainRepository,
PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink,
PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState,
RouteRepository, SandboxCeiling,
}; };
use picloud_orchestrator_core::routing::RouteTable; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
use picloud_orchestrator_core::{ use picloud_orchestrator_core::{
data_plane_router, user_routes_router, DataPlaneState, LocalExecutorClient, data_plane_router, user_routes_router, DataPlaneState, LocalExecutorClient,
}; };
@@ -24,6 +29,38 @@ use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool; use sqlx::PgPool;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
/// Default session TTL when `PICLOUD_SESSION_TTL_HOURS` isn't set.
const DEFAULT_SESSION_TTL_HOURS: u64 = 24;
/// Bundles the auth-related dependencies that both `build_app` and the
/// startup bootstrap need. Built once in `main.rs` from the shared pool.
pub struct AuthDeps {
pub users: Arc<dyn AdminUserRepository>,
pub sessions: Arc<dyn AdminSessionRepository>,
pub ttl: Duration,
}
impl AuthDeps {
/// Construct from a pool with the binary's standard defaults.
#[must_use]
pub fn from_pool(pool: PgPool) -> Self {
Self {
users: Arc::new(PostgresAdminUserRepository::new(pool.clone())),
sessions: Arc::new(PostgresAdminSessionRepository::new(pool)),
ttl: read_session_ttl(),
}
}
}
fn read_session_ttl() -> Duration {
let hours = std::env::var("PICLOUD_SESSION_TTL_HOURS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.filter(|h| *h > 0)
.unwrap_or(DEFAULT_SESSION_TTL_HOURS);
Duration::from_secs(hours * 3600)
}
/// Compose the manager + orchestrator routes on top of a shared /// Compose the manager + orchestrator routes on top of a shared
/// Postgres pool, returning an Axum router ready to be served. /// Postgres pool, returning an Axum router ready to be served.
/// ///
@@ -31,20 +68,48 @@ use tower_http::trace::TraceLayer;
/// is mounted by Caddy at `/admin/*` (its base path). Anything else /// is mounted by Caddy at `/admin/*` (its base path). Anything else
/// falls through to the user-route table — user scripts can bind to /// falls through to the user-route table — user scripts can bind to
/// arbitrary paths (subject to the reserved-prefix list). /// arbitrary paths (subject to the reserved-prefix list).
pub async fn build_app(pool: PgPool) -> anyhow::Result<Router> { ///
/// `auth` carries the admin user/session repositories and the
/// configured session TTL. The manager-side admin endpoints
/// (`/api/v1/admin/scripts/*`, `/api/v1/admin/routes/*`,
/// `/api/v1/admin/admins/*`, `/api/v1/admin/auth/me`) are guarded by
/// the `require_admin` middleware. The data plane
/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`,
/// `/version`) stays open — it's the public ingress for user scripts.
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
let engine = Arc::new(Engine::new(Limits::default())); let engine = Arc::new(Engine::new(Limits::default()));
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone())); let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone())); let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone())); let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
let route_repo = Arc::new(PostgresRouteRepository::new(pool)); let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
let domains_repo: Arc<dyn AppDomainRepository> =
Arc::new(PostgresAppDomainRepository::new(pool));
// Compile the routes table once at startup; admin writes refresh it. // Compile the routes table once at startup; admin writes refresh it.
let route_table = Arc::new(RouteTable::new()); let route_table = Arc::new(RouteTable::new());
let initial = route_repo.list_all().await?; let initial = route_repo.list_all().await?;
let compiled = compile_routes(&initial) let compiled = compile_routes(&initial)
.map_err(|e| anyhow::anyhow!("failed to compile stored routes: {e}"))?; .map_err(|e| anyhow::anyhow!("failed to compile stored routes: {e}"))?;
route_table.replace(compiled); route_table.replace_all(compiled);
// Same shape for app domains (Host → app_id cache).
let app_domain_table = Arc::new(AppDomainTable::new());
let initial_domains = domains_repo.list_all().await?;
let compiled_domains: Vec<_> = initial_domains
.iter()
.filter_map(|d| {
picloud_orchestrator_core::routing::parse_app_domain(&d.pattern)
.ok()
.map(|p| picloud_orchestrator_core::routing::CompiledAppDomain {
app_id: d.app_id,
pattern: p.pattern,
shape_key: p.shape_key,
})
})
.collect();
app_domain_table.replace(compiled_domains);
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle( let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
script_repo.clone(), script_repo.clone(),
@@ -52,25 +117,60 @@ pub async fn build_app(pool: PgPool) -> anyhow::Result<Router> {
let executor = Arc::new(LocalExecutorClient::new(engine.clone())); let executor = Arc::new(LocalExecutorClient::new(engine.clone()));
let admin = AdminState { let admin = AdminState {
repo: Arc::new(PostgresScriptRepoHandle(script_repo)), repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
logs: log_repo, logs: log_repo,
apps: apps_repo.clone(),
validator: engine as Arc<dyn ScriptValidator>, validator: engine as Arc<dyn ScriptValidator>,
sandbox_ceiling: SandboxCeiling::from_env(), sandbox_ceiling: SandboxCeiling::from_env(),
}; };
let route_admin = RouteAdminState { let route_admin = RouteAdminState {
routes: route_repo, routes: route_repo.clone(),
scripts: Arc::new(PostgresScriptRepoHandle(script_repo)),
domains: domains_repo.clone(),
table: route_table.clone(), table: route_table.clone(),
}; };
let data_plane = DataPlaneState { let data_plane = DataPlaneState {
executor, executor,
resolver, resolver,
log_sink, log_sink,
app_domains: app_domain_table.clone(),
routes: route_table, routes: route_table,
}; };
let apps_state = AppsState {
apps: apps_repo,
domains: domains_repo,
routes: route_repo,
domain_table: app_domain_table,
};
let auth_state = AuthState {
users: auth.users.clone(),
sessions: auth.sessions.clone(),
ttl: auth.ttl,
};
let admins_state = AdminsState {
users: auth.users,
sessions: auth.sessions,
};
// /admin/auth/login + /logout are unguarded by design (login is how
// you get in). /admin/auth/me applies the middleware internally so
// the same Router::with_state machinery composes cleanly. Everything
// else under /admin gets the require_admin layer.
let guarded_admin = Router::new()
.merge(admin_router(admin))
.merge(route_admin_router(route_admin))
.merge(admins_router(admins_state))
.merge(apps_router(apps_state))
.layer(from_fn_with_state(auth_state.clone(), require_admin));
// Silence "unused import" lint on `apps_api` — we re-export via the
// facade above; the bare module path is retained so it's discoverable.
let _ = apps_api::AppsState::clone;
let api_v1 = Router::new() let api_v1 = Router::new()
.nest("/admin", admin_router(admin)) .nest("/admin", auth_router(auth_state))
.nest("/admin", route_admin_router(route_admin)) .nest("/admin", guarded_admin)
.merge(data_plane_router(data_plane.clone())); .merge(data_plane_router(data_plane.clone()));
Ok(Router::new() Ok(Router::new()
@@ -138,6 +238,12 @@ impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> { ) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
self.0.list().await self.0.list().await
} }
async fn list_for_app(
&self,
app_id: picloud_shared::AppId,
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
self.0.list_for_app(app_id).await
}
async fn create( async fn create(
&self, &self,
input: picloud_manager_core::NewScript, input: picloud_manager_core::NewScript,

View File

@@ -1,17 +1,38 @@
//! PiCloud all-in-one binary — see `lib.rs` for the actual app //! PiCloud all-in-one binary — see `lib.rs` for the actual app
//! composition; this file is only the runtime shell (env config, //! composition; this file is only the runtime shell (env config,
//! logger, migrations, listener). //! logger, migrations, listener) plus the small `admin` CLI subcommand
//! used for out-of-band password recovery.
use std::io::{BufRead, Write};
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use picloud::{build_app, init_db}; use picloud::{build_app, init_db, AuthDeps};
use picloud_manager_core::migrations; use picloud_manager_core::{
auth::{hash_password, validate_password_hash},
bootstrap_first_admin, migrations, seed_hello_world_if_fresh, AdminSessionRepository,
AdminUserRepository, HelloWorldOutcome, PostgresAppRepository, PostgresRouteRepository,
PostgresScriptRepository,
};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
init_tracing(); init_tracing();
// Subcommand dispatch — `picloud admin reset-password <username>`.
// Kept handwritten to avoid pulling clap in just for one verb. Falls
// through to the server when no subcommand is given.
let args: Vec<String> = std::env::args().collect();
if args.get(1).map(String::as_str) == Some("admin") {
return run_admin_cli(&args[2..]).await;
}
run_server().await
}
async fn run_server() -> anyhow::Result<()> {
let addr: SocketAddr = std::env::var("PICLOUD_BIND") let addr: SocketAddr = std::env::var("PICLOUD_BIND")
.unwrap_or_else(|_| "0.0.0.0:8080".into()) .unwrap_or_else(|_| "0.0.0.0:8080".into())
.parse()?; .parse()?;
@@ -22,7 +43,32 @@ async fn main() -> anyhow::Result<()> {
migrations::run(&pool).await?; migrations::run(&pool).await?;
tracing::info!("migrations applied"); tracing::info!("migrations applied");
let app = build_app(pool).await?; let auth = AuthDeps::from_pool(pool.clone());
bootstrap_first_admin(&*auth.users).await?;
// Seed Hello World into the default app when this is a fresh
// install (no scripts and no routes). Idempotent on upgrades.
let apps = Arc::new(PostgresAppRepository::new(pool.clone()));
let scripts = Arc::new(PostgresScriptRepository::new(pool.clone()));
let routes = Arc::new(PostgresRouteRepository::new(pool.clone()));
match seed_hello_world_if_fresh(apps, scripts, routes).await {
Ok(HelloWorldOutcome::Seeded) => {
tracing::info!("hello-world seed inserted into the default app");
}
Ok(HelloWorldOutcome::SkippedExisting) => {
tracing::debug!("hello-world seed skipped (default app already populated)");
}
Err(err) => {
tracing::warn!(?err, "hello-world seed failed (continuing startup)");
}
}
// Background session-prune sweep. Cheap; keeps the table from
// growing unbounded. Expired rows are also rejected at lookup time,
// so a delayed sweep can't extend session lifetimes.
spawn_session_pruner(auth.sessions.clone());
let app = build_app(pool, auth).await?;
let listener = tokio::net::TcpListener::bind(addr).await?; let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(%addr, "picloud all-in-one listening"); tracing::info!(%addr, "picloud all-in-one listening");
@@ -33,6 +79,112 @@ async fn main() -> anyhow::Result<()> {
Ok(()) Ok(())
} }
fn spawn_session_pruner(sessions: Arc<dyn AdminSessionRepository>) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(Duration::from_secs(600));
// First tick fires immediately; skip it so we don't race startup.
ticker.tick().await;
loop {
ticker.tick().await;
match sessions.prune_expired().await {
Ok(n) if n > 0 => tracing::debug!(pruned = n, "expired admin sessions pruned"),
Ok(_) => {}
Err(err) => tracing::warn!(?err, "admin session prune failed"),
}
}
});
}
// ----------------------------------------------------------------------------
// `admin` subcommand
// ----------------------------------------------------------------------------
async fn run_admin_cli(args: &[String]) -> anyhow::Result<()> {
match args.first().map(String::as_str) {
Some("reset-password") => {
let username = args.get(1).ok_or_else(|| {
anyhow::anyhow!(
"usage: picloud admin reset-password <username> [--password-hash <hash>]"
)
})?;
// Optional inline hash via --password-hash <hash>; otherwise
// read a raw password from stdin.
let hash_arg = parse_flag(&args[2..], "--password-hash");
cmd_reset_password(username, hash_arg).await
}
Some(other) => Err(anyhow::anyhow!("unknown admin subcommand: {other}")),
None => Err(anyhow::anyhow!(
"usage: picloud admin reset-password <username>"
)),
}
}
fn parse_flag(args: &[String], name: &str) -> Option<String> {
let mut it = args.iter();
while let Some(a) = it.next() {
if a == name {
return it.next().cloned();
}
}
None
}
async fn cmd_reset_password(username: &str, password_hash: Option<String>) -> anyhow::Result<()> {
let database_url =
std::env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL is required"))?;
let pool = init_db(&database_url).await?;
migrations::run(&pool).await?;
let users = picloud_manager_core::PostgresAdminUserRepository::new(pool.clone());
let sessions = picloud_manager_core::PostgresAdminSessionRepository::new(pool);
let target = users
.get_by_username(username)
.await?
.ok_or_else(|| anyhow::anyhow!("no admin user named {username:?}"))?;
let hash = if let Some(h) = password_hash {
validate_password_hash(&h)
.map_err(|_| anyhow::anyhow!("--password-hash is not a valid Argon2id PHC string"))?;
h
} else {
let raw = prompt_password_from_stdin()?;
hash_password(&raw).map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))?
};
users.update_password_hash(target.id, &hash).await?;
// Recovery implies the operator already lost control of the account;
// re-activate it (so a deactivated admin can also recover) and wipe
// any pre-existing sessions in case the original holder is still
// signed in elsewhere.
if !target.is_active {
users.set_active(target.id, true).await?;
}
let dropped = sessions.delete_for_user(target.id).await?;
println!("Password reset for {username}. Sessions dropped: {dropped}. Active: true.");
Ok(())
}
fn prompt_password_from_stdin() -> anyhow::Result<String> {
eprint!("New password (will be read from stdin, no echo): ");
std::io::stderr().flush().ok();
let mut line = String::new();
std::io::stdin()
.lock()
.read_line(&mut line)
.map_err(|e| anyhow::anyhow!("failed to read stdin: {e}"))?;
let pw = line.trim_end_matches(['\n', '\r']).to_string();
if pw.is_empty() {
return Err(anyhow::anyhow!("password must not be empty"));
}
Ok(pw)
}
// ----------------------------------------------------------------------------
// Misc
// ----------------------------------------------------------------------------
fn init_tracing() { fn init_tracing() {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))

View File

@@ -17,9 +17,67 @@ use axum_test::TestServer;
use serde_json::{json, Value}; use serde_json::{json, Value};
use sqlx::PgPool; use sqlx::PgPool;
/// Build the all-in-one app over the test pool, seed a single admin
/// directly through the repo (bypassing the env-var bootstrap path so
/// tests don't contaminate the process environment), log in, and bake
/// the bearer token into the TestServer as a default header so every
/// request in the test passes the `require_admin` middleware.
async fn server(pool: PgPool) -> TestServer { async fn server(pool: PgPool) -> TestServer {
let app = picloud::build_app(pool).await.expect("build_app"); let (server, _app_id) = server_with_app(pool).await;
TestServer::new(app).expect("TestServer should build") server
}
/// Like `server`, but also returns the default app's id — needed by
/// any test that creates scripts (every script now requires `app_id`).
async fn server_with_app(pool: PgPool) -> (TestServer, String) {
use picloud_manager_core::auth::hash_password;
let auth = picloud::AuthDeps::from_pool(pool.clone());
let hash = hash_password("test-pw").expect("hash");
auth.users
.create("test-admin", &hash)
.await
.expect("seed admin");
let app = picloud::build_app(pool, auth).await.expect("build_app");
let mut server = TestServer::new(app).expect("TestServer should build");
let resp = server
.post("/api/v1/admin/auth/login")
.json(&json!({ "username": "test-admin", "password": "test-pw" }))
.await;
resp.assert_status_ok();
let token = resp.json::<Value>()["token"]
.as_str()
.expect("login should return token")
.to_string();
server.add_header("authorization", format!("Bearer {token}"));
// Note: user-route dispatch needs an explicit `host: <claim>` header
// on each request (the axum_test client doesn't default to a real
// host). The default app claims `localhost`; user-route tests below
// add the header per request via `.add_header("host", "localhost")`
// so per-test overrides for other apps cleanly replace it.
// The 0005 migration unconditionally inserts a `default` app; fetch
// its id so tests can attach scripts to it without re-running the
// Rust-side hello-world seed (which only fires from main.rs).
// The get-app handler returns `{ ...App, redirect_to?: ... }` —
// the app fields are flattened at the response root.
let app: Value = server.get("/api/v1/admin/apps/default").await.json();
let app_id = app["id"]
.as_str()
.unwrap_or_else(|| panic!("default app id missing from response: {app}"))
.to_string();
(server, app_id)
}
/// Merge `{ "app_id": <default> }` into a create-script body. Saves
/// repeating the same field in 25+ tests.
fn with_app(app_id: &str, mut body: Value) -> Value {
body.as_object_mut()
.expect("script body must be a JSON object")
.insert("app_id".into(), Value::String(app_id.to_string()));
body
} }
// ============================================================================ // ============================================================================
@@ -41,30 +99,37 @@ async fn healthz_responds_ok(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_script_returns_201_with_full_record(pool: PgPool) { async fn create_script_returns_201_with_full_record(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let r = s let r = s
.post("/api/v1/admin/scripts") .post("/api/v1/admin/scripts")
.json(&json!({ .json(&with_app(
"name": "echo", &app_id,
"description": "test", json!({
"source": "#{ statusCode: 200, body: 42 }", "name": "echo",
})) "description": "test",
"source": "#{ statusCode: 200, body: 42 }",
}),
))
.await; .await;
r.assert_status(axum::http::StatusCode::CREATED); r.assert_status(axum::http::StatusCode::CREATED);
let body: Value = r.json(); let body: Value = r.json();
assert_eq!(body["name"], "echo"); assert_eq!(body["name"], "echo");
assert_eq!(body["version"], 1); assert_eq!(body["version"], 1);
assert_eq!(body["timeout_seconds"], 30); assert_eq!(body["timeout_seconds"], 30);
assert_eq!(body["app_id"], app_id);
assert!(body["id"].as_str().is_some()); assert!(body["id"].as_str().is_some());
} }
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_with_invalid_syntax_returns_422(pool: PgPool) { async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
let r = server(pool) let (s, app_id) = server_with_app(pool).await;
.await let r = s
.post("/api/v1/admin/scripts") .post("/api/v1/admin/scripts")
.json(&json!({ "name": "broken", "source": "@@@ not rhai @@@" })) .json(&with_app(
&app_id,
json!({ "name": "broken", "source": "@@@ not rhai @@@" }),
))
.await; .await;
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY); r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
let body: Value = r.json(); let body: Value = r.json();
@@ -74,14 +139,14 @@ async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn duplicate_name_returns_409(pool: PgPool) { async fn duplicate_name_returns_409(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
s.post("/api/v1/admin/scripts") s.post("/api/v1/admin/scripts")
.json(&json!({ "name": "dup", "source": "42" })) .json(&with_app(&app_id, json!({ "name": "dup", "source": "42" })))
.await .await
.assert_status(axum::http::StatusCode::CREATED); .assert_status(axum::http::StatusCode::CREATED);
let r = s let r = s
.post("/api/v1/admin/scripts") .post("/api/v1/admin/scripts")
.json(&json!({ "name": "dup", "source": "43" })) .json(&with_app(&app_id, json!({ "name": "dup", "source": "43" })))
.await; .await;
r.assert_status(axum::http::StatusCode::CONFLICT); r.assert_status(axum::http::StatusCode::CONFLICT);
} }
@@ -89,10 +154,10 @@ async fn duplicate_name_returns_409(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn list_returns_all_scripts(pool: PgPool) { async fn list_returns_all_scripts(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
for name in ["alpha", "bravo", "charlie"] { for name in ["alpha", "bravo", "charlie"] {
s.post("/api/v1/admin/scripts") s.post("/api/v1/admin/scripts")
.json(&json!({ "name": name, "source": "1" })) .json(&with_app(&app_id, json!({ "name": name, "source": "1" })))
.await .await
.assert_status(axum::http::StatusCode::CREATED); .assert_status(axum::http::StatusCode::CREATED);
} }
@@ -107,10 +172,10 @@ async fn list_returns_all_scripts(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn update_bumps_version_and_persists_changes(pool: PgPool) { async fn update_bumps_version_and_persists_changes(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let created: Value = s let created: Value = s
.post("/api/v1/admin/scripts") .post("/api/v1/admin/scripts")
.json(&json!({ "name": "u", "source": "1" })) .json(&with_app(&app_id, json!({ "name": "u", "source": "1" })))
.await .await
.json(); .json();
let id = created["id"].as_str().unwrap(); let id = created["id"].as_str().unwrap();
@@ -129,10 +194,10 @@ async fn update_bumps_version_and_persists_changes(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn update_with_invalid_source_returns_422(pool: PgPool) { async fn update_with_invalid_source_returns_422(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let created: Value = s let created: Value = s
.post("/api/v1/admin/scripts") .post("/api/v1/admin/scripts")
.json(&json!({ "name": "u", "source": "1" })) .json(&with_app(&app_id, json!({ "name": "u", "source": "1" })))
.await .await
.json(); .json();
let id = created["id"].as_str().unwrap(); let id = created["id"].as_str().unwrap();
@@ -147,10 +212,10 @@ async fn update_with_invalid_source_returns_422(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn delete_then_get_returns_404(pool: PgPool) { async fn delete_then_get_returns_404(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let created: Value = s let created: Value = s
.post("/api/v1/admin/scripts") .post("/api/v1/admin/scripts")
.json(&json!({ "name": "d", "source": "1" })) .json(&with_app(&app_id, json!({ "name": "d", "source": "1" })))
.await .await
.json(); .json();
let id = created["id"].as_str().unwrap(); let id = created["id"].as_str().unwrap();
@@ -181,13 +246,16 @@ async fn get_nonexistent_returns_404(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn execute_echoes_body_back(pool: PgPool) { async fn execute_echoes_body_back(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let created: Value = s let created: Value = s
.post("/api/v1/admin/scripts") .post("/api/v1/admin/scripts")
.json(&json!({ .json(&with_app(
"name": "echo", &app_id,
"source": "#{ statusCode: 200, body: ctx.request.body }", json!({
})) "name": "echo",
"source": "#{ statusCode: 200, body: ctx.request.body }",
}),
))
.await .await
.json(); .json();
let id = created["id"].as_str().unwrap(); let id = created["id"].as_str().unwrap();
@@ -204,13 +272,16 @@ async fn execute_echoes_body_back(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn execute_passes_through_status_and_headers(pool: PgPool) { async fn execute_passes_through_status_and_headers(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let created: Value = s let created: Value = s
.post("/api/v1/admin/scripts") .post("/api/v1/admin/scripts")
.json(&json!({ .json(&with_app(
"name": "header-test", &app_id,
"source": "#{ statusCode: 201, headers: #{ \"x-tag\": \"on\" }, body: 1 }", json!({
})) "name": "header-test",
"source": "#{ statusCode: 201, headers: #{ \"x-tag\": \"on\" }, body: 1 }",
}),
))
.await .await
.json(); .json();
let id = created["id"].as_str().unwrap(); let id = created["id"].as_str().unwrap();
@@ -237,13 +308,16 @@ async fn execute_nonexistent_returns_404(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn execution_logs_capture_invocations(pool: PgPool) { async fn execution_logs_capture_invocations(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let created: Value = s let created: Value = s
.post("/api/v1/admin/scripts") .post("/api/v1/admin/scripts")
.json(&json!({ .json(&with_app(
"name": "logger", &app_id,
"source": "log::info(\"called\", #{ marker: 7 }); #{ statusCode: 200, body: \"done\" }", json!({
})) "name": "logger",
"source": "log::info(\"called\", #{ marker: 7 }); #{ statusCode: 200, body: \"done\" }",
}),
))
.await .await
.json(); .json();
let id = created["id"].as_str().unwrap(); let id = created["id"].as_str().unwrap();
@@ -294,10 +368,13 @@ async fn execution_logs_capture_invocations(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_without_sandbox_returns_empty_object(pool: PgPool) { async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let created: Value = s let created: Value = s
.post("/api/v1/admin/scripts") .post("/api/v1/admin/scripts")
.json(&json!({ "name": "no-sandbox", "source": "1" })) .json(&with_app(
&app_id,
json!({ "name": "no-sandbox", "source": "1" }),
))
.await .await
.json(); .json();
assert_eq!(created["sandbox"], json!({})); assert_eq!(created["sandbox"], json!({}));
@@ -306,14 +383,17 @@ async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) { async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let created: Value = s let created: Value = s
.post("/api/v1/admin/scripts") .post("/api/v1/admin/scripts")
.json(&json!({ .json(&with_app(
"name": "tight", &app_id,
"source": "1", json!({
"sandbox": { "max_operations": 500, "max_string_size": 1024 } "name": "tight",
})) "source": "1",
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
}),
))
.await .await
.json(); .json();
assert_eq!( assert_eq!(
@@ -333,14 +413,17 @@ async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) { async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
// Default conservative ceiling caps max_operations at 10_000_000. // Default conservative ceiling caps max_operations at 10_000_000.
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let r = s let r = s
.post("/api/v1/admin/scripts") .post("/api/v1/admin/scripts")
.json(&json!({ .json(&with_app(
"name": "too-loose", &app_id,
"source": "1", json!({
"sandbox": { "max_operations": 100_000_000 } "name": "too-loose",
})) "source": "1",
"sandbox": { "max_operations": 100_000_000 }
}),
))
.await; .await;
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY); r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
let body: Value = r.json(); let body: Value = r.json();
@@ -350,14 +433,17 @@ async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn sandbox_unknown_field_returns_422(pool: PgPool) { async fn sandbox_unknown_field_returns_422(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let r = s let r = s
.post("/api/v1/admin/scripts") .post("/api/v1/admin/scripts")
.json(&json!({ .json(&with_app(
"name": "typo", &app_id,
"source": "1", json!({
"sandbox": { "max_operashuns": 500 } "name": "typo",
})) "source": "1",
"sandbox": { "max_operashuns": 500 }
}),
))
.await; .await;
// serde's deny_unknown_fields causes axum to reject with 422 or // serde's deny_unknown_fields causes axum to reject with 422 or
// 400 depending on extractor; the routing is irrelevant here, just // 400 depending on extractor; the routing is irrelevant here, just
@@ -371,15 +457,18 @@ async fn sandbox_unknown_field_returns_422(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) { async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
// Tight max_operations on a loop the default would happily run. // Tight max_operations on a loop the default would happily run.
let created: Value = s let created: Value = s
.post("/api/v1/admin/scripts") .post("/api/v1/admin/scripts")
.json(&json!({ .json(&with_app(
"name": "tight-exec", &app_id,
"source": "let n = 0; for i in 0..10000 { n += 1; } n", json!({
"sandbox": { "max_operations": 500 } "name": "tight-exec",
})) "source": "let n = 0; for i in 0..10000 { n += 1; } n",
"sandbox": { "max_operations": 500 }
}),
))
.await .await
.json(); .json();
let id = created["id"].as_str().unwrap(); let id = created["id"].as_str().unwrap();
@@ -396,14 +485,17 @@ async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn update_replaces_sandbox_wholesale(pool: PgPool) { async fn update_replaces_sandbox_wholesale(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let created: Value = s let created: Value = s
.post("/api/v1/admin/scripts") .post("/api/v1/admin/scripts")
.json(&json!({ .json(&with_app(
"name": "patch-target", &app_id,
"source": "1", json!({
"sandbox": { "max_operations": 500, "max_string_size": 1024 } "name": "patch-target",
})) "source": "1",
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
}),
))
.await .await
.json(); .json();
let id = created["id"].as_str().unwrap(); let id = created["id"].as_str().unwrap();
@@ -429,10 +521,10 @@ async fn update_replaces_sandbox_wholesale(pool: PgPool) {
// Custom routing // Custom routing
// ============================================================================ // ============================================================================
async fn create_basic_script(s: &TestServer, name: &str, source: &str) -> String { async fn create_basic_script(s: &TestServer, app_id: &str, name: &str, source: &str) -> String {
let v: Value = s let v: Value = s
.post("/api/v1/admin/scripts") .post("/api/v1/admin/scripts")
.json(&json!({ "name": name, "source": source })) .json(&with_app(app_id, json!({ "name": name, "source": source })))
.await .await
.json(); .json();
v["id"].as_str().unwrap().to_string() v["id"].as_str().unwrap().to_string()
@@ -441,9 +533,10 @@ async fn create_basic_script(s: &TestServer, name: &str, source: &str) -> String
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_exact_dispatches_to_script(pool: PgPool) { async fn route_exact_dispatches_to_script(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script( let id = create_basic_script(
&s, &s,
&app_id,
"greet", "greet",
"#{ statusCode: 200, body: #{ msg: \"hi\", path: ctx.request.path } }", "#{ statusCode: 200, body: #{ msg: \"hi\", path: ctx.request.path } }",
) )
@@ -457,7 +550,7 @@ async fn route_exact_dispatches_to_script(pool: PgPool) {
.await .await
.assert_status(axum::http::StatusCode::CREATED); .assert_status(axum::http::StatusCode::CREATED);
let r = s.get("/greet").await; let r = s.get("/greet").add_header("host", "localhost").await;
r.assert_status_ok(); r.assert_status_ok();
let body: Value = r.json(); let body: Value = r.json();
assert_eq!(body["msg"], "hi"); assert_eq!(body["msg"], "hi");
@@ -467,9 +560,10 @@ async fn route_exact_dispatches_to_script(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_param_captures_path_vars(pool: PgPool) { async fn route_param_captures_path_vars(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script( let id = create_basic_script(
&s, &s,
&app_id,
"greet-name", "greet-name",
"#{ statusCode: 200, body: #{ name: ctx.request.params.name } }", "#{ statusCode: 200, body: #{ name: ctx.request.params.name } }",
) )
@@ -483,7 +577,7 @@ async fn route_param_captures_path_vars(pool: PgPool) {
.await .await
.assert_status(axum::http::StatusCode::CREATED); .assert_status(axum::http::StatusCode::CREATED);
let r = s.get("/greet/alice").await; let r = s.get("/greet/alice").add_header("host", "localhost").await;
r.assert_status_ok(); r.assert_status_ok();
let body: Value = r.json(); let body: Value = r.json();
assert_eq!(body["name"], "alice"); assert_eq!(body["name"], "alice");
@@ -492,9 +586,10 @@ async fn route_param_captures_path_vars(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_prefix_captures_rest(pool: PgPool) { async fn route_prefix_captures_rest(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script( let id = create_basic_script(
&s, &s,
&app_id,
"echo-prefix", "echo-prefix",
"#{ statusCode: 200, body: #{ rest: ctx.request.rest } }", "#{ statusCode: 200, body: #{ rest: ctx.request.rest } }",
) )
@@ -508,19 +603,28 @@ async fn route_prefix_captures_rest(pool: PgPool) {
.await .await
.assert_status(axum::http::StatusCode::CREATED); .assert_status(axum::http::StatusCode::CREATED);
let r = s.get("/echo/foo/bar").await; let r = s.get("/echo/foo/bar").add_header("host", "localhost").await;
r.assert_status_ok(); r.assert_status_ok();
let body: Value = r.json(); let body: Value = r.json();
assert_eq!(body["rest"], "foo/bar"); assert_eq!(body["rest"], "foo/bar");
s.get("/echo").await.assert_status_not_found(); s.get("/echo")
.add_header("host", "localhost")
.await
.assert_status_not_found();
} }
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_query_string_exposed_to_script(pool: PgPool) { async fn route_query_string_exposed_to_script(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(&s, "qs", "#{ statusCode: 200, body: ctx.request.query }").await; let id = create_basic_script(
&s,
&app_id,
"qs",
"#{ statusCode: 200, body: ctx.request.query }",
)
.await;
s.post(&format!("/api/v1/admin/scripts/{id}/routes")) s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({ .json(&json!({
"host_kind": "any", "host_kind": "any",
@@ -530,7 +634,7 @@ async fn route_query_string_exposed_to_script(pool: PgPool) {
.await .await
.assert_status(axum::http::StatusCode::CREATED); .assert_status(axum::http::StatusCode::CREATED);
let r = s.get("/qs?a=1&b=two").await; let r = s.get("/qs?a=1&b=two").add_header("host", "localhost").await;
r.assert_status_ok(); r.assert_status_ok();
let body: Value = r.json(); let body: Value = r.json();
assert_eq!(body, json!({ "a": "1", "b": "two" })); assert_eq!(body, json!({ "a": "1", "b": "two" }));
@@ -539,8 +643,8 @@ async fn route_query_string_exposed_to_script(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_invalid_pattern_returns_422(pool: PgPool) { async fn route_invalid_pattern_returns_422(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(&s, "x", "1").await; let id = create_basic_script(&s, &app_id, "x", "1").await;
let r = s let r = s
.post(&format!("/api/v1/admin/scripts/{id}/routes")) .post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({ .json(&json!({
@@ -555,8 +659,8 @@ async fn route_invalid_pattern_returns_422(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_conflict_returns_409(pool: PgPool) { async fn route_conflict_returns_409(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(&s, "x", "1").await; let id = create_basic_script(&s, &app_id, "x", "1").await;
s.post(&format!("/api/v1/admin/scripts/{id}/routes")) s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({ .json(&json!({
"host_kind": "any", "host_kind": "any",
@@ -582,8 +686,8 @@ async fn route_conflict_returns_409(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_reserved_path_returns_422(pool: PgPool) { async fn route_reserved_path_returns_422(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(&s, "x", "1").await; let id = create_basic_script(&s, &app_id, "x", "1").await;
let r = s let r = s
.post(&format!("/api/v1/admin/scripts/{id}/routes")) .post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({ .json(&json!({
@@ -598,8 +702,8 @@ async fn route_reserved_path_returns_422(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_match_preview_endpoint(pool: PgPool) { async fn route_match_preview_endpoint(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(&s, "g", "1").await; let id = create_basic_script(&s, &app_id, "g", "1").await;
s.post(&format!("/api/v1/admin/scripts/{id}/routes")) s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({ .json(&json!({
"host_kind": "any", "host_kind": "any",
@@ -611,7 +715,11 @@ async fn route_match_preview_endpoint(pool: PgPool) {
let r = s let r = s
.post("/api/v1/admin/routes:match") .post("/api/v1/admin/routes:match")
.json(&json!({ "url": "http://localhost:8000/greet/alice", "method": "GET" })) .json(&json!({
"app_id": app_id,
"url": "http://localhost:8000/greet/alice",
"method": "GET"
}))
.await; .await;
r.assert_status_ok(); r.assert_status_ok();
let body: Value = r.json(); let body: Value = r.json();
@@ -622,8 +730,8 @@ async fn route_match_preview_endpoint(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_delete_removes_dispatch(pool: PgPool) { async fn route_delete_removes_dispatch(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(&s, "g", "#{ statusCode: 200, body: 1 }").await; let id = create_basic_script(&s, &app_id, "g", "#{ statusCode: 200, body: 1 }").await;
let created: Value = s let created: Value = s
.post(&format!("/api/v1/admin/scripts/{id}/routes")) .post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({ .json(&json!({
@@ -635,27 +743,35 @@ async fn route_delete_removes_dispatch(pool: PgPool) {
.json(); .json();
let route_id = created["id"].as_str().unwrap(); let route_id = created["id"].as_str().unwrap();
s.get("/g").await.assert_status_ok(); s.get("/g")
.add_header("host", "localhost")
.await
.assert_status_ok();
s.delete(&format!("/api/v1/admin/routes/{route_id}")) s.delete(&format!("/api/v1/admin/routes/{route_id}"))
.await .await
.assert_status(axum::http::StatusCode::NO_CONTENT); .assert_status(axum::http::StatusCode::NO_CONTENT);
s.get("/g").await.assert_status_not_found(); s.get("/g")
.add_header("host", "localhost")
.await
.assert_status_not_found();
} }
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_specificity_param_beats_prefix(pool: PgPool) { async fn route_specificity_param_beats_prefix(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let id_p = create_basic_script( let id_p = create_basic_script(
&s, &s,
&app_id,
"by-param", "by-param",
"#{ statusCode: 200, body: #{ tag: \"param\" } }", "#{ statusCode: 200, body: #{ tag: \"param\" } }",
) )
.await; .await;
let id_pr = create_basic_script( let id_pr = create_basic_script(
&s, &s,
&app_id,
"by-prefix", "by-prefix",
"#{ statusCode: 200, body: #{ tag: \"prefix\" } }", "#{ statusCode: 200, body: #{ tag: \"prefix\" } }",
) )
@@ -678,12 +794,12 @@ async fn route_specificity_param_beats_prefix(pool: PgPool) {
.assert_status(axum::http::StatusCode::CREATED); .assert_status(axum::http::StatusCode::CREATED);
// Single segment under /foo/ — both match; param wins by spec. // Single segment under /foo/ — both match; param wins by spec.
let r = s.get("/foo/x").await; let r = s.get("/foo/x").add_header("host", "localhost").await;
let body: Value = r.json(); let body: Value = r.json();
assert_eq!(body["tag"], "param"); assert_eq!(body["tag"], "param");
// Two segments — only prefix matches. // Two segments — only prefix matches.
let r2 = s.get("/foo/x/y").await; let r2 = s.get("/foo/x/y").add_header("host", "localhost").await;
let body2: Value = r2.json(); let body2: Value = r2.json();
assert_eq!(body2["tag"], "prefix"); assert_eq!(body2["tag"], "prefix");
} }
@@ -692,7 +808,7 @@ async fn route_specificity_param_beats_prefix(pool: PgPool) {
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn root_returns_404_when_no_route(pool: PgPool) { async fn root_returns_404_when_no_route(pool: PgPool) {
let s = server(pool).await; let s = server(pool).await;
let r = s.get("/").await; let r = s.get("/").add_header("host", "localhost").await;
r.assert_status_not_found(); r.assert_status_not_found();
} }
@@ -705,22 +821,325 @@ async fn version_includes_public_base_url(pool: PgPool) {
let v: Value = r.json(); let v: Value = r.json();
assert!(v["public_base_url"].is_string()); assert!(v["public_base_url"].is_string());
assert_eq!(v["api"], 1); assert_eq!(v["api"], 1);
assert_eq!(v["schema"], 3); assert_eq!(v["schema"], 5);
assert_eq!(v["sdk"], "1.1"); assert_eq!(v["sdk"], "1.1");
} }
// ============================================================================ // ============================================================================
// ============================================================================
// App scoping (Phase 3b)
// ============================================================================
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn default_app_is_seeded_by_migration(pool: PgPool) {
let s = server(pool).await;
let r = s.get("/api/v1/admin/apps").await;
r.assert_status_ok();
let apps: Vec<Value> = r.json();
let default = apps
.iter()
.find(|a| a["slug"] == "default")
.expect("default app must exist");
assert_eq!(default["name"], "Default");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn cross_app_isolation_at_dispatch(pool: PgPool) {
let (s, default_id) = server_with_app(pool).await;
// Two apps each create a script with the same name (per-app
// uniqueness — would have collided pre-3b).
let app_b: Value = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "tenant-b", "name": "Tenant B" }))
.await
.json();
let b_id = app_b["id"].as_str().unwrap();
s.post(&format!("/api/v1/admin/apps/{b_id}/domains"))
.json(&json!({ "pattern": "b.localhost" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
let id_default: String = s
.post("/api/v1/admin/scripts")
.json(&with_app(
&default_id,
json!({
"name": "echo",
"source": "#{ statusCode: 200, body: #{ from: \"default\" } }"
}),
))
.await
.json::<Value>()["id"]
.as_str()
.unwrap()
.to_string();
let id_b: String = s
.post("/api/v1/admin/scripts")
.json(&with_app(
b_id,
json!({
"name": "echo",
"source": "#{ statusCode: 200, body: #{ from: \"b\" } }"
}),
))
.await
.json::<Value>()["id"]
.as_str()
.unwrap()
.to_string();
s.post(&format!("/api/v1/admin/scripts/{id_default}/routes"))
.json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/echo" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
s.post(&format!("/api/v1/admin/scripts/{id_b}/routes"))
.json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/echo" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
// Same path, different host — routes land in different apps.
let from_default: Value = s.get("/echo").add_header("host", "localhost").await.json();
assert_eq!(from_default["from"], "default");
let from_b: Value = s
.get("/echo")
.add_header("host", "b.localhost")
.await
.json();
assert_eq!(from_b["from"], "b");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn unknown_host_returns_404(pool: PgPool) {
let s = server(pool).await;
let r = s.get("/whatever").add_header("host", "nope.invalid").await;
r.assert_status_not_found();
let body: Value = r.json();
assert!(body["error"]
.as_str()
.unwrap()
.contains("no app claims host"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn execute_by_id_works_without_host_claim(pool: PgPool) {
// The /api/v1/execute/{id} bypass is the implicit __internal__
// claim of every app — it MUST keep working for an app with zero
// public domain claims.
let (s, _) = server_with_app(pool).await;
let app: Value = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "internal-only", "name": "Internal Only" }))
.await
.json();
let app_id = app["id"].as_str().unwrap();
let script: Value = s
.post("/api/v1/admin/scripts")
.json(&with_app(
app_id,
json!({ "name": "x", "source": "#{ statusCode: 200, body: \"ok\" }" }),
))
.await
.json();
let id = script["id"].as_str().unwrap();
let r = s
.post(&format!("/api/v1/execute/{id}"))
.json(&json!({}))
.await;
r.assert_status_ok();
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn duplicate_slug_creates_a_409(pool: PgPool) {
let s = server(pool).await;
s.post("/api/v1/admin/apps")
.json(&json!({ "slug": "alpha", "name": "First" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
let r = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "alpha", "name": "Second" }))
.await;
r.assert_status(axum::http::StatusCode::CONFLICT);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn reserved_slug_rejected(pool: PgPool) {
let s = server(pool).await;
for bad in ["new", "api", "admin", "login"] {
let r = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": bad, "name": "x" }))
.await;
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
}
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn slug_rename_keeps_old_as_redirect(pool: PgPool) {
let s = server(pool).await;
let app: Value = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "old-slug", "name": "x" }))
.await
.json();
let id = app["id"].as_str().unwrap();
s.patch(&format!("/api/v1/admin/apps/{id}"))
.json(&json!({ "slug": "new-slug" }))
.await
.assert_status_ok();
let resp: Value = s.get("/api/v1/admin/apps/old-slug").await.json();
// The old slug resolves via history and surfaces `redirect_to`.
assert_eq!(resp["redirect_to"], "new-slug");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn claiming_historical_slug_needs_force_takeover(pool: PgPool) {
let s = server(pool).await;
// Set up a history row.
let first: Value = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "soon-retired", "name": "x" }))
.await
.json();
s.patch(&format!(
"/api/v1/admin/apps/{}",
first["id"].as_str().unwrap()
))
.json(&json!({ "slug": "kept" }))
.await
.assert_status_ok();
// Plain create against the retired slug → 409.
let r = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "soon-retired", "name": "y" }))
.await;
r.assert_status(axum::http::StatusCode::CONFLICT);
// With force_takeover → 201.
let r = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "soon-retired", "name": "y", "force_takeover": true }))
.await;
r.assert_status(axum::http::StatusCode::CREATED);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn shape_key_collision_rejected(pool: PgPool) {
let s = server(pool).await;
let a: Value = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "a", "name": "A" }))
.await
.json();
let b: Value = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "b", "name": "B" }))
.await
.json();
s.post(&format!(
"/api/v1/admin/apps/{}/domains",
a["id"].as_str().unwrap()
))
.json(&json!({ "pattern": "*.example.com" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
// Parameterized form should collide with wildcard form.
let r = s
.post(&format!(
"/api/v1/admin/apps/{}/domains",
b["id"].as_str().unwrap()
))
.json(&json!({ "pattern": "{tenant}.example.com" }))
.await;
r.assert_status(axum::http::StatusCode::CONFLICT);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn delete_app_with_scripts_returns_409(pool: PgPool) {
let s = server(pool).await;
let app: Value = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "with-scripts", "name": "x" }))
.await
.json();
let id = app["id"].as_str().unwrap();
s.post("/api/v1/admin/scripts")
.json(&with_app(id, json!({ "name": "s", "source": "1" })))
.await
.assert_status(axum::http::StatusCode::CREATED);
let r = s.delete(&format!("/api/v1/admin/apps/{id}")).await;
r.assert_status(axum::http::StatusCode::CONFLICT);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn list_scripts_filtered_by_app(pool: PgPool) {
let (s, default_id) = server_with_app(pool).await;
let other: Value = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "filter-target", "name": "x" }))
.await
.json();
let other_id = other["id"].as_str().unwrap();
s.post("/api/v1/admin/scripts")
.json(&with_app(
&default_id,
json!({ "name": "in-default", "source": "1" }),
))
.await
.assert_status(axum::http::StatusCode::CREATED);
s.post("/api/v1/admin/scripts")
.json(&with_app(
other_id,
json!({ "name": "in-other", "source": "1" }),
))
.await
.assert_status(axum::http::StatusCode::CREATED);
// Filter by id.
let filtered: Vec<Value> = s
.get(&format!("/api/v1/admin/scripts?app={other_id}"))
.await
.json();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0]["name"], "in-other");
// Filter by slug.
let filtered_by_slug: Vec<Value> = s
.get("/api/v1/admin/scripts?app=filter-target")
.await
.json();
assert_eq!(filtered_by_slug.len(), 1);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn execution_errors_are_still_logged(pool: PgPool) { async fn execution_errors_are_still_logged(pool: PgPool) {
let s = server(pool).await; let (s, app_id) = server_with_app(pool).await;
let created: Value = s let created: Value = s
.post("/api/v1/admin/scripts") .post("/api/v1/admin/scripts")
.json(&json!({ .json(&with_app(
"name": "boom", &app_id,
"source": "1 / 0", json!({
})) "name": "boom",
"source": "1 / 0",
}),
))
.await .await
.json(); .json();
let id = created["id"].as_str().unwrap(); let id = created["id"].as_str().unwrap();

53
crates/shared/src/app.rs Normal file
View File

@@ -0,0 +1,53 @@
//! App scoping: top-level isolation boundary for scripts, routes,
//! domains, and (forward) data. Every script and route belongs to
//! exactly one app; cross-app references are not allowed.
//!
//! See blueprint §11.5. The orchestrator dispatches via two-phase
//! lookup: `Host → app_id → route trie`.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::AppId;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct App {
pub id: AppId,
/// URL-safe identifier; appears in dashboard paths. Mutable via the
/// slug-rename flow which preserves the old slug as a permanent 301
/// in `app_slug_history`.
pub slug: String,
pub name: String,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DomainShape {
/// Exact host: `app.example.com`.
Exact,
/// Wildcard suffix: `*.example.com` matches any subdomain.
Wildcard,
/// Parameterized wildcard: `{tenant}.example.com`. Same shape as
/// `Wildcard` for collision purposes; the binding name surfaces in
/// request context (future).
Parameterized,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppDomain {
pub id: Uuid,
pub app_id: AppId,
/// As the user typed it: `app.example.com`, `*.example.com`, or
/// `{tenant}.example.com`.
pub pattern: String,
pub shape: DomainShape,
/// Normalized collision key. `exact:<host>` for exact; `wildcard:<suffix>`
/// for both wildcard and parameterized (parameter name is a binding,
/// not a discriminator — per blueprint §11.5).
pub shape_key: String,
pub created_at: DateTime<Utc>,
}

View File

@@ -11,4 +11,10 @@ pub enum Error {
#[error("invalid script source: {0}")] #[error("invalid script source: {0}")]
InvalidScript(String), InvalidScript(String),
#[error("app not found: {0}")]
AppNotFound(crate::AppId),
#[error("domain claim conflict: {0}")]
DomainConflict(String),
} }

View File

@@ -4,13 +4,16 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::{RequestId, ScriptId}; use crate::{AppId, RequestId, ScriptId};
/// One row in the `execution_logs` table. Same shape flows through the /// One row in the `execution_logs` table. Same shape flows through the
/// `ExecutionLogSink` trait and the `GET /scripts/{id}/logs` response. /// `ExecutionLogSink` trait and the `GET /scripts/{id}/logs` response.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionLog { pub struct ExecutionLog {
pub id: Uuid, pub id: Uuid,
/// Owning app at the time of execution. Materialized at write time
/// so a future "move script to another app" doesn't retag history.
pub app_id: AppId,
pub script_id: ScriptId, pub script_id: ScriptId,
pub request_id: RequestId, pub request_id: RequestId,

View File

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

View File

@@ -4,6 +4,7 @@
//! that core's crate. Things here must be genuinely shared (IDs, the Script //! that core's crate. Things here must be genuinely shared (IDs, the Script
//! entity, error roots, transport DTOs). //! entity, error roots, transport DTOs).
pub mod app;
pub mod error; pub mod error;
pub mod execution_log; pub mod execution_log;
pub mod ids; pub mod ids;
@@ -14,9 +15,10 @@ pub mod script;
pub mod validator; pub mod validator;
pub mod version; pub mod version;
pub use app::{App, AppDomain, DomainShape};
pub use error::Error; pub use error::Error;
pub use execution_log::{ExecutionLog, ExecutionStatus}; pub use execution_log::{ExecutionLog, ExecutionStatus};
pub use ids::{ExecutionId, RequestId, ScriptId}; pub use ids::{AdminUserId, AppId, ExecutionId, RequestId, ScriptId};
pub use log_sink::{ExecutionLogSink, LogSinkError}; pub use log_sink::{ExecutionLogSink, LogSinkError};
pub use route::{HostKind, PathKind, Route}; pub use route::{HostKind, PathKind, Route};
pub use sandbox::ScriptSandbox; pub use sandbox::ScriptSandbox;

View File

@@ -7,7 +7,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::ScriptId; use crate::{AppId, ScriptId};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -40,6 +40,10 @@ pub enum PathKind {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Route { pub struct Route {
pub id: Uuid, pub id: Uuid,
/// Owning app. Always equals `scripts.app_id` for the bound script.
/// Carried on the route row so the orchestrator can partition the
/// route table without joining back to scripts on every refresh.
pub app_id: AppId,
pub script_id: ScriptId, pub script_id: ScriptId,
pub host_kind: HostKind, pub host_kind: HostKind,

View File

@@ -1,7 +1,7 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ScriptId, ScriptSandbox}; use crate::{AppId, ScriptId, ScriptSandbox};
/// A user-uploaded Rhai script and its execution configuration. /// A user-uploaded Rhai script and its execution configuration.
/// ///
@@ -11,6 +11,10 @@ use crate::{ScriptId, ScriptSandbox};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Script { pub struct Script {
pub id: ScriptId, pub id: ScriptId,
/// Owning app. Set on create, immutable thereafter — a "move to
/// another app" is a copy+delete, not an in-place edit (snapshot
/// semantics — see blueprint §11.5).
pub app_id: AppId,
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
pub version: i32, pub version: i32,

View File

@@ -1,12 +1,23 @@
{ {
"name": "picloud-dashboard", "name": "picloud-dashboard",
"version": "0.1.0", "version": "0.5.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picloud-dashboard", "name": "picloud-dashboard",
"version": "0.1.0", "version": "0.5.1",
"dependencies": {
"@codemirror/autocomplete": "^6.20.2",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.7.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.0",
"@lezer/highlight": "^1.2.3",
"codemirror": "^6.0.2"
},
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
@@ -23,7 +34,99 @@
"svelte-check": "^4.1.4", "svelte-check": "^4.1.4",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"typescript-eslint": "^8.20.0", "typescript-eslint": "^8.20.0",
"vite": "^6.0.7" "vite": "^6.0.7",
"vitest": "^3.0.5"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.2",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz",
"integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.6",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz",
"integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz",
"integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
@@ -741,6 +844,47 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@polka/url": { "node_modules/@polka/url": {
"version": "1.0.0-next.29", "version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -1131,7 +1275,6 @@
"integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==", "integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5", "@sveltejs/acorn-typescript": "^1.0.5",
@@ -1174,7 +1317,6 @@
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
"debug": "^4.4.1", "debug": "^4.4.1",
@@ -1209,6 +1351,17 @@
"vite": "^6.0.0" "vite": "^6.0.0"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/cookie": { "node_modules/@types/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -1216,6 +1369,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1236,7 +1396,6 @@
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -1293,7 +1452,6 @@
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/types": "8.59.4", "@typescript-eslint/types": "8.59.4",
@@ -1401,7 +1559,6 @@
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}, },
@@ -1532,13 +1689,127 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.4",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^4.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1606,6 +1877,16 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/axobject-query": { "node_modules/axobject-query": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -1634,6 +1915,16 @@
"concat-map": "0.0.1" "concat-map": "0.0.1"
} }
}, },
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -1644,6 +1935,23 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1661,6 +1969,16 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -1687,6 +2005,21 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1724,6 +2057,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1770,6 +2109,16 @@
} }
} }
}, },
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1794,6 +2143,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -1855,7 +2211,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -2082,6 +2437,16 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/esutils": { "node_modules/esutils": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -2092,6 +2457,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2310,6 +2685,13 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@@ -2425,6 +2807,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2584,6 +2973,23 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2597,7 +3003,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -2625,7 +3030,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.12", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -2759,7 +3163,6 @@
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -2923,6 +3326,13 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/sirv": { "node_modules/sirv": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@@ -2948,6 +3358,20 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/strip-json-comments": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -2961,6 +3385,25 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -2980,7 +3423,6 @@
"integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==", "integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@@ -3058,6 +3500,20 @@
} }
} }
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.16", "version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -3075,6 +3531,36 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/totalist": { "node_modules/totalist": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -3117,7 +3603,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -3180,7 +3665,6 @@
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
@@ -3250,6 +3734,29 @@
} }
} }
}, },
"node_modules/vite-node": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.4.1",
"es-module-lexer": "^1.7.0",
"pathe": "^2.0.3",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitefu": { "node_modules/vitefu": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
@@ -3270,6 +3777,85 @@
} }
} }
}, },
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
"@vitest/mocker": "3.2.4",
"@vitest/pretty-format": "^3.2.4",
"@vitest/runner": "3.2.4",
"@vitest/snapshot": "3.2.4",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
"magic-string": "^0.30.17",
"pathe": "^2.0.3",
"picomatch": "^4.0.2",
"std-env": "^3.9.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.14",
"tinypool": "^1.1.1",
"tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
"vite-node": "3.2.4",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.4",
"@vitest/ui": "3.2.4",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/debug": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -3286,6 +3872,23 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",

View File

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

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

@@ -0,0 +1,328 @@
<!--
Confirmation modal — replaces window.confirm/prompt for destructive
actions so the dashboard can render the full context (counts, lists,
warnings) in its own style.
Usage:
{#if showing}
<ConfirmModal
title="Delete app"
variant="danger"
confirmLabel="Delete app"
confirmPhrase={app.slug}
onConfirm={() => doDelete()}
onCancel={() => (showing = false)}
>
<p>Body content — counts, lists, warnings.</p>
</ConfirmModal>
{/if}
When `confirmPhrase` is set the confirm button stays disabled until
the user types the phrase exactly — same pattern GitHub uses for
irreversible repo deletes. Omit it for low-stakes confirmations.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
type Variant = 'danger' | 'neutral';
interface Props {
title: string;
children: Snippet;
onConfirm: () => void | Promise<void>;
onCancel: () => void;
variant?: Variant;
confirmLabel?: string;
cancelLabel?: string;
/** When set, the confirm button is disabled until the user types
* this string exactly (case-sensitive). */
confirmPhrase?: string;
/** Shown above the confirm input. Defaults to a sensible message
* that mentions the phrase. */
confirmPhrasePrompt?: string;
/** While true the buttons are disabled and the confirm label is
* replaced with a "busy" form (e.g. "Deleting…"). */
busy?: boolean;
busyLabel?: string;
}
let {
title,
children,
onConfirm,
onCancel,
variant = 'neutral',
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
confirmPhrase,
confirmPhrasePrompt,
busy = false,
busyLabel
}: Props = $props();
let typed = $state('');
let phraseMatches = $derived(
confirmPhrase === undefined || typed === confirmPhrase
);
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && !busy) {
event.preventDefault();
onCancel();
}
}
function handleBackdrop(event: MouseEvent) {
// Only cancel when clicking the backdrop itself, not bubbled
// clicks from the dialog content.
if (event.target === event.currentTarget && !busy) {
onCancel();
}
}
async function handleConfirm() {
if (!phraseMatches || busy) return;
await onConfirm();
}
// Focus management: when the modal mounts, focus the slug-retype
// input (if present) or the cancel button (so an accidental Enter
// doesn't auto-confirm a destructive action).
let inputRef = $state<HTMLInputElement | null>(null);
let cancelRef = $state<HTMLButtonElement | null>(null);
$effect(() => {
if (inputRef) inputRef.focus();
else if (cancelRef) cancelRef.focus();
});
</script>
<svelte:window onkeydown={handleKeydown} />
<div
class="backdrop"
role="presentation"
onclick={handleBackdrop}
>
<div
class="dialog"
class:danger={variant === 'danger'}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-title"
>
<h2 id="confirm-title">{title}</h2>
<div class="body">
{@render children()}
</div>
{#if confirmPhrase}
<label class="phrase">
<span>
{confirmPhrasePrompt ?? `Type the slug to confirm:`}
<code>{confirmPhrase}</code>
</span>
<input
bind:this={inputRef}
bind:value={typed}
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
disabled={busy}
/>
</label>
{/if}
<div class="actions">
<button
type="button"
class="secondary"
bind:this={cancelRef}
onclick={onCancel}
disabled={busy}
>
{cancelLabel}
</button>
<button
type="button"
class={variant === 'danger' ? 'danger' : ''}
onclick={handleConfirm}
disabled={!phraseMatches || busy}
>
{busy ? (busyLabel ?? `${confirmLabel}`) : confirmLabel}
</button>
</div>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(2, 6, 23, 0.7);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 100;
}
.dialog {
background: #0f172a;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.5rem;
padding: 1.5rem;
width: 100%;
max-width: 32rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
}
.dialog.danger {
border-color: #7f1d1d;
}
h2 {
margin: 0 0 1rem;
font-size: 1.125rem;
}
.dialog.danger h2 {
color: #fca5a5;
}
.body {
font-size: 0.9rem;
line-height: 1.5;
color: #cbd5e1;
}
.body :global(p) {
margin: 0 0 0.75rem;
}
.body :global(p:last-child) {
margin-bottom: 0;
}
.body :global(code) {
background: #1e293b;
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
font-size: 0.85em;
}
.body :global(strong) {
color: #e2e8f0;
}
.body :global(ul) {
margin: 0.5rem 0 0.75rem;
padding-left: 1.25rem;
}
.body :global(.impact-list) {
list-style: none;
padding: 0;
margin: 0.75rem 0;
background: #1e293b;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
max-height: 12rem;
overflow-y: auto;
}
.body :global(.impact-list li) {
padding: 0.25rem 0;
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.body :global(.impact-list li + li) {
border-top: 1px solid #334155;
}
.body :global(.modal-error) {
margin-top: 0.75rem;
padding: 0.6rem 0.75rem;
border: 1px solid #b91c1c;
background: #450a0a;
color: #fecaca;
border-radius: 0.375rem;
font-size: 0.85rem;
}
.body :global(.muted) {
color: #64748b;
}
.phrase {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 1rem 0 0;
font-size: 0.85rem;
color: #cbd5e1;
}
.phrase code {
background: #1e293b;
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
color: #fca5a5;
}
.phrase input {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.phrase input:focus {
outline: none;
border-color: #38bdf8;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.5rem;
}
button {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
font: inherit;
font-weight: 600;
cursor: pointer;
}
button.secondary {
background: transparent;
color: #94a3b8;
border: 1px solid #334155;
}
button.danger {
background: #7f1d1d;
color: #fecaca;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@@ -5,6 +5,11 @@
// the same Caddy upstream so the "Test invoke" panel can hit it // the same Caddy upstream so the "Test invoke" panel can hit it
// without any cross-origin gymnastics. // without any cross-origin gymnastics.
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { browser } from '$app/environment';
import { clearSession, getToken, setSession, type AdminUser } from './auth';
export interface ScriptSandbox { export interface ScriptSandbox {
max_operations?: number; max_operations?: number;
max_string_size?: number; max_string_size?: number;
@@ -16,6 +21,7 @@ export interface ScriptSandbox {
export interface Script { export interface Script {
id: string; id: string;
app_id: string;
name: string; name: string;
description: string | null; description: string | null;
version: number; version: number;
@@ -27,11 +33,64 @@ export interface Script {
updated_at: string; updated_at: string;
} }
export interface App {
id: string;
slug: string;
name: string;
description: string | null;
created_at: string;
updated_at: string;
}
export type DomainShape = 'exact' | 'wildcard' | 'parameterized';
export interface AppDomain {
id: string;
app_id: string;
pattern: string;
shape: DomainShape;
shape_key: string;
created_at: string;
}
export interface AppLookupResponse {
id: string;
slug: string;
name: string;
description: string | null;
created_at: string;
updated_at: string;
/// Present only when the requested slug was a retired redirect.
redirect_to?: string;
}
export interface SlugCheckResponse {
ok: boolean;
conflict_kind: 'current' | 'historical' | 'invalid' | 'reserved' | null;
current_app: App | null;
reason: string | null;
}
export interface CreateAppInput {
slug: string;
name: string;
description?: string | null;
force_takeover?: boolean;
}
export interface PatchAppInput {
name?: string;
description?: string | null;
slug?: string;
force_takeover?: boolean;
}
export type HostKind = 'any' | 'strict' | 'wildcard'; export type HostKind = 'any' | 'strict' | 'wildcard';
export type PathKind = 'exact' | 'prefix' | 'param'; export type PathKind = 'exact' | 'prefix' | 'param';
export interface Route { export interface Route {
id: string; id: string;
app_id: string;
script_id: string; script_id: string;
host_kind: HostKind; host_kind: HostKind;
host: string; host: string;
@@ -101,6 +160,7 @@ export interface ExecutionLog {
} }
export interface CreateScriptInput { export interface CreateScriptInput {
app_id: string;
name: string; name: string;
description?: string | null; description?: string | null;
source: string; source: string;
@@ -134,12 +194,26 @@ export class ApiError extends Error {
} }
async function adminRequest<T>(path: string, init?: RequestInit): Promise<T> { async function adminRequest<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, { const headers: Record<string, string> = {
...init, 'content-type': 'application/json',
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) } ...((init?.headers as Record<string, string>) ?? {})
}); };
const tok = getToken();
if (tok && !headers['authorization']) {
headers['authorization'] = `Bearer ${tok}`;
}
const res = await fetch(path, { ...init, headers });
const text = await res.text(); const text = await res.text();
const parsed: unknown = text ? safeJson(text) : null; const parsed: unknown = text ? safeJson(text) : null;
if (res.status === 401) {
// Token gone stale or never present. Drop any cached session
// and bounce to login — unless we're already on it, in which
// case throw and let the login form render the error.
clearSession();
if (browser && !window.location.pathname.endsWith('/login')) {
void goto(`${base}/login`);
}
}
if (!res.ok) { if (!res.ok) {
const message = const message =
(parsed && typeof parsed === 'object' && 'error' in parsed (parsed && typeof parsed === 'object' && 'error' in parsed
@@ -158,11 +232,76 @@ function safeJson(text: string): unknown {
} }
} }
export interface AdminUserRecord {
id: string;
username: string;
is_active: boolean;
created_at: string;
last_login_at: string | null;
}
export interface CreateAdminInput {
username: string;
password: string;
}
export interface PatchAdminInput {
username?: string;
password?: string;
is_active?: boolean;
}
interface LoginResponse {
user: AdminUser;
token: string;
expires_at: string;
}
export const api = { export const api = {
health: () => fetch('/healthz').then((r) => r.text()), health: () => fetch('/healthz').then((r) => r.text()),
version: () => adminRequest<VersionInfo>('/version'), version: () => adminRequest<VersionInfo>('/version'),
auth: {
login: async (username: string, password: string): Promise<AdminUser> => {
const r = await adminRequest<LoginResponse>('/api/v1/admin/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
setSession(r.user, r.token);
return r.user;
},
logout: async (): Promise<void> => {
try {
await adminRequest<null>('/api/v1/admin/auth/logout', { method: 'POST' });
} finally {
// Always clear locally — logout is idempotent server-side
// and we don't want a network blip to strand the SPA in
// a "logged out on server, still logged in client-side"
// state.
clearSession();
}
},
me: () => adminRequest<AdminUser>('/api/v1/admin/auth/me')
},
admins: {
list: () => adminRequest<AdminUserRecord[]>('/api/v1/admin/admins'),
get: (id: string) => adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`),
create: (input: CreateAdminInput) =>
adminRequest<AdminUserRecord>('/api/v1/admin/admins', {
method: 'POST',
body: JSON.stringify(input)
}),
update: (id: string, input: PatchAdminInput) =>
adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`, {
method: 'PATCH',
body: JSON.stringify(input)
}),
remove: (id: string) =>
adminRequest<null>(`/api/v1/admin/admins/${id}`, { method: 'DELETE' })
},
routes: { routes: {
listForScript: (scriptId: string) => listForScript: (scriptId: string) =>
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`), adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
@@ -173,20 +312,23 @@ export const api = {
}), }),
remove: (routeId: string) => remove: (routeId: string) =>
adminRequest<null>(`/api/v1/admin/routes/${routeId}`, { method: 'DELETE' }), adminRequest<null>(`/api/v1/admin/routes/${routeId}`, { method: 'DELETE' }),
check: (input: RouteInput) => check: (appId: string, input: RouteInput) =>
adminRequest<CheckRouteResponse>('/api/v1/admin/routes:check', { adminRequest<CheckRouteResponse>('/api/v1/admin/routes:check', {
method: 'POST', method: 'POST',
body: JSON.stringify(input) body: JSON.stringify({ ...input, app_id: appId })
}), }),
match: (url: string, method = 'GET') => match: (appId: string, url: string, method = 'GET') =>
adminRequest<MatchRouteResponse>('/api/v1/admin/routes:match', { adminRequest<MatchRouteResponse>('/api/v1/admin/routes:match', {
method: 'POST', method: 'POST',
body: JSON.stringify({ url, method }) body: JSON.stringify({ app_id: appId, url, method })
}) })
}, },
scripts: { scripts: {
list: () => adminRequest<Script[]>('/api/v1/admin/scripts'), list: (opts: { app?: string } = {}) => {
const qs = opts.app ? `?app=${encodeURIComponent(opts.app)}` : '';
return adminRequest<Script[]>(`/api/v1/admin/scripts${qs}`);
},
get: (id: string) => adminRequest<Script>(`/api/v1/admin/scripts/${id}`), get: (id: string) => adminRequest<Script>(`/api/v1/admin/scripts/${id}`),
create: (input: CreateScriptInput) => create: (input: CreateScriptInput) =>
adminRequest<Script>('/api/v1/admin/scripts', { adminRequest<Script>('/api/v1/admin/scripts', {
@@ -211,6 +353,54 @@ export const api = {
} }
}, },
apps: {
list: () => adminRequest<App[]>('/api/v1/admin/apps'),
get: (idOrSlug: string) =>
adminRequest<AppLookupResponse>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`),
create: (input: CreateAppInput) =>
adminRequest<App>('/api/v1/admin/apps', {
method: 'POST',
body: JSON.stringify(input)
}),
update: (idOrSlug: string, input: PatchAppInput) =>
adminRequest<App>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`, {
method: 'PATCH',
body: JSON.stringify(input)
}),
remove: (idOrSlug: string, opts: { force?: boolean } = {}) => {
const qs = opts.force ? '?force=true' : '';
return adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}${qs}`,
{ method: 'DELETE' }
);
},
slugCheck: (idOrSlug: string, newSlug: string) =>
adminRequest<SlugCheckResponse>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/slug:check`,
{
method: 'POST',
body: JSON.stringify({ new_slug: newSlug })
}
)
},
domains: {
listForApp: (idOrSlug: string) =>
adminRequest<AppDomain[]>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains`
),
create: (idOrSlug: string, pattern: string) =>
adminRequest<AppDomain>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains`,
{ method: 'POST', body: JSON.stringify({ pattern }) }
),
remove: (idOrSlug: string, domainId: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains/${domainId}`,
{ method: 'DELETE' }
)
},
execute: async ( execute: async (
id: string, id: string,
body: unknown, body: unknown,

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,4 +1,4 @@
import type { HostKind, PathKind } from './api'; import type { AppDomain, HostKind, PathKind } from './api';
/** Guess a path kind from the literal user input. The dashboard pre-fills /** Guess a path kind from the literal user input. The dashboard pre-fills
* the kind selector but the user can override (the backend trusts the * the kind selector but the user can override (the backend trusts the
@@ -30,3 +30,97 @@ export function pathKindMismatchWarning(raw: string, kind: PathKind): string | n
: 'this looks like a literal path'; : 'this looks like a literal path';
return `Selected kind is "${kind}", but ${hint}. Routing will use the selected kind.`; return `Selected kind is "${kind}", but ${hint}. Routing will use the selected kind.`;
} }
/** Parse the user's free-text host input into the (kind, stored host)
* pair the API expects. Mirrors the dashboard's pre-existing storage
* convention: wildcard route hosts are stored as the bare suffix
* (`*.foo.com` → `foo.com`); display-time formatting re-adds the `*.`. */
export interface ParsedHostInput {
kind: HostKind;
/** What gets sent in the API payload as `host`. */
host: string;
/** Canonical display form, for chips/labels. */
display: string;
}
export function parseHostInput(raw: string): ParsedHostInput {
const trimmed = raw.trim();
if (trimmed === '' || trimmed === '*') {
return { kind: 'any', host: '', display: '*' };
}
if (trimmed.startsWith('*.')) {
const suffix = trimmed.slice(2);
return { kind: 'wildcard', host: suffix, display: `*.${suffix}` };
}
return { kind: 'strict', host: trimmed, display: trimmed };
}
/** Frontend mirror of `validate_route_host_against_app` in
* crates/manager-core/src/route_admin.rs. Lets us surface a live
* warning instead of waiting for a 422. The server runs the same
* check on submit — this is purely an early signal. */
export type HostClaimCheck =
| { ok: true; matched: string }
| { ok: false; reason: string };
export function checkHostAgainstClaims(
parsed: ParsedHostInput,
claims: AppDomain[]
): HostClaimCheck {
if (parsed.kind === 'any') return { ok: true, matched: '*' };
if (claims.length === 0) {
return {
ok: false,
reason: 'this app has no domain claims yet — add one in the apps Domains tab'
};
}
const hostLower = parsed.host.toLowerCase();
for (const claim of claims) {
const claimLower = claim.pattern.toLowerCase();
const claimSuffix = claimLower.split('.').slice(1).join('.');
if (parsed.kind === 'strict') {
if (claim.shape === 'exact' && hostLower === claimLower) {
return { ok: true, matched: claim.pattern };
}
if (
(claim.shape === 'wildcard' || claim.shape === 'parameterized') &&
claimSuffix &&
hostLower.endsWith(`.${claimSuffix}`)
) {
return { ok: true, matched: claim.pattern };
}
} else {
// wildcard route
if (
(claim.shape === 'wildcard' || claim.shape === 'parameterized') &&
claimSuffix === hostLower
) {
return { ok: true, matched: claim.pattern };
}
}
}
return {
ok: false,
reason: `${parsed.display} is not covered by any of this apps claims`
};
}
/** Suggestion strings for the host input's <datalist>. We always
* include the wildcard `*` (any host) as the first option, then the
* app's claims rendered in the form the route input expects. */
export function hostSuggestions(claims: AppDomain[]): string[] {
const items: string[] = ['*'];
for (const claim of claims) {
if (claim.shape === 'exact') {
items.push(claim.pattern);
} else {
// Both wildcard and parameterized claims are usable as a
// wildcard route. Render as `*.suffix` — we can't preserve
// the {param} binding name through the current route form.
const suffix = claim.pattern.split('.').slice(1).join('.');
if (suffix) items.push(`*.${suffix}`);
}
}
// Dedupe while preserving order.
return [...new Set(items)];
}

View File

@@ -0,0 +1,30 @@
// Slug normalization for app slugs, mirrored against the backend's
// validate_slug rules in crates/manager-core/src/apps_api.rs:
// - regex: ^[a-z0-9][a-z0-9-]{0,62}$
// - 1..=63 chars, lowercase ascii alphanumerics + `-`
// - must start with [a-z0-9]
// - reserved words are enforced server-side only
//
// Normalization rules are GitLab-style (close to `Babosa::Latin#to_slug`):
// 1. NFKD-decompose Unicode and drop combining marks (é → e, ñ → n,
// ü → u, etc.).
// 2. ß → ss (a single common case the strip-marks pass misses).
// 3. Lowercase.
// 4. Replace any run of non-[a-z0-9] with a single `-`.
// 5. Trim leading/trailing `-`.
// 6. Truncate to 63 chars.
export const SLUG_MAX = 63;
export function slugify(input: string): string {
if (!input) return '';
let s = input.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
s = s.toLowerCase().replace(/ß/g, 'ss');
s = s.replace(/[^a-z0-9]+/g, '-');
s = s.replace(/^-+|-+$/g, '');
if (s.length > SLUG_MAX) {
// Truncate, then re-trim in case the cut landed on a `-`.
s = s.slice(0, SLUG_MAX).replace(/-+$/g, '');
}
return s;
}

View File

@@ -1,17 +1,67 @@
<script lang="ts"> <script lang="ts">
import { base } from '$app/paths'; import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { page } from '$app/state';
import { api } from '$lib/api';
import { currentUser, getToken } from '$lib/auth';
let { children } = $props(); let { children } = $props();
let booting = $state(true);
const user = $derived($currentUser);
const isLoginRoute = $derived(page.url.pathname.endsWith('/login'));
onMount(async () => {
// Hydrate the session: if there's a token, ask the server who we
// are. On 401 the fetch wrapper already redirects to /login and
// clears state; on success we land in the SPA fully signed in.
const tok = getToken();
if (!tok) {
if (!isLoginRoute) {
await goto(`${base}/login`);
}
booting = false;
return;
}
try {
const me = await api.auth.me();
currentUser.set(me);
} catch {
// adminRequest handles 401 redirects. For other errors fall
// through — the page will surface its own error state.
}
booting = false;
});
async function handleLogout() {
await api.auth.logout();
await goto(`${base}/login`);
}
</script> </script>
<div class="shell"> <div class="shell">
<header> <header>
<a href={base + '/'} class="brand">PiCloud</a> <a href={base + '/'} class="brand">PiCloud</a>
<nav> <nav>
<a href={base + '/'}>Scripts</a> <a href={base + '/apps'}>Apps</a>
<a href={base + '/admins'}>Admins</a>
</nav> </nav>
<div class="spacer"></div>
{#if user}
<div class="usermenu">
<span class="username">{user.username}</span>
<button type="button" class="logout" onclick={handleLogout}>Logout</button>
</div>
{/if}
</header> </header>
<main> <main>
{@render children?.()} {#if booting}
<p class="boot">Loading…</p>
{:else}
{@render children?.()}
{/if}
</main> </main>
</div> </div>
@@ -45,6 +95,11 @@
text-decoration: none; text-decoration: none;
} }
nav {
display: flex;
gap: 1.5rem;
}
nav a { nav a {
color: #94a3b8; color: #94a3b8;
text-decoration: none; text-decoration: none;
@@ -55,6 +110,36 @@
color: #e2e8f0; color: #e2e8f0;
} }
.spacer {
flex: 1;
}
.usermenu {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
}
.username {
color: #cbd5e1;
}
.logout {
background: transparent;
color: #94a3b8;
border: 1px solid #334155;
padding: 0.35rem 0.75rem;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.8rem;
}
.logout:hover {
background: #1e293b;
color: #e2e8f0;
}
main { main {
flex: 1; flex: 1;
padding: 2rem; padding: 2rem;
@@ -63,4 +148,8 @@
margin: 0 auto; margin: 0 auto;
box-sizing: border-box; box-sizing: border-box;
} }
.boot {
color: #64748b;
}
</style> </style>

View File

@@ -1,249 +1,20 @@
<script lang="ts"> <script lang="ts">
import { base } from '$app/paths'; import { base } from '$app/paths';
import { api, ApiError, type Script } from '$lib/api'; import { goto } from '$app/navigation';
import { onMount } from 'svelte';
const SAMPLE_SOURCE = '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}'; // Dashboard entry: always lands on the apps list now (multi-app
// scoping makes "scripts at root" no longer meaningful — every
let scripts = $state<Script[] | null>(null); // script lives inside an app).
let listError = $state<string | null>(null); onMount(() => {
let loading = $state(true); void goto(`${base}/apps`, { replaceState: true });
let showCreate = $state(false);
let createName = $state('');
let createDescription = $state('');
let createSource = $state(SAMPLE_SOURCE);
let creating = $state(false);
let createError = $state<string | null>(null);
async function load() {
loading = true;
listError = null;
try {
scripts = await api.scripts.list();
} catch (e) {
listError = e instanceof Error ? e.message : String(e);
scripts = null;
} finally {
loading = false;
}
}
async function submitCreate(event: Event) {
event.preventDefault();
creating = true;
createError = null;
try {
await api.scripts.create({
name: createName.trim(),
description: createDescription.trim() || null,
source: createSource
});
showCreate = false;
createName = '';
createDescription = '';
createSource = SAMPLE_SOURCE;
await load();
} catch (e) {
createError = e instanceof Error ? e.message : String(e);
if (e instanceof ApiError && e.status === 422) {
createError = `Syntax error: ${createError}`;
}
} finally {
creating = false;
}
}
$effect(() => {
void load();
}); });
</script> </script>
<section> <p class="muted">Redirecting…</p>
<header class="page-header">
<h1>Scripts</h1>
<button type="button" onclick={() => (showCreate = !showCreate)}>
{showCreate ? 'Cancel' : 'New script'}
</button>
</header>
{#if showCreate}
<form class="create-form" onsubmit={submitCreate}>
<div class="row">
<label>
<span>Name</span>
<input bind:value={createName} required minlength="1" placeholder="echo" />
</label>
<label>
<span>Description</span>
<input bind:value={createDescription} placeholder="optional" />
</label>
</div>
<label class="full">
<span>Source (Rhai)</span>
<textarea bind:value={createSource} rows="10" spellcheck="false"></textarea>
</label>
{#if createError}
<div class="error">{createError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creating}>
{creating ? 'Creating…' : 'Create script'}
</button>
</div>
</form>
{/if}
{#if loading}
<p class="muted">Loading…</p>
{:else if listError}
<div class="error">
<strong>Could not load scripts.</strong>
<p>{listError}</p>
<button type="button" onclick={() => void load()}>Retry</button>
</div>
{:else if scripts && scripts.length === 0}
<p class="muted">No scripts yet. Create one above to get started.</p>
{:else if scripts}
<ul class="list">
{#each scripts as script (script.id)}
<li>
<a href="{base}/scripts/{script.id}">
<div class="primary">
<strong>{script.name}</strong>
<span class="muted">v{script.version}</span>
</div>
<div class="secondary muted">
{script.description ?? '—'}
</div>
</a>
</li>
{/each}
</ul>
{/if}
</section>
<style> <style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
button {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.muted { .muted {
color: #64748b; color: #64748b;
} }
.error {
border: 1px solid #b91c1c;
background: #450a0a;
color: #fecaca;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.create-form {
background: #1e293b;
border-radius: 0.5rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.create-form .row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 0.75rem;
}
.create-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: #cbd5e1;
}
.create-form label.full {
grid-column: 1 / -1;
}
.create-form input,
.create-form textarea {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
}
.create-form textarea {
font-family:
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
min-height: 8rem;
resize: vertical;
}
.actions {
display: flex;
justify-content: flex-end;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.list a {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
text-decoration: none;
color: inherit;
}
.list a:hover {
background: #283549;
}
.primary {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.secondary {
font-size: 0.875rem;
}
</style> </style>

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,342 @@
<script lang="ts">
import { base } from '$app/paths';
import { api, ApiError, type App } from '$lib/api';
import { slugify, SLUG_MAX } from '$lib/slugify';
let apps = $state<App[] | null>(null);
let listError = $state<string | null>(null);
let loading = $state(true);
let showCreate = $state(false);
let createSlug = $state('');
let createName = $state('');
let createDescription = $state('');
// Auto-derive slug from name until the user takes manual control of
// the slug field. Clearing the slug input releases the lock so the
// auto-derive resumes — matches the GitLab project-create UX.
let slugTouched = $state(false);
let creating = $state(false);
let createError = $state<string | null>(null);
let createHistoricalConflict = $state<App | null>(null);
function onNameInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
createName = value;
if (!slugTouched) {
createSlug = slugify(value);
}
}
function onSlugInput(event: Event) {
const raw = (event.target as HTMLInputElement).value;
const normalized = slugify(raw);
createSlug = normalized;
// Re-sync the input element so a paste of "Hello World!" shows
// "hello-world" immediately, not the raw value.
if (raw !== normalized) {
(event.target as HTMLInputElement).value = normalized;
}
slugTouched = normalized.length > 0;
}
async function load() {
loading = true;
listError = null;
try {
apps = await api.apps.list();
} catch (e) {
listError = e instanceof Error ? e.message : String(e);
apps = null;
} finally {
loading = false;
}
}
function resetCreate() {
createSlug = '';
createName = '';
createDescription = '';
createError = null;
createHistoricalConflict = null;
slugTouched = false;
}
async function submitCreate(event: Event, forceTakeover = false) {
event.preventDefault();
creating = true;
createError = null;
if (!forceTakeover) createHistoricalConflict = null;
try {
await api.apps.create({
slug: createSlug.trim(),
name: createName.trim(),
description: createDescription.trim() || null,
force_takeover: forceTakeover || undefined
});
showCreate = false;
resetCreate();
await load();
} catch (e) {
if (e instanceof ApiError && e.status === 409 && e.body) {
const body = e.body as { conflict_kind?: string; current_app?: App };
if (body.conflict_kind === 'historical' && body.current_app) {
createHistoricalConflict = body.current_app;
createError = null;
return;
}
}
createError = e instanceof Error ? e.message : String(e);
} finally {
creating = false;
}
}
$effect(() => {
void load();
});
</script>
<section>
<header class="page-header">
<h1>Apps</h1>
<button
type="button"
onclick={() => {
showCreate = !showCreate;
if (!showCreate) resetCreate();
}}
>
{showCreate ? 'Cancel' : 'New app'}
</button>
</header>
{#if showCreate}
<form class="create-form" onsubmit={(e) => submitCreate(e)}>
<div class="row">
<label>
<span>Name</span>
<input
value={createName}
oninput={onNameInput}
required
placeholder="My App"
/>
</label>
<label>
<span>Slug</span>
<input
value={createSlug}
oninput={onSlugInput}
required
pattern="[a-z0-9][a-z0-9-]*"
maxlength={SLUG_MAX}
placeholder="my-app"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
/>
</label>
</div>
<label>
<span>Description</span>
<input bind:value={createDescription} placeholder="optional" />
</label>
{#if createHistoricalConflict}
<div class="warning">
<strong>Slug previously redirected.</strong>
<p>
<code>{createSlug}</code> currently redirects to
<code>{createHistoricalConflict.slug}</code>. Using it here will break any
external links that still target the old slug.
</p>
<div class="actions">
<button type="button" class="secondary" onclick={() => (createHistoricalConflict = null)}>
Cancel
</button>
<button
type="button"
onclick={(e) => submitCreate(e, true)}
disabled={creating}
>
{creating ? 'Claiming…' : 'Claim slug anyway'}
</button>
</div>
</div>
{:else if createError}
<div class="error">{createError}</div>
{/if}
{#if !createHistoricalConflict}
<div class="actions">
<button type="submit" disabled={creating}>
{creating ? 'Creating…' : 'Create app'}
</button>
</div>
{/if}
</form>
{/if}
{#if loading}
<p class="muted">Loading…</p>
{:else if listError}
<div class="error">
<strong>Could not load apps.</strong>
<p>{listError}</p>
<button type="button" onclick={() => void load()}>Retry</button>
</div>
{:else if apps && apps.length === 0}
<p class="muted">No apps yet. Create one above to get started.</p>
{:else if apps}
<ul class="list">
{#each apps as app (app.id)}
<li>
<a href="{base}/apps/{app.slug}">
<div class="primary">
<strong>{app.name}</strong>
<span class="muted">/{app.slug}</span>
</div>
<div class="secondary muted">
{app.description ?? '—'}
</div>
</a>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
button {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
}
button.secondary {
background: transparent;
color: #94a3b8;
border: 1px solid #334155;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.muted {
color: #64748b;
}
.error {
border: 1px solid #b91c1c;
background: #450a0a;
color: #fecaca;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.warning {
border: 1px solid #ca8a04;
background: #3f2e07;
color: #fde68a;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.warning code {
background: #1e293b;
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
}
.create-form {
background: #1e293b;
border-radius: 0.5rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.create-form .row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 0.75rem;
}
.create-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: #cbd5e1;
}
.create-form input {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.list a {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
text-decoration: none;
color: inherit;
}
.list a:hover {
background: #283549;
}
.primary {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.secondary {
font-size: 0.875rem;
}
</style>

View File

@@ -0,0 +1,747 @@
<script lang="ts">
import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import {
api,
ApiError,
type App,
type AppDomain,
type Script
} from '$lib/api';
import CodeEditor from '$lib/CodeEditor.svelte';
import ConfirmModal from '$lib/ConfirmModal.svelte';
const SAMPLE_SOURCE =
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
type Tab = 'scripts' | 'domains' | 'settings';
let slug = $derived(page.params.slug ?? '');
let app = $state<App | null>(null);
let loadError = $state<string | null>(null);
let loading = $state(true);
let activeTab = $state<Tab>('scripts');
let scripts = $state<Script[]>([]);
let domains = $state<AppDomain[]>([]);
// Script create
let showCreateScript = $state(false);
let createScriptName = $state('');
let createScriptDescription = $state('');
let createScriptSource = $state(SAMPLE_SOURCE);
let creatingScript = $state(false);
let createScriptError = $state<string | null>(null);
// Domain create
let createDomainPattern = $state('');
let creatingDomain = $state(false);
let createDomainError = $state<string | null>(null);
// Settings
let editName = $state('');
let editDescription = $state('');
let editSlug = $state('');
let savingSettings = $state(false);
let settingsError = $state<string | null>(null);
let slugTakeoverNeeded = $state<App | null>(null);
// Delete confirmations
let confirmingDeleteApp = $state(false);
let deletingApp = $state(false);
let deleteAppError = $state<string | null>(null);
let domainToRemove = $state<AppDomain | null>(null);
let removingDomain = $state(false);
let removeDomainError = $state<string | null>(null);
async function loadApp() {
loading = true;
loadError = null;
try {
const fetched = await api.apps.get(slug);
if (fetched.redirect_to && fetched.redirect_to !== slug) {
await goto(`${base}/apps/${fetched.redirect_to}`, { replaceState: true });
return;
}
app = {
id: fetched.id,
slug: fetched.slug,
name: fetched.name,
description: fetched.description,
created_at: fetched.created_at,
updated_at: fetched.updated_at
};
editName = app.name;
editDescription = app.description ?? '';
editSlug = app.slug;
await Promise.all([loadScripts(app.id), loadDomains(app.id)]);
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
async function loadScripts(appId: string) {
try {
scripts = await api.scripts.list({ app: appId });
} catch (e) {
scripts = [];
loadError = e instanceof Error ? e.message : String(e);
}
}
async function loadDomains(appId: string) {
try {
domains = await api.domains.listForApp(appId);
} catch (e) {
domains = [];
loadError = e instanceof Error ? e.message : String(e);
}
}
async function submitCreateScript(event: Event) {
event.preventDefault();
if (!app) return;
creatingScript = true;
createScriptError = null;
try {
await api.scripts.create({
app_id: app.id,
name: createScriptName.trim(),
description: createScriptDescription.trim() || null,
source: createScriptSource
});
showCreateScript = false;
createScriptName = '';
createScriptDescription = '';
createScriptSource = SAMPLE_SOURCE;
await loadScripts(app.id);
} catch (e) {
createScriptError = e instanceof Error ? e.message : String(e);
if (e instanceof ApiError && e.status === 422) {
createScriptError = `Validation: ${createScriptError}`;
}
} finally {
creatingScript = false;
}
}
async function submitCreateDomain(event: Event) {
event.preventDefault();
if (!app) return;
creatingDomain = true;
createDomainError = null;
try {
await api.domains.create(app.id, createDomainPattern.trim());
createDomainPattern = '';
await loadDomains(app.id);
} catch (e) {
createDomainError = e instanceof Error ? e.message : String(e);
} finally {
creatingDomain = false;
}
}
function askRemoveDomain(d: AppDomain) {
removeDomainError = null;
domainToRemove = d;
}
async function confirmRemoveDomain() {
if (!app || !domainToRemove) return;
removingDomain = true;
removeDomainError = null;
try {
await api.domains.remove(app.id, domainToRemove.id);
domainToRemove = null;
await loadDomains(app.id);
} catch (e) {
removeDomainError = e instanceof Error ? e.message : String(e);
} finally {
removingDomain = false;
}
}
async function saveSettings(event: Event, forceTakeover = false) {
event.preventDefault();
if (!app) return;
savingSettings = true;
settingsError = null;
if (!forceTakeover) slugTakeoverNeeded = null;
try {
const slugChanged = editSlug.trim() !== app.slug;
const updated = await api.apps.update(app.id, {
name: editName.trim() !== app.name ? editName.trim() : undefined,
description:
editDescription !== (app.description ?? '')
? editDescription || null
: undefined,
slug: slugChanged ? editSlug.trim() : undefined,
force_takeover: forceTakeover || undefined
});
if (slugChanged) {
await goto(`${base}/apps/${updated.slug}`, { replaceState: true });
return;
}
app = updated;
} catch (e) {
if (e instanceof ApiError && e.status === 409 && e.body) {
const body = e.body as { conflict_kind?: string; current_app?: App };
if (body.conflict_kind === 'historical' && body.current_app) {
slugTakeoverNeeded = body.current_app;
settingsError = null;
return;
}
}
settingsError = e instanceof Error ? e.message : String(e);
} finally {
savingSettings = false;
}
}
function askDeleteApp() {
deleteAppError = null;
confirmingDeleteApp = true;
}
async function confirmDeleteApp() {
if (!app) return;
deletingApp = true;
deleteAppError = null;
try {
// force=true cascades scripts (and thereby their routes +
// execution logs); domains and slug-history rows cascade off
// the app row itself.
await api.apps.remove(app.id, { force: true });
await goto(`${base}/apps`);
} catch (e) {
deleteAppError = e instanceof Error ? e.message : String(e);
} finally {
deletingApp = false;
}
}
$effect(() => {
void loadApp();
});
</script>
{#if loading && !app}
<p class="muted">Loading…</p>
{:else if loadError && !app}
<div class="error">
<strong>Could not load app.</strong>
<p>{loadError}</p>
<a href="{base}/apps">Back to apps</a>
</div>
{:else if app}
<header class="page-header">
<div>
<div class="breadcrumb">
<a href="{base}/apps">Apps</a> / <code>{app.slug}</code>
</div>
<h1>{app.name}</h1>
{#if app.description}<p class="muted">{app.description}</p>{/if}
</div>
</header>
<nav class="tabs">
<button
type="button"
class:active={activeTab === 'scripts'}
onclick={() => (activeTab = 'scripts')}>Scripts ({scripts.length})</button
>
<button
type="button"
class:active={activeTab === 'domains'}
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
>
<button
type="button"
class:active={activeTab === 'settings'}
onclick={() => (activeTab = 'settings')}>Settings</button
>
</nav>
{#if activeTab === 'scripts'}
<section>
<div class="row">
<h2>Scripts</h2>
<button
type="button"
onclick={() => (showCreateScript = !showCreateScript)}
>
{showCreateScript ? 'Cancel' : 'New script'}
</button>
</div>
{#if showCreateScript}
<form class="create-form" onsubmit={submitCreateScript}>
<div class="row">
<label>
<span>Name</span>
<input bind:value={createScriptName} required placeholder="echo" />
</label>
<label>
<span>Description</span>
<input bind:value={createScriptDescription} placeholder="optional" />
</label>
</div>
<label class="full">
<span>Source (Rhai)</span>
<CodeEditor bind:value={createScriptSource} language="rhai" minHeight="14rem" />
</label>
{#if createScriptError}
<div class="error">{createScriptError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creatingScript}>
{creatingScript ? 'Creating…' : 'Create script'}
</button>
</div>
</form>
{/if}
{#if scripts.length === 0}
<p class="muted">No scripts in this app yet.</p>
{:else}
<ul class="list">
{#each scripts as script (script.id)}
<li>
<a href="{base}/scripts/{script.id}">
<div class="primary">
<strong>{script.name}</strong>
<span class="muted">v{script.version}</span>
</div>
<div class="secondary muted">{script.description ?? '—'}</div>
</a>
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'domains'}
<section>
<h2>Domain claims</h2>
<p class="muted">
Hosts this app answers on. Routes inside this app can only bind to
these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for
wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture.
</p>
<form class="create-form inline" onsubmit={submitCreateDomain}>
<input
bind:value={createDomainPattern}
required
placeholder="app.example.com"
/>
<button type="submit" disabled={creatingDomain}>
{creatingDomain ? 'Adding…' : 'Add domain'}
</button>
</form>
{#if createDomainError}
<div class="error">{createDomainError}</div>
{/if}
{#if domains.length === 0}
<p class="muted">No domain claims yet.</p>
{:else}
<ul class="list">
{#each domains as d (d.id)}
<li class="domain-row">
<div>
<code>{d.pattern}</code>
<span class="muted">{d.shape}</span>
</div>
<button
type="button"
class="secondary danger"
onclick={() => askRemoveDomain(d)}
>
Delete
</button>
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'settings'}
<section>
<h2>Settings</h2>
<form class="create-form" onsubmit={(e) => saveSettings(e)}>
<label>
<span>Name</span>
<input bind:value={editName} required />
</label>
<label>
<span>Description</span>
<input bind:value={editDescription} />
</label>
<label>
<span>Slug</span>
<input
bind:value={editSlug}
required
pattern="[a-z0-9][a-z0-9-]*"
/>
<small class="muted">
Renaming records the old slug as a permanent 301 redirect.
</small>
</label>
{#if slugTakeoverNeeded}
<div class="warning">
<strong>Slug previously redirected.</strong>
<p>
<code>{editSlug}</code> currently redirects to
<code>{slugTakeoverNeeded.slug}</code>. Renaming to it will break old
links.
</p>
<div class="actions">
<button
type="button"
class="secondary"
onclick={() => (slugTakeoverNeeded = null)}
>
Cancel
</button>
<button
type="button"
onclick={(e) => saveSettings(e, true)}
disabled={savingSettings}
>
{savingSettings ? 'Renaming…' : 'Rename anyway'}
</button>
</div>
</div>
{:else if settingsError}
<div class="error">{settingsError}</div>
{/if}
{#if !slugTakeoverNeeded}
<div class="actions">
<button type="submit" disabled={savingSettings}>
{savingSettings ? 'Saving…' : 'Save changes'}
</button>
</div>
{/if}
</form>
<div class="danger-zone">
<h3>Delete app</h3>
<p class="muted">
Permanently removes the app along with all its scripts, routes,
execution logs, and domain claims.
</p>
<button type="button" class="danger" onclick={askDeleteApp}>Delete app</button>
</div>
</section>
{/if}
{#if confirmingDeleteApp}
<ConfirmModal
title="Delete app “{app.name}”"
variant="danger"
confirmLabel="Delete app"
busyLabel="Deleting…"
confirmPhrase={app.slug}
confirmPhrasePrompt="Type the app slug to confirm:"
busy={deletingApp}
onConfirm={confirmDeleteApp}
onCancel={() => (confirmingDeleteApp = false)}
>
<p>
This will <strong>permanently delete</strong> everything inside
<strong>{app.name}</strong>. There is no undo.
</p>
<ul class="impact-list">
<li>
<span>Scripts</span><strong>{scripts.length}</strong>
</li>
<li>
<span>Domain claims</span><strong>{domains.length}</strong>
</li>
<li>
<span>Routes &amp; execution logs</span><strong>all</strong>
</li>
</ul>
{#if domains.length > 0}
<p>The following hosts will stop pointing at this app:</p>
<ul class="impact-list">
{#each domains as d (d.id)}
<li>
<code>{d.pattern}</code><span class="muted">{d.shape}</span>
</li>
{/each}
</ul>
{/if}
{#if deleteAppError}
<p class="modal-error">{deleteAppError}</p>
{/if}
</ConfirmModal>
{/if}
{#if domainToRemove}
<ConfirmModal
title="Delete domain claim"
variant="danger"
confirmLabel="Delete claim"
busyLabel="Deleting…"
busy={removingDomain}
onConfirm={confirmRemoveDomain}
onCancel={() => (domainToRemove = null)}
>
<p>
<strong>{app.name}</strong> will stop answering on
<code>{domainToRemove.pattern}</code>.
</p>
<p class="muted">
Routes already bound to this host are blocked from deletion by the
API; if so, youll see an error here.
</p>
{#if removeDomainError}
<p class="modal-error">{removeDomainError}</p>
{/if}
</ConfirmModal>
{/if}
{/if}
<style>
.page-header {
margin-bottom: 1rem;
}
.breadcrumb {
font-size: 0.875rem;
color: #64748b;
margin-bottom: 0.25rem;
}
.breadcrumb a {
color: #94a3b8;
text-decoration: none;
}
.breadcrumb a:hover {
color: #e2e8f0;
}
.breadcrumb code {
background: #1e293b;
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
h2 {
font-size: 1.125rem;
margin: 0 0 1rem;
}
h3 {
font-size: 1rem;
margin: 0 0 0.5rem;
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid #1e293b;
margin-bottom: 1.25rem;
}
.tabs button {
background: transparent;
color: #94a3b8;
border: none;
padding: 0.6rem 1rem;
font: inherit;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tabs button:hover {
color: #e2e8f0;
}
.tabs button.active {
color: #38bdf8;
border-bottom-color: #38bdf8;
}
button {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
}
button.secondary {
background: transparent;
color: #94a3b8;
border: 1px solid #334155;
}
button.danger {
background: #7f1d1d;
color: #fecaca;
}
button.secondary.danger {
background: transparent;
color: #fca5a5;
border-color: #7f1d1d;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.muted {
color: #64748b;
}
.error {
border: 1px solid #b91c1c;
background: #450a0a;
color: #fecaca;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.warning {
border: 1px solid #ca8a04;
background: #3f2e07;
color: #fde68a;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.warning code {
background: #1e293b;
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
}
.create-form {
background: #1e293b;
border-radius: 0.5rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.create-form.inline {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.create-form .row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 0.75rem;
}
.create-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: #cbd5e1;
}
.create-form label.full {
grid-column: 1 / -1;
}
.create-form input {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
flex: 1;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.list a {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
text-decoration: none;
color: inherit;
}
.list a:hover {
background: #283549;
}
.domain-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
}
.domain-row code {
background: #0b1220;
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
}
.primary {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.secondary {
font-size: 0.875rem;
}
.danger-zone {
margin-top: 2rem;
padding: 1rem;
border: 1px solid #7f1d1d;
border-radius: 0.5rem;
background: #1e0a0a;
}
</style>

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

@@ -5,6 +5,7 @@
import { import {
api, api,
ApiError, ApiError,
type AppDomain,
type ExecutionLog, type ExecutionLog,
type Route, type Route,
type RouteInput, type RouteInput,
@@ -12,7 +13,26 @@
type VersionInfo type VersionInfo
} from '$lib/api'; } from '$lib/api';
import { logLevelColor, statusColor } from '$lib/styles'; import { logLevelColor, statusColor } from '$lib/styles';
import { guessHostKind, guessPathKind, pathKindMismatchWarning } from '$lib/route-utils'; import {
checkHostAgainstClaims,
guessPathKind,
hostSuggestions,
parseHostInput,
pathKindMismatchWarning
} from '$lib/route-utils';
import CodeEditor from '$lib/CodeEditor.svelte';
import { format as formatRhai } from '$lib/rhai';
/// Pretty-print a JSON string in place, leaving it untouched if the
/// input doesn't parse. The error state is shown next to the button
/// so users see why it didn't reformat.
function formatJson(s: string): { ok: true; text: string } | { ok: false; error: string } {
try {
return { ok: true, text: JSON.stringify(JSON.parse(s), null, 2) };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}
}
// Route is `/scripts/[id]` so `page.params.id` is always present. // Route is `/scripts/[id]` so `page.params.id` is always present.
let id = $derived(page.params.id ?? ''); let id = $derived(page.params.id ?? '');
@@ -25,6 +45,9 @@
let scriptLoading = $state(true); let scriptLoading = $state(true);
let info = $state<VersionInfo | null>(null); let info = $state<VersionInfo | null>(null);
let appSlug = $state<string | null>(null);
let appDomains = $state<AppDomain[]>([]);
async function loadScript() { async function loadScript() {
scriptLoading = true; scriptLoading = true;
scriptError = null; scriptError = null;
@@ -35,6 +58,23 @@
editableDescription = script.description ?? ''; editableDescription = script.description ?? '';
editableTimeout = script.timeout_seconds; editableTimeout = script.timeout_seconds;
editableSandbox = { ...(script.sandbox ?? {}) }; editableSandbox = { ...(script.sandbox ?? {}) };
// Resolve the owning app's slug for the breadcrumb and its
// domain claims for the route form's suggestions + live
// validation. Both are non-fatal — the page works without
// them.
const appId = script.app_id;
void api.apps
.get(appId)
.then((a) => {
appSlug = a.slug;
})
.catch(() => {});
void api.domains
.listForApp(appId)
.then((d) => {
appDomains = d;
})
.catch(() => {});
} catch (e) { } catch (e) {
scriptError = e instanceof Error ? e.message : String(e); scriptError = e instanceof Error ? e.message : String(e);
script = null; script = null;
@@ -55,6 +95,17 @@
let editableSource = $state(''); let editableSource = $state('');
let savingSource = $state(false); let savingSource = $state(false);
let saveSourceError = $state<string | null>(null); let saveSourceError = $state<string | null>(null);
let rhaiFormatError = $state<string | null>(null);
function formatRhaiSource() {
const r = formatRhai(editableSource);
if (r.ok) {
editableSource = r.text;
rhaiFormatError = null;
} else {
rhaiFormatError = `Parse error: ${r.error.message} (line ${r.error.line}, position ${r.error.column})`;
}
}
async function saveSource() { async function saveSource() {
if (!script) return; if (!script) return;
@@ -72,7 +123,28 @@
let testBody = $state('{}'); let testBody = $state('{}');
let testHeaders = $state('{}'); let testHeaders = $state('{}');
let testBodyFormatError = $state<string | null>(null);
let testHeadersFormatError = $state<string | null>(null);
let testInProgress = $state(false); let testInProgress = $state(false);
function formatTestBody() {
const r = formatJson(testBody);
if (r.ok) {
testBody = r.text;
testBodyFormatError = null;
} else {
testBodyFormatError = r.error;
}
}
function formatTestHeaders() {
const r = formatJson(testHeaders);
if (r.ok) {
testHeaders = r.text;
testHeadersFormatError = null;
} else {
testHeadersFormatError = r.error;
}
}
let testResult = $state<{ let testResult = $state<{
status: number; status: number;
headers: Record<string, string>; headers: Record<string, string>;
@@ -120,12 +192,14 @@
let routesLoading = $state(true); let routesLoading = $state(true);
let showAddRoute = $state(false); let showAddRoute = $state(false);
let newRoutePath = $state(''); let newRoutePath = $state('/');
let newRoutePathKind = $state<'exact' | 'prefix' | 'param'>('exact'); let newRoutePathKind = $state<'exact' | 'prefix' | 'param'>('exact');
let newRouteHost = $state(''); // Host input is free-form; the kind is derived from what the user
let newRouteHostKind = $state<'any' | 'strict' | 'wildcard'>('any'); // typed (see `parsedHost` below). Default `*` = Any, matching the
// canonical display form for an unrestricted host.
let newRouteHost = $state('*');
let newRouteMethod = $state(''); let newRouteMethod = $state('');
let routeKindAutoUpdate = $state(true); let pathKindAutoUpdate = $state(true);
let creatingRoute = $state(false); let creatingRoute = $state(false);
let createRouteError = $state<string | null>(null); let createRouteError = $state<string | null>(null);
@@ -133,17 +207,17 @@
let previewMethod = $state('GET'); let previewMethod = $state('GET');
let previewResult = $state<string | null>(null); let previewResult = $state<string | null>(null);
// Auto-update kind selectors as the user types. // Auto-update the path-kind selector as the user types.
$effect(() => { $effect(() => {
if (routeKindAutoUpdate) { if (pathKindAutoUpdate) {
newRoutePathKind = guessPathKind(newRoutePath); newRoutePathKind = guessPathKind(newRoutePath);
} }
}); });
$effect(() => {
if (routeKindAutoUpdate) { let parsedHost = $derived(parseHostInput(newRouteHost));
newRouteHostKind = guessHostKind(newRouteHost); let hostCheck = $derived(checkHostAgainstClaims(parsedHost, appDomains));
} let hostDatalistId = 'route-host-suggestions';
}); let suggestions = $derived(hostSuggestions(appDomains));
let pathKindWarning = $derived( let pathKindWarning = $derived(
newRoutePath.trim() ? pathKindMismatchWarning(newRoutePath, newRoutePathKind) : null newRoutePath.trim() ? pathKindMismatchWarning(newRoutePath, newRoutePathKind) : null
@@ -167,18 +241,18 @@
createRouteError = null; createRouteError = null;
try { try {
const input: RouteInput = { const input: RouteInput = {
host_kind: newRouteHostKind, host_kind: parsedHost.kind,
host: newRouteHostKind === 'any' ? '' : newRouteHost.trim(), host: parsedHost.host,
path_kind: newRoutePathKind, path_kind: newRoutePathKind,
path: newRoutePath.trim(), path: newRoutePath.trim(),
method: newRouteMethod.trim() || null method: newRouteMethod.trim() || null
}; };
await api.routes.create(id, input); await api.routes.create(id, input);
showAddRoute = false; showAddRoute = false;
newRoutePath = ''; newRoutePath = '/';
newRouteHost = ''; newRouteHost = '*';
newRouteMethod = ''; newRouteMethod = '';
routeKindAutoUpdate = true; pathKindAutoUpdate = true;
await loadRoutes(); await loadRoutes();
} catch (e) { } catch (e) {
if (e instanceof ApiError && e.status === 409) { if (e instanceof ApiError && e.status === 409) {
@@ -206,8 +280,9 @@
async function runPreview() { async function runPreview() {
previewResult = null; previewResult = null;
if (!script) return;
try { try {
const r = await api.routes.match(previewUrl, previewMethod); const r = await api.routes.match(script.app_id, previewUrl, previewMethod);
if (r.matched) { if (r.matched) {
const ours = r.matched.script_id === id; const ours = r.matched.script_id === id;
const tag = ours ? '✓ matches THIS script' : '⚠ matches a DIFFERENT script'; const tag = ours ? '✓ matches THIS script' : '⚠ matches a DIFFERENT script';
@@ -323,6 +398,13 @@
{:else if script} {:else if script}
<header class="page-header"> <header class="page-header">
<div> <div>
{#if appSlug}
<div class="breadcrumb">
<a href="{base}/apps">Apps</a> /
<a href="{base}/apps/{appSlug}">{appSlug}</a> / Scripts /
<code>{script.name}</code>
</div>
{/if}
<h1>{script.name}</h1> <h1>{script.name}</h1>
<p class="muted"> <p class="muted">
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'} v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
@@ -351,8 +433,16 @@
{#if tab === 'edit'} {#if tab === 'edit'}
<div class="grid"> <div class="grid">
<section class="card"> <section class="card">
<h2>Source</h2> <header class="editor-header">
<textarea bind:value={editableSource} rows="14" spellcheck="false"></textarea> <h2>Source</h2>
<button type="button" class="ghost small" onclick={formatRhaiSource}>
Format
</button>
</header>
<CodeEditor bind:value={editableSource} language="rhai" minHeight="22rem" />
{#if rhaiFormatError}
<div class="error inline">{rhaiFormatError}</div>
{/if}
{#if saveSourceError} {#if saveSourceError}
<div class="error inline">{saveSourceError}</div> <div class="error inline">{saveSourceError}</div>
{/if} {/if}
@@ -369,14 +459,30 @@
<section class="card"> <section class="card">
<h2>Test invoke</h2> <h2>Test invoke</h2>
<label> <div class="json-block">
<span>Request body (JSON)</span> <header class="json-header">
<textarea bind:value={testBody} rows="5" spellcheck="false"></textarea> <span>Request body (JSON)</span>
</label> <button type="button" class="ghost small" onclick={formatTestBody}>
<label> Format
<span>Headers (JSON object)</span> </button>
<textarea bind:value={testHeaders} rows="3" spellcheck="false"></textarea> </header>
</label> <CodeEditor bind:value={testBody} language="json" minHeight="9rem" />
{#if testBodyFormatError}
<div class="error inline">{testBodyFormatError}</div>
{/if}
</div>
<div class="json-block">
<header class="json-header">
<span>Headers (JSON object)</span>
<button type="button" class="ghost small" onclick={formatTestHeaders}>
Format
</button>
</header>
<CodeEditor bind:value={testHeaders} language="json" minHeight="6rem" />
{#if testHeadersFormatError}
<div class="error inline">{testHeadersFormatError}</div>
{/if}
</div>
<div class="actions"> <div class="actions">
<button type="button" onclick={invoke} disabled={testInProgress}> <button type="button" onclick={invoke} disabled={testInProgress}>
{testInProgress ? 'Running…' : 'Send'} {testInProgress ? 'Running…' : 'Send'}
@@ -415,7 +521,7 @@
<span>Path</span> <span>Path</span>
<input <input
bind:value={newRoutePath} bind:value={newRoutePath}
oninput={() => (routeKindAutoUpdate = true)} oninput={() => (pathKindAutoUpdate = true)}
placeholder="/greet, /greet/:name, /webhooks/*" placeholder="/greet, /greet/:name, /webhooks/*"
required required
autocomplete="off" autocomplete="off"
@@ -426,7 +532,7 @@
<span>Path kind</span> <span>Path kind</span>
<select <select
bind:value={newRoutePathKind} bind:value={newRoutePathKind}
onchange={() => (routeKindAutoUpdate = false)} onchange={() => (pathKindAutoUpdate = false)}
> >
<option value="exact">exact</option> <option value="exact">exact</option>
<option value="param">param</option> <option value="param">param</option>
@@ -445,31 +551,43 @@
</select> </select>
</label> </label>
</div> </div>
<div class="row"> <label class="full">
<label> <span class="host-label">
<span>Host kind</span> Host
<select <span class="kind-chip kind-{parsedHost.kind}">
bind:value={newRouteHostKind} {parsedHost.kind}
onchange={() => (routeKindAutoUpdate = false)} </span>
> </span>
<option value="any">ANY</option> <input
<option value="strict">strict</option> bind:value={newRouteHost}
<option value="wildcard">wildcard</option> placeholder="* · app.example.com · *.example.com"
</select> list={hostDatalistId}
</label> autocomplete="off"
<label class:disabled={newRouteHostKind === 'any'}> autocapitalize="off"
<span>Host</span> autocorrect="off"
<input spellcheck="false"
bind:value={newRouteHost} />
oninput={() => (routeKindAutoUpdate = true)} <datalist id={hostDatalistId}>
disabled={newRouteHostKind === 'any'} {#each suggestions as s (s)}
placeholder={newRouteHostKind === 'wildcard' <option value={s}></option>
? '*.example.com' {/each}
: 'sub.example.com'} </datalist>
autocomplete="off" <small class="muted">
/> <code>*</code> = any host claimed by this app ·
</label> <code>*.foo.com</code> = wildcard · <code>foo.com</code> =
</div> strict
</small>
</label>
{#if !hostCheck.ok}
<div class="warning inline">
{hostCheck.reason}.
{#if appDomains.length > 0}
Claims:
{#each appDomains as d, i (d.id)}<code>{d.pattern}</code>{#if i < appDomains.length - 1},
{/if}{/each}
{/if}
</div>
{/if}
{#if pathKindWarning} {#if pathKindWarning}
<div class="warning inline">{pathKindWarning}</div> <div class="warning inline">{pathKindWarning}</div>
{/if} {/if}
@@ -687,6 +805,23 @@
align-items: flex-start; align-items: flex-start;
margin: 1rem 0 1.5rem; margin: 1rem 0 1.5rem;
} }
.breadcrumb {
font-size: 0.875rem;
color: #64748b;
margin-bottom: 0.25rem;
}
.breadcrumb a {
color: #94a3b8;
text-decoration: none;
}
.breadcrumb a:hover {
color: #e2e8f0;
}
.breadcrumb code {
background: #1e293b;
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
}
h1 { h1 {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
@@ -727,6 +862,32 @@
color: #94a3b8; color: #94a3b8;
border: 1px solid #334155; border: 1px solid #334155;
} }
button.small {
padding: 0.2rem 0.6rem;
font-size: 0.75rem;
font-weight: 500;
}
.json-block {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.json-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
color: #cbd5e1;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.editor-header h2 {
margin: 0;
}
button.link { button.link {
background: transparent; background: transparent;
color: #94a3b8; color: #94a3b8;
@@ -798,7 +959,6 @@
align-items: center; align-items: center;
} }
textarea,
input, input,
select { select {
background: #0b1220; background: #0b1220;
@@ -811,12 +971,6 @@
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
textarea {
font-family:
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
font-size: 0.85rem;
resize: vertical;
}
input:disabled, input:disabled,
select:disabled { select:disabled {
opacity: 0.5; opacity: 0.5;
@@ -828,9 +982,6 @@
font-size: 0.85rem; font-size: 0.85rem;
color: #cbd5e1; color: #cbd5e1;
} }
label.disabled {
opacity: 0.6;
}
label.full { label.full {
width: 100%; width: 100%;
} }
@@ -947,6 +1098,31 @@
background: #581c87; background: #581c87;
color: #e9d5ff; color: #e9d5ff;
} }
.host-label {
display: flex;
align-items: center;
gap: 0.5rem;
}
.kind-chip {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
padding: 0.1rem 0.4rem;
border-radius: 0.25rem;
letter-spacing: 0.03em;
}
.kind-chip.kind-any {
background: #1e293b;
color: #cbd5e1;
}
.kind-chip.kind-strict {
background: #14532d;
color: #bbf7d0;
}
.kind-chip.kind-wildcard {
background: #1e3a8a;
color: #bfdbfe;
}
.route-row .method { .route-row .method {
color: #fbbf24; color: #fbbf24;
font-weight: 700; font-weight: 700;

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

@@ -40,6 +40,12 @@ services:
DATABASE_URL: postgres://${POSTGRES_USER:-picloud}:${POSTGRES_PASSWORD:-picloud}@postgres:5432/${POSTGRES_DB:-picloud} DATABASE_URL: postgres://${POSTGRES_USER:-picloud}:${POSTGRES_PASSWORD:-picloud}@postgres:5432/${POSTGRES_DB:-picloud}
RUST_LOG: ${RUST_LOG:-info} RUST_LOG: ${RUST_LOG:-info}
PICLOUD_PUBLIC_BASE_URL: ${PICLOUD_PUBLIC_BASE_URL:-http://localhost:8000} PICLOUD_PUBLIC_BASE_URL: ${PICLOUD_PUBLIC_BASE_URL:-http://localhost:8000}
# Bootstrap admin (Phase 3a). Read once on first start to seed the
# admin_users table; ignored on subsequent boots if the table is
# non-empty. No defaults on purpose — leaving these unset in prod
# is a foot-gun. For dev, .env.example documents sensible values.
PICLOUD_ADMIN_USERNAME: ${PICLOUD_ADMIN_USERNAME:?set PICLOUD_ADMIN_USERNAME (see .env.example)}
PICLOUD_ADMIN_PASSWORD: ${PICLOUD_ADMIN_PASSWORD:?set PICLOUD_ADMIN_PASSWORD (see .env.example)}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

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. 2. **Runtime self-report.** `GET /version` returns every surface version. Dashboards, monitoring, inter-service handshakes, and humans all read from one source. `/healthz` stays a plain `"ok"` string for k8s probes — version negotiation is a separate concern.
3. **Golden SDK contract tests.** `tests/sdk_contract/` Rhai scripts exercise every SDK surface and must pass on every commit. The contract is the test. 3. **Golden SDK contract tests.** `tests/sdk_contract/` Rhai scripts exercise every SDK surface and must pass on every commit. The contract is the test.
4. **Migration replay test.** An integration test that boots a fresh Postgres, applies every migration in order, and asserts the resulting schema. Catches the most common mistake (edited-not-added migration). 4. **Migration replay test.** An integration test that boots a fresh Postgres, applies every migration in order, and asserts the resulting schema. Catches the most common mistake (edited-not-added migration).
5. **CI guardrail script.** A small diff-aware check that: 5. **CI guardrail script.** [`scripts/check-versioning.sh`](../scripts/check-versioning.sh) — runs the structural checks that don't need git history:
- Fails if `SDK_VERSION`'s major changed without a `CHANGELOG.md` breaking-change entry - Migration files are numbered sequentially from `0001_*.sql` with no gaps.
- Fails if a new file appeared in `migrations/` that isn't the next sequential number - `SDK_VERSION` parses as `MAJOR.MINOR` (numeric, no extra components).
- Fails if a route handler removed or retyped a public field without a `BREAKING:` line in the commit message - `[workspace.package].version` parses as `MAJOR.MINOR.PATCH`.
(3) through (5) are wired in over the next few PRs; (1) and (2) land in the same commit as this document. Run manually as `bash scripts/check-versioning.sh`. Wires into CI when CI exists. Deferred to the same future PR that introduces CI: SDK-major-bump-needs-CHANGELOG and `BREAKING:` commit-message annotation (both need git history + a CHANGELOG file that doesn't exist yet).
(3) and (4) are now in place: [`crates/executor-core/tests/sdk_contract.rs`](../crates/executor-core/tests/sdk_contract.rs) holds the SDK contract suite; [`crates/manager-core/tests/schema_snapshot.rs`](../crates/manager-core/tests/schema_snapshot.rs) holds the schema snapshot guard.
--- ---
@@ -124,10 +126,10 @@ A surface can hit its own `1.0` independently of the product. The SDK in particu
| | Version | | | Version |
|---|---| |---|---|
| Product | `0.5.0` | | Product | `0.5.1` |
| SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) | | SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) |
| API | `1` | | API | `1` (additive: `Script.app_id`, `Route.app_id`, `ExecutionLog.app_id`, new `/api/v1/admin/apps/*` endpoints, `?app=` filter on script list) |
| Schema | `3` (matches `migrations/0003_routes.sql`) | | Schema | `5` (matches `migrations/0005_apps.sql`) |
| Wire | `1` (reserved; cluster mode not implemented) | | Wire | `1` (reserved; cluster mode not implemented) |
Read live from `GET /version` on any running instance. Read live from `GET /version` on any running instance.

126
scripts/check-versioning.sh Executable file
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,363 @@ volumes:
--- ---
## 11.4 Admin Auth (Phase 3a) — Shipped
**Status**: shipped. Implementation lives in `crates/manager-core/src/{auth,auth_*,admin_user_repo,admin_session_repo,admin_users_api}.rs`; migration `0004_admin_auth.sql`.
**Purpose**: gate the admin API (`/api/v1/admin/*`) and dashboard (`/admin/*`) behind per-user authentication. Before this phase the surface was open — anyone reaching the bound port could create, edit, and delete scripts.
**Why per-user, not a shared secret**: shared admin passwords get shared between humans, leave no audit trail, and can't be revoked per-person. Per-user accounts solve all three. The initial cut deliberately stops there — no roles, no per-app permissions — because that scope is small enough to ship in a single phase without blocking Phase 3b. Roles + per-app permissions are queued for v1.3+.
### Naming: `admin_users` vs `users`
We reserve the unqualified **`users`** table for the v1.1+ Rhai SDK feature (script-level end users — see §8.4). Platform-operator accounts live in **`admin_users`**. They are different concepts and never share rows, even when a PiCloud install hosts apps that themselves run user management.
### Schema
```sql
CREATE TABLE admin_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, -- Argon2id (PHC string)
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ
);
CREATE TABLE admin_sessions (
token_hash TEXT PRIMARY KEY, -- SHA-256(hex) of the bearer token; raw token only exists in the login response + cookie
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX admin_sessions_user_idx ON admin_sessions (user_id);
CREATE INDEX admin_sessions_expiry_idx ON admin_sessions (expires_at);
```
`is_active` was added to the shipped cut so admins can be deactivated (login rejected, sessions wiped) without losing audit history; deletion still cascades sessions through the FK.
**Password hashing**: Argon2id with default OWASP parameters. This also resolves the v1.1+ open question about user-password hashing (§10) — the platform settles on Argon2id once, here.
### Bootstrap
On startup, if `admin_users` is empty, the manager reads `PICLOUD_ADMIN_USERNAME` plus a password from env (or a config file) and inserts the row. Two password env vars are accepted, in this precedence:
1. **`PICLOUD_ADMIN_PASSWORD_HASH`** (recommended) — pre-computed Argon2id PHC-format hash. The platform validates the string parses, then inserts it as-is. This avoids the raw password ever being written into env/compose files or process listings.
2. **`PICLOUD_ADMIN_PASSWORD`** (fallback) — raw password. The platform hashes it with Argon2id defaults and discards the raw value. Simpler for first-time setup; less ideal for committed configs.
If both are set, the hash wins and the raw value is ignored (with a warning logged). If neither is set on a fresh install, startup fails with a clear error pointing at the env vars.
**Once that bootstrap row exists, the env vars become inert** — restarting with different values does not change the password. This is deliberate: the env var is a one-time setup hatch, not a recovery backdoor (a backdoor would let anyone with systemd-unit or compose-file access override any admin's password).
Recovery is a separate manual flow:
```sh
picloud admin reset-password <username>
```
This requires shell access on the host (and therefore implies the operator already controls the box).
### Login & Session
```
POST /api/v1/admin/auth/login
{ "username": "...", "password": "..." }
→ 200 OK
Set-Cookie: picloud_session=<token>; HttpOnly; Secure; SameSite=Lax; Path=/
{ "user": { "id": "...", "username": "..." }, "token": "<token>", "expires_at": "..." }
```
Token format: opaque random string (32 bytes base64). Stored hashed; the raw value lives only in the login response and the session cookie. The same token works as a bearer credential for non-browser clients:
```
Authorization: Bearer <token>
```
One token system serves both dashboard and CLI/CI clients — no separate "API token" concept. Personal long-lived API tokens can be added later as a distinct `admin_api_tokens` table if demand appears.
**Session TTL** is a **24-hour sliding window**: each authenticated request bumps `expires_at` to `now + ttl` and `last_used_at` to `now`. The TTL itself is configurable per deploy via `PICLOUD_SESSION_TTL_HOURS` (default `24`). A separate background sweep deletes rows where `expires_at < now()`; until that sweep runs, expired rows are also rejected at auth-check time (so a stuck sweep can't extend session lifetime past expiry).
Companion endpoints:
- `POST /api/v1/admin/auth/logout` — deletes the session row.
- `GET /api/v1/admin/auth/me` — returns the current authenticated user.
### Admin User Management
```
GET /api/v1/admin/admins — list
POST /api/v1/admin/admins — create ({ username, password })
GET /api/v1/admin/admins/{id} — get
PATCH /api/v1/admin/admins/{id} — update ({ username?, password?, is_active? })
DELETE /api/v1/admin/admins/{id} — delete
```
Initial cut: every authenticated admin can call all of these. No self-elevation concerns because there are no privilege levels yet. The PATCH and DELETE handlers both refuse to leave the system with zero active admins (`422 Unprocessable Entity` with a clear message); PATCH that transitions `is_active` from true to false also wipes that user's sessions immediately.
Validation: username `^[a-z0-9._-]{2,32}$`, password minimum 8 characters (no complexity rules — follows NIST 800-63B guidance).
Dashboard surface: `/admin/login` (unauthed), `/admin/admins` (user list with add / change-password / deactivate / reactivate / delete actions per row). The top-bar shows the logged-in admin and a logout button. Token is held in a Svelte store with a localStorage echo so a page refresh doesn't sign you out; cookie-based auth works in parallel for non-SPA browser hits.
### Forward Compatibility
Schema is intentionally simple so role/permission tables can be added without touching `admin_users`. Illustrative future shape:
```sql
CREATE TABLE admin_roles (
id UUID PRIMARY KEY,
name TEXT UNIQUE -- e.g., 'super_admin', 'app_editor', 'app_viewer'
);
CREATE TABLE admin_user_roles (
admin_user_id UUID REFERENCES admin_users(id) ON DELETE CASCADE,
role_id UUID REFERENCES admin_roles(id) ON DELETE RESTRICT,
app_id UUID REFERENCES apps(id) ON DELETE CASCADE, -- nullable for global roles
PRIMARY KEY (admin_user_id, role_id, app_id)
);
```
Permission checks land in middleware that initially only enforces "authenticated"; the same middleware is the seam where role checks slot in later. Don't pre-build the role tables — but keep the middleware shape such that adding them is a localized change.
---
## 11.5 App Scoping (Phase 3b) — Shipped
**Status**: shipped. Implementation lives in:
- `crates/shared/src/{app,ids,script,route}.rs``App`, `AppDomain`, `AppId`, `app_id` fields on `Script`/`Route`/`ExecutionLog`.
- `crates/manager-core/src/{app_repo,app_domain_repo,apps_api,app_bootstrap}.rs` — repos + admin API + Hello-World seed.
- `crates/orchestrator-core/src/routing/{app_domains,pattern,table}.rs``AppDomainTable`, `parse_app_domain`, per-app `RouteTable`.
- Migration `0005_apps.sql`.
**Deviations from the design below**: none of substance. Two operational notes:
- The Hello-World seed lives in `crates/manager-core/seeds/hello.rhai` and is inserted by a Rust bootstrap step (`seed_hello_world_if_fresh`) rather than from the migration — keeps it testable and gives the dashboard editor real source to render. The migration always inserts the `default` app + `localhost` claim; the seed only fires when that app is otherwise empty.
- Per-app admin roles/permissions are deferred — every authenticated admin can act on every app. The middleware seam (`auth_middleware::require_admin`) is the place where role checks slot in later.
**Purpose**: PiCloud hosts multiple independent applications on one platform. Each app is the isolation boundary for scripts, routes, domains, and (later) data — App A cannot see or modify App B's resources except through HTTP calls between them.
**Why this slot**: pulled forward from the original v1.3+ "multi-user / project namespacing" bullet. Adding the `app_id` scoping dimension to schemas while the surface is small is cheap; retrofitting it after KV, docs, users, etc. ship is a multi-table migration on populated data.
### Apps Own Scripts
Every script belongs to exactly one app (`scripts.app_id`, non-null). Script IDs remain globally unique UUIDs — the API operates on script IDs directly without needing `app_id` in the URL. The dashboard nests scripts under their app in URLs (see "Dashboard URL Layout" below) but the script ID alone is still enough to resolve them server-side.
Cross-app script reuse is not done by linking. A future **duplicate-to-app** feature may copy a script's content and config into another app under a new ID, with **snapshot semantics**: the copy is independent, and changes to the original do not propagate. Genuine cross-app integration goes through HTTP calls (and, much later, an explicit export/import model for shared data).
### Apps Own Domains
Routes can no longer claim arbitrary hostnames freely. Each app declares a set of **domain claims**:
| Form | Example | Matches |
|---|---|---|
| Exact host | `app.example.com` | only that exact host |
| Single-label wildcard | `*.example.com` | one label deep: `foo.example.com`, not `a.b.example.com` |
| Parameterized | `{tenant}.example.com` | same shape as wildcard; binds `tenant` into request context |
**Syntax convention**: domain parameters use `{name}` (curly braces); route-path parameters use `:name` (colon). These are deliberately distinct so docs and conflict messages never confuse the two.
Every app also implicitly carries the reserved claim `__internal__`, granting access to `/api/v1/execute/{id}/*` for that app's scripts. An app with no public domain still works for execute-by-id (and, later, cron triggers, queue triggers, etc.).
When a route is created, its host must match one of the parent app's domain claims. The dashboard's route-creation UI offers a selector populated from the app's claims rather than a free-text host field.
### Conflict Rules — Checked at Claim Time
Domain-claim collisions are detected when a domain is added to an app, not when requests arrive:
- **Exact vs identical exact** → reject ("domain already claimed").
- **Exact vs wildcard** → allowed. `foo.example.com` (App A) coexists with `*.example.com` (App B); at request time the more-specific match wins, so A handles `foo.example.com`, B handles every other subdomain.
- **Wildcard vs wildcard at the same shape** → reject. Two apps cannot both claim `*.example.com`. `{tenant}.example.com` has the same shape as `*.example.com` for this check — the parameter name is a binding, not a discriminator.
Route-conflict errors are strictly **intra-app**. A user creating a route inside App A never sees an error that references App B. The only cross-app surface is "this domain is already claimed" at domain-claim time, which is honest and unavoidable.
### Runtime Dispatch
Request handling becomes a two-phase lookup:
1. **Host → app**: pick the app whose claim most-specifically matches the request's `Host` header (exact beats wildcard; ties are impossible by the claim rules above).
2. **Path → route**: run that app's route trie unchanged using the existing matcher.
The orchestrator's route matcher does not learn about apps — it just operates on whichever app's table was selected in step 1. This keeps the existing conflict-detection logic intact.
### Local Development
On `localhost`, `localhost` is treated as a regular domain claimed by exactly one app, defaulting to a bootstrap "default" app installed at first run. Dev and prod use the same dispatch model — no second mental model.
### Cross-App Data Sharing — Deferred
Per-app isolation is the **default and only mode** in the initial cut. KV collection `users` in App A is distinct from KV collection `users` in App B; App B cannot read App A's data without an HTTP endpoint that App A explicitly exposes.
A formal export/import model — where App B exports a collection under a public name and admin grants App A read or read-write access — is a future addition. Until it ships, the escape hatch is function-to-function HTTP calls. Sharing is easier to add than to retract; isolation comes first.
### Schema Sketch
```sql
CREATE TABLE apps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE, -- URL-safe; used in dashboard paths
name TEXT NOT NULL, -- display name; can be edited freely
description TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE app_domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
pattern TEXT NOT NULL, -- 'app.example.com' | '*.example.com' | '{tenant}.example.com'
shape TEXT NOT NULL, -- 'exact' | 'wildcard' | 'parameterized'
shape_key TEXT NOT NULL, -- normalized form for collision check (parameterized → wildcard form)
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE (shape_key) -- two apps cannot share the same shape-key
);
ALTER TABLE scripts ADD COLUMN app_id UUID NOT NULL REFERENCES apps(id) ON DELETE RESTRICT;
ALTER TABLE routes ADD COLUMN app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE;
-- Existing route uniqueness checks remain unchanged; they are now scoped within an app.
```
The `UNIQUE (shape_key)` constraint enforces the "same shape" rule at the DB level. Exact-vs-wildcard coexistence is allowed because exact hosts produce a different `shape_key` from wildcards.
### Bootstrap & Migration
The migration's behavior **depends on whether the install already has user content**:
- **Fresh install** (no pre-existing scripts or routes): seed a **"Hello World"** app with `localhost` as its sole domain claim, a `hello.rhai` script that returns a greeting, and a `/hello` GET route. This serves as the reference example for new users — they can hit `http://localhost:<port>/hello` immediately after first boot and see something work. The seed is intentionally minimal; future iterations may flesh it out.
- **Upgrading install** (pre-existing scripts or routes): create a **"default"** app with `slug = 'default'`, `localhost` as its sole domain claim, and assign every existing script and route to it. The Hello World seed is **not** added in this case — adding it would pollute the user's existing content.
The branch point is detected by inspecting whether `scripts` had any rows before the migration ran.
### Dashboard URL Layout
The dashboard is **app-hierarchical**, using the app's `slug` for human-readable URLs:
```
/admin/apps — app list
/admin/apps/new — create app
/admin/apps/{slug} — app overview
/admin/apps/{slug}/scripts — scripts in this app
/admin/apps/{slug}/scripts/{id} — script detail (script ID still globally unique; slug is for breadcrumbs)
/admin/apps/{slug}/routes — routes in this app
/admin/apps/{slug}/domains — domain claims for this app
/admin/apps/{slug}/settings — app settings
```
Renaming an app changes its `slug`. The previous slug stays as a **permanent redirect** to the renamed app, persisting until another app (a new app or another rename) tries to claim that retired slug. When such a collision happens, the dashboard shows a warning before letting the operator proceed: *"`old-slug` currently redirects to app `bar` — using it here will break any external links that still target the old slug."* If the operator confirms, the redirect row is dropped and the slug is reused.
Implementation sketch:
```sql
CREATE TABLE app_slug_history (
slug TEXT PRIMARY KEY, -- the retired slug
current_app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
retired_at TIMESTAMP DEFAULT NOW()
);
```
Slug lookup order:
1. `apps.slug = {slug}` → render the page directly.
2. `app_slug_history.slug = {slug}``301` redirect to `/admin/apps/{current_app.slug}/<rest>`.
3. Neither → `404`.
Slug claim order (create or rename to a slug `S`):
1. If `S` matches a current app's slug → reject as a conflict (the usual unique-constraint error).
2. If `S` matches a row in `app_slug_history` → return a "needs confirmation" response. Dashboard surfaces the warning; on confirm, delete the history row inside the same transaction as the create/rename.
3. Otherwise → proceed normally; if this was a rename, insert the old slug into `app_slug_history`.
A rename back to an app's own retired slug is a special case: just delete the row from `app_slug_history` and don't warn.
### API URL Layout
The HTTP API stays **flat**:
```
GET /api/v1/admin/apps — list apps
POST /api/v1/admin/apps — create app
GET /api/v1/admin/apps/{id_or_slug} — get app
PATCH /api/v1/admin/apps/{id_or_slug} — update app
DELETE /api/v1/admin/apps/{id_or_slug} — delete app
GET /api/v1/admin/apps/{id_or_slug}/domains — list/manage domain claims
POST /api/v1/admin/apps/{id_or_slug}/domains
DELETE /api/v1/admin/apps/{id_or_slug}/domains/{domain_id}
GET /api/v1/admin/scripts — list scripts (now supports ?app={id_or_slug} filter)
GET /api/v1/admin/scripts/{id} — unchanged; script IDs are globally unique
... (rest of scripts/routes endpoints unchanged)
```
The scripts and routes endpoints keep their existing shape — this avoids forcing API consumers to a v2 migration. The new app-management endpoints are additive. Clients that want app context can use the `?app=` filter.
---
## 12. Development Roadmap ## 12. Development Roadmap
### Phase 1: MVP ✓ (Current) ### Phase 1: MVP ✓ (Shipped)
- [x] Orchestrator: REST API for script CRUD + execute - [x] Manager: REST API for script CRUD + executions log
- [x] Executor image: load + run Rhai script - [x] Orchestrator: HTTP ingress, route resolution, dispatch
- [x] Dashboard: upload script, deploy, delete - [x] Executor: embedded Rhai engine with sandbox limits (replaces the original Docker-per-execution model — embedded gives better latency and less infra)
- [x] PostgreSQL: script storage + execution logs - [x] Dashboard (SvelteKit): script upload, edit, routing config, execution log viewer
- [ ] **Timeline**: 4-6 weeks - [x] PostgreSQL: scripts, routes, execution_logs; embedded migrations
- [x] Caddy reverse proxy in front of everything
**Deliverables:** **Delivered beyond original MVP scope:** custom routing (exact / prefix / param + host-aware) with conflict detection, per-script Rhai sandbox config, four-tab dashboard detail UI, structured versioning scheme (product + SDK + API + schema + wire) with `/version` self-report, Rhai editor with autocomplete / goto / find-usages / formatter, SDK contract + schema snapshot + integration test suites.
- Docker image for executor
- Rust binary (Orchestrator)
- Static HTML + Alpine.js dashboard
- docker-compose.yml for local/prod deployment
--- ---
### Phase 2: v1.0 (Polish & Usability) ### Phase 2: v1.0 (Polish & Usability) ✓ (Shipped)
- Script versioning + rollback - [x] Execution history dashboard
- Execution history dashboard (view logs, timings, errors) - [x] Better error messages (Rhai parse errors, sandbox limits, timeouts)
- Better error messages (script parse errors, timeouts) - [x] Timeout / resource-limit enforcement (per-script sandbox config)
- Timeout/resource limit enforcement - [x] Rhai SDK docs current through SDK 1.1
- Container cleanup/GC
- Rhai SDK: `request()` function fully documented
**Timeline**: 2-3 weeks (Script versioning + rollback remains deferred — see Phase 6.)
--- ---
### Phase 3: v1.1 (Expand Capabilities & Services) ### Phase 3: v1.0.x — Foundations (Current focus)
- Queue-based triggers (RabbitMQ / Redis)
- Scheduled jobs (cron syntax)
- Secrets management (encrypted env vars)
- **Rhai SDK: KV Store** (`kv.get()`, `kv.set()`, `kv.delete()` with collections)
- **Rhai SDK: Document Store** (`docs.create()`, `docs.find()`, `docs.update()`, `docs.delete()` with schema validation)
- **Rhai SDK: User Management** (auth, CRUD, roles, permissions, invitations, password reset)
- **Rhai SDK: Email** (`email.send(to, subject, body)` via SMTP)
- Rhai SDK: `s3.*`, `queue.*`, `invoke()`, `retry.*()`
- External HTTP calls from scripts (`http.get()`, `http.post()`)
- Script versioning with automatic rollback on error
**Timeline**: 8-10 weeks Two foundation pieces that must land before the v1.1 service expansion, because retrofitting them later is expensive.
**3a. Admin auth** — ✓ shipped. See section 11.4. Per-user `admin_users` (not a shared secret), Argon2id passwords, env-var bootstrap of the first admin, session-token doubling as bearer token for API. No roles in this cut; schema is forward-compatible with later RBAC.
**3b. Multi-app scoping** — ✓ shipped. See section 11.5. `apps`, `app_domains`, `app_slug_history` tables; `app_id` columns on `scripts`, `routes`, `execution_logs`. Migration assigns existing data to a `default` app and always claims `localhost`; a Rust-side bootstrap inserts a `Hello World` script + `/hello` route when the default app is empty. Orchestrator dispatch is two-phase (Host → app → route trie). `/api/v1/execute/{id}/*` continues to work without a public domain claim. Dashboard is app-hierarchical (`/admin/apps`, `/admin/apps/{slug}/...`); API stays flat with new endpoints under `/api/v1/admin/apps/*` and a `?app=` filter on script listing. Per-app admin roles deferred.
**Why both before v1.1**: every v1.1 service (KV, docs, users, etc.) needs an `app_id` scoping key in its schema. Adding it now, with one small migration on existing tables, is cheap. Adding it after those services ship is several migrations on populated data.
--- ---
### Phase 4: v1.2 (Advanced Workflows & Hierarchies) ### Phase 4: v1.1 (Expand Capabilities & Services)
Ordered roughly by foundation value: each row enables the rows below it.
1. **Rhai SDK: KV Store** (`kv.get/set/delete/has` with collections, scoped per app)
2. **Rhai SDK: Document Store** (`docs.create/find/update/delete/list/query`, scoped per app)
3. **Rhai SDK: HTTP** (`http.get/post/put/delete` with SSRF deny-list)
4. **Cron triggers** (manager scheduler skeleton already exists; needs schedules table + `FOR UPDATE SKIP LOCKED` dispatch)
5. **Rhai SDK: Email** (`email.send` via SMTP; needs per-deploy config)
6. **Rhai SDK: User Management** (auth, CRUD, roles, permissions, invitations, password reset; depends on email for invites; scoped per app)
7. **Queue triggers** (start with Postgres LISTEN/NOTIFY; RabbitMQ/Redis later if needed)
8. **`invoke()` + `retry::*`** (function-to-function calls; execution_logs gain `parent_execution_id`)
9. **Secrets management** (encrypted env vars, per app)
---
### Phase 5: v1.2 (Advanced Workflows & Hierarchies)
- Function workflows (DAG execution, conditional branching, error handling) - Function workflows (DAG execution, conditional branching, error handling)
- Function hierarchy (parent/child invocation, sync/async calls)
- Nested workflows - Nested workflows
- Call graph visualization + execution tracing - Call graph visualization + execution tracing
- Advanced query support for document store (`docs.query()` with filters) - Advanced query support for document store (`docs.query()` with filters: `$gt`, `$or`, etc.)
- Service interceptors (see section 9.4)
**Timeline**: 6-8 weeks
--- ---
### Phase 5: v1.3+ (Scaling, Security, Observability) ### Phase 6: v1.3+ (Scaling, Security, Observability)
- Multi-user / project namespacing - Cluster mode (split-process manager + per-node orchestrator + executor); cluster-mode wire protocol versioning
- Cross-app data sharing (explicit export/import model — see section 11.5)
- Script versioning + rollback (keep N historical versions in a side table; rollback endpoint)
- Rate limiting on endpoints - Rate limiting on endpoints
- Auth (API keys, dashboard login) - Auth (richer model: API keys, OAuth, etc.)
- Metrics + monitoring dashboard - Metrics + monitoring dashboard
- Container pooling / warm starts
- Distributed tracing (OpenTelemetry) - Distributed tracing (OpenTelemetry)
- Webhooks for execution events - Webhooks for execution events
- S3 integration (object storage reads/writes) - S3 integration (object storage reads/writes)