Compare commits
55 Commits
feat/users
...
feat/v1.1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e375735796 | ||
|
|
098e18a989 | ||
|
|
9b4a834627 | ||
|
|
5302bd3192 | ||
|
|
902dd78027 | ||
|
|
dea776b2a3 | ||
|
|
fe1dd90836 | ||
|
|
aaba58dee1 | ||
|
|
2669714a51 | ||
|
|
662d5a2cf8 | ||
|
|
fc8d473416 | ||
|
|
c73e3c80c0 | ||
|
|
f147665157 | ||
|
|
e4851b3deb | ||
|
|
5d08974876 | ||
|
|
ca278bddc8 | ||
|
|
7b50047730 | ||
|
|
b42e273479 | ||
|
|
f32ed73561 | ||
|
|
64799b73ff | ||
|
|
beb3bcb97c | ||
|
|
79c8db2cb7 | ||
|
|
f4cd883d76 | ||
|
|
b459b99fe9 | ||
|
|
f694a6d504 | ||
|
|
70b66451d6 | ||
|
|
c4fa53052d | ||
|
|
2f6840fe3e | ||
|
|
75c815d02a | ||
|
|
d9c3d4d661 | ||
|
|
bef4d34c43 | ||
|
|
99a3ed1b6b | ||
|
|
4644ea4919 | ||
|
|
ec3c768262 | ||
|
|
3e72ddde78 | ||
|
|
cd20ffb580 | ||
|
|
cddd479fd2 | ||
|
|
8bbcdd86aa | ||
|
|
2d56e42699 | ||
|
|
f9d9ed8cb4 | ||
|
|
c17f8a5bd9 | ||
|
|
7198fb4d0e | ||
|
|
029a4a199f | ||
|
|
74f7b3b631 | ||
|
|
e6fc6e6a0e | ||
|
|
66b84abf6d | ||
|
|
a9fc838577 | ||
|
|
2948875a96 | ||
|
|
b7175cc581 | ||
|
|
d40ebf65a2 | ||
|
|
816a13b920 | ||
|
|
248571dcde | ||
|
|
85bbabcbdf | ||
|
|
1314420fca | ||
|
|
33697a2766 |
11
.gitignore
vendored
11
.gitignore
vendored
@@ -30,6 +30,17 @@ config.local.toml
|
|||||||
/dashboard/build
|
/dashboard/build
|
||||||
/dashboard/.env
|
/dashboard/.env
|
||||||
|
|
||||||
|
# Dashboard — Playwright E2E
|
||||||
|
/dashboard/tests/e2e/.auth
|
||||||
|
/dashboard/tests/e2e/.results
|
||||||
|
/dashboard/playwright-report
|
||||||
|
/dashboard/test-results
|
||||||
|
/dashboard/.playwright
|
||||||
|
# When playwright is invoked from the repo root by accident, these
|
||||||
|
# also land here.
|
||||||
|
/playwright-report
|
||||||
|
/test-results
|
||||||
|
|
||||||
# Caddy
|
# Caddy
|
||||||
/caddy/data
|
/caddy/data
|
||||||
/caddy/config
|
/caddy/config
|
||||||
|
|||||||
17
CLAUDE.md
17
CLAUDE.md
@@ -8,7 +8,7 @@ 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.
|
**Current focus (Phase 4, v1.1.0):** SDK foundation + stdlib utilities — the shape every v1.1.x service module hangs off, see [docs/sdk-shape.md](docs/sdk-shape.md). Subsequent v1.1.x releases (KV in v1.1.1, docs in v1.1.2, …) fill it in; see blueprint §12 for the full table. Phase 3 shipped end-to-end: admin auth, multi-app scoping, and Phase 3.5 capability gating (`manager-core::authz::{can, require, Capability}` + migration `0006_users_authz.sql`). 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
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ Caddy fronts everything. Same Caddyfile shape works for single-node and cluster
|
|||||||
- **Rust 1.92+** workspace, pinned via `rust-toolchain.toml`
|
- **Rust 1.92+** workspace, pinned via `rust-toolchain.toml`
|
||||||
- **Axum** for HTTP, **Tokio** async, **sqlx** for Postgres
|
- **Axum** for HTTP, **Tokio** async, **sqlx** for Postgres
|
||||||
- **Rhai** embedded scripting (in `executor-core`)
|
- **Rhai** embedded scripting (in `executor-core`)
|
||||||
- **PostgreSQL 15+** with `pgcrypto` and (v1.1+) `hstore`
|
- **PostgreSQL 15+** with `pgcrypto`. v1.1+ data-plane tables use JSONB for value columns (hstore was considered for KV and rejected — see blueprint §8.1).
|
||||||
- **SvelteKit** dashboard, static adapter, CodeMirror 6 for the script editor
|
- **SvelteKit** dashboard, static adapter, CodeMirror 6 for the script editor
|
||||||
- **Caddy 2** reverse proxy (auto-HTTPS in prod)
|
- **Caddy 2** reverse proxy (auto-HTTPS in prod)
|
||||||
- **Docker Compose** for dev and single-node prod
|
- **Docker Compose** for dev and single-node prod
|
||||||
@@ -103,9 +103,22 @@ docs/
|
|||||||
- **Honor the three-service boundary.** Don't reach across `*-core` crates. If `orchestrator-core` needs something from `manager-core`, define a trait in `shared` and inject the impl.
|
- **Honor the three-service boundary.** Don't reach across `*-core` crates. If `orchestrator-core` needs something from `manager-core`, define a trait in `shared` and inject the impl.
|
||||||
- **`executor-core` has no Postgres dependency.** Data-plane services (kv, docs, users — v1.1+) come in via injected `ServiceProvider` traits.
|
- **`executor-core` has no Postgres dependency.** Data-plane services (kv, docs, users — v1.1+) come in via injected `ServiceProvider` traits.
|
||||||
- **Database writes only from `manager-core`.** `orchestrator-core` reads scripts (cached); `executor-core` doesn't touch the DB.
|
- **Database writes only from `manager-core`.** `orchestrator-core` reads scripts (cached); `executor-core` doesn't touch the DB.
|
||||||
|
- **Stateful SDK services use the handle pattern + `SdkCallCx`.** Collection-scoped surfaces look like `kv::collection("x").get(k)`, not `kv::get("x", k)`. Every service trait method takes `&SdkCallCx` and **MUST** derive `app_id` from `cx.app_id` — never trust a script-passed `app_id`. That is the cross-app isolation boundary. See [docs/sdk-shape.md](docs/sdk-shape.md).
|
||||||
- **MVP builds only the `picloud` all-in-one binary.** The three split binaries exist as skeletons so the crate boundaries stay honest; flesh them out only when cluster mode is being implemented.
|
- **MVP builds only the `picloud` all-in-one binary.** The three split binaries exist as skeletons so the crate boundaries stay honest; flesh them out only when cluster mode is being implemented.
|
||||||
- **Trunk-based dev.** See [docs/git-workflow.md](docs/git-workflow.md). No long-lived branches. Feature flags for incomplete work.
|
- **Trunk-based dev.** See [docs/git-workflow.md](docs/git-workflow.md). No long-lived branches. Feature flags for incomplete work.
|
||||||
|
|
||||||
|
## Runtime configuration
|
||||||
|
|
||||||
|
Environment variables consumed by the `picloud` binary:
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `PICLOUD_BIND` | `0.0.0.0:8080` | HTTP listen address. Port 8080 is owned by another process on this host — override locally. |
|
||||||
|
| `PICLOUD_MAX_CONCURRENT_EXECUTIONS` | `32` | Global concurrency cap on data-plane script executions. Overflow returns HTTP 503 with `Retry-After: 1` immediately (no queue). |
|
||||||
|
| `DATABASE_URL` | — | Required. Postgres connection string. |
|
||||||
|
| `PICLOUD_SESSION_TTL_HOURS` | `24` | Sliding-window session lifetime. |
|
||||||
|
| `PICLOUD_SANDBOX_MAX_*` | conservative defaults | Per-knob admin ceilings on Rhai sandbox overrides. See `manager-core::sandbox::SandboxCeiling`. |
|
||||||
|
|
||||||
## 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()`, 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.
|
||||||
|
|||||||
353
Cargo.lock
generated
353
Cargo.lock
generated
@@ -40,6 +40,56 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.102"
|
||||||
@@ -68,6 +118,21 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "assert_cmd"
|
||||||
|
version = "2.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"bstr",
|
||||||
|
"libc",
|
||||||
|
"predicates",
|
||||||
|
"predicates-core",
|
||||||
|
"predicates-tree",
|
||||||
|
"wait-timeout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -236,6 +301,17 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bstr"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
@@ -302,6 +378,52 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -440,6 +562,12 @@ version = "0.1.13"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "difflib"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -452,6 +580,27 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "directories"
|
||||||
|
version = "5.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
|
||||||
|
dependencies = [
|
||||||
|
"dirs-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs-sys"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"option-ext",
|
||||||
|
"redox_users",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -516,6 +665,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figment"
|
name = "figment"
|
||||||
version = "0.10.19"
|
version = "0.10.19"
|
||||||
@@ -536,6 +691,15 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "float-cmp"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -1010,6 +1174,12 @@ version = "2.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -1077,6 +1247,12 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@@ -1161,6 +1337,12 @@ dependencies = [
|
|||||||
"spin 0.5.2",
|
"spin 0.5.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "normalize-line-endings"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -1231,6 +1413,18 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "option-ext"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking"
|
name = "parking"
|
||||||
version = "2.2.1"
|
version = "2.2.1"
|
||||||
@@ -1335,6 +1529,27 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "picloud-cli"
|
||||||
|
version = "0.6.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"assert_cmd",
|
||||||
|
"chrono",
|
||||||
|
"clap",
|
||||||
|
"directories",
|
||||||
|
"libc",
|
||||||
|
"picloud-shared",
|
||||||
|
"predicates",
|
||||||
|
"reqwest",
|
||||||
|
"rpassword",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"tokio",
|
||||||
|
"toml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-executor"
|
name = "picloud-executor"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -1509,6 +1724,36 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "predicates"
|
||||||
|
version = "3.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"difflib",
|
||||||
|
"float-cmp",
|
||||||
|
"normalize-line-endings",
|
||||||
|
"predicates-core",
|
||||||
|
"regex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "predicates-core"
|
||||||
|
version = "1.0.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "predicates-tree"
|
||||||
|
version = "1.0.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2"
|
||||||
|
dependencies = [
|
||||||
|
"predicates-core",
|
||||||
|
"termtree",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pretty_assertions"
|
name = "pretty_assertions"
|
||||||
version = "1.4.1"
|
version = "1.4.1"
|
||||||
@@ -1704,6 +1949,29 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_users"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libredox",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "1.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -1729,7 +1997,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -1812,6 +2082,17 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rpassword"
|
||||||
|
version = "7.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "835a57a69104632d64deb0df2e09a69945cd7a6eab4070fc9b1d7e50cf6c3edc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rtoolbox",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rsa"
|
name = "rsa"
|
||||||
version = "0.9.10"
|
version = "0.9.10"
|
||||||
@@ -1832,6 +2113,16 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rtoolbox"
|
||||||
|
version = "0.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust-multipart-rfc7578_2"
|
name = "rust-multipart-rfc7578_2"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -1853,6 +2144,19 @@ version = "2.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.40"
|
version = "0.23.40"
|
||||||
@@ -2327,6 +2631,12 @@ dependencies = [
|
|||||||
"unicode-properties",
|
"unicode-properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -2364,6 +2674,25 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.4.2",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "termtree"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thin-vec"
|
name = "thin-vec"
|
||||||
version = "0.2.18"
|
version = "0.2.18"
|
||||||
@@ -2783,6 +3112,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.1"
|
version = "1.23.1"
|
||||||
@@ -2813,6 +3148,15 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wait-timeout"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "want"
|
name = "want"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -3066,6 +3410,15 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ members = [
|
|||||||
"crates/picloud-manager",
|
"crates/picloud-manager",
|
||||||
"crates/picloud-orchestrator",
|
"crates/picloud-orchestrator",
|
||||||
"crates/picloud-executor",
|
"crates/picloud-executor",
|
||||||
|
"crates/picloud-cli",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -3,30 +3,38 @@ use std::sync::{Arc, Mutex};
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use picloud_shared::{ScriptValidator, ValidationError, SDK_VERSION};
|
use picloud_shared::{ScriptValidator, SdkCallCx, Services, ValidationError, SDK_VERSION};
|
||||||
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module, Scope};
|
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module, Scope};
|
||||||
use serde_json::Value as Json;
|
use serde_json::Value as Json;
|
||||||
|
|
||||||
use crate::sandbox::Limits;
|
use crate::sandbox::Limits;
|
||||||
|
use crate::sdk;
|
||||||
|
use crate::sdk::bridge::{dynamic_to_json, json_to_dynamic};
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
|
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Preconfigured Rhai engine with sandbox limits applied.
|
/// Preconfigured Rhai engine with sandbox limits applied and the SDK
|
||||||
|
/// `Services` bundle attached.
|
||||||
///
|
///
|
||||||
/// One `Engine` is constructed at process startup and reused across
|
/// One `Engine` is constructed at process startup and reused across
|
||||||
/// invocations. `execute` is **synchronous** — it owns the per-call
|
/// invocations. `execute` is **synchronous** — it owns the per-call
|
||||||
/// scope and log buffer. Wall-clock timeouts and offloading off the
|
/// scope and log buffer. Wall-clock timeouts and offloading off the
|
||||||
/// async runtime belong to the caller (orchestrator-core's
|
/// async runtime belong to the caller (orchestrator-core's
|
||||||
/// `LocalExecutorClient` wraps this with `spawn_blocking` + `timeout`).
|
/// `LocalExecutorClient` wraps this with `spawn_blocking` + `timeout`).
|
||||||
|
///
|
||||||
|
/// The `Services` bundle is empty in v1.1.0; subsequent v1.1.x PRs add
|
||||||
|
/// service handles (KV, docs, …) and `sdk::register_all` wires them
|
||||||
|
/// into each per-call Rhai engine.
|
||||||
pub struct Engine {
|
pub struct Engine {
|
||||||
limits: Limits,
|
limits: Limits,
|
||||||
|
services: Services,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(limits: Limits) -> Self {
|
pub fn new(limits: Limits, services: Services) -> Self {
|
||||||
Self { limits }
|
Self { limits, services }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -55,7 +63,20 @@ impl Engine {
|
|||||||
pub fn execute(&self, source: &str, req: ExecRequest) -> Result<ExecResponse, ExecError> {
|
pub fn execute(&self, source: &str, req: ExecRequest) -> Result<ExecResponse, ExecError> {
|
||||||
let effective_limits = self.limits.with_overrides(&req.sandbox_overrides);
|
let effective_limits = self.limits.with_overrides(&req.sandbox_overrides);
|
||||||
let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
|
let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
let engine = build_engine(effective_limits, Some(logs.clone()));
|
let mut engine = build_engine(effective_limits, Some(logs.clone()));
|
||||||
|
|
||||||
|
// Per-call context handed to every stateful SDK service via the
|
||||||
|
// `sdk::register_all` hook. The Arc lets future service closures
|
||||||
|
// capture cheap clones of the cx for use at script-call time.
|
||||||
|
let cx = Arc::new(SdkCallCx {
|
||||||
|
app_id: req.app_id,
|
||||||
|
principal: req.principal.clone(),
|
||||||
|
execution_id: req.execution_id,
|
||||||
|
request_id: req.request_id,
|
||||||
|
trigger_depth: req.trigger_depth,
|
||||||
|
root_execution_id: req.root_execution_id,
|
||||||
|
});
|
||||||
|
sdk::register_all(&mut engine, &self.services, cx);
|
||||||
|
|
||||||
let ast = engine
|
let ast = engine
|
||||||
.compile(source)
|
.compile(source)
|
||||||
@@ -265,69 +286,6 @@ fn parse_structured_response(map: Map) -> Result<(u16, BTreeMap<String, String>,
|
|||||||
Ok((status_code, headers, body))
|
Ok((status_code, headers, body))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
|
||||||
// Rhai ↔ serde_json bridges
|
|
||||||
// ----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn json_to_dynamic(value: Json) -> Dynamic {
|
|
||||||
match value {
|
|
||||||
Json::Null => Dynamic::UNIT,
|
|
||||||
Json::Bool(b) => b.into(),
|
|
||||||
Json::Number(n) => {
|
|
||||||
if let Some(i) = n.as_i64() {
|
|
||||||
i.into()
|
|
||||||
} else if let Some(f) = n.as_f64() {
|
|
||||||
f.into()
|
|
||||||
} else {
|
|
||||||
n.to_string().into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Json::String(s) => s.into(),
|
|
||||||
Json::Array(arr) => arr
|
|
||||||
.into_iter()
|
|
||||||
.map(json_to_dynamic)
|
|
||||||
.collect::<Vec<Dynamic>>()
|
|
||||||
.into(),
|
|
||||||
Json::Object(obj) => {
|
|
||||||
let mut m = Map::new();
|
|
||||||
for (k, v) in obj {
|
|
||||||
m.insert(k.into(), json_to_dynamic(v));
|
|
||||||
}
|
|
||||||
Dynamic::from(m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dynamic_to_json(value: &Dynamic) -> Json {
|
|
||||||
if value.is_unit() {
|
|
||||||
return Json::Null;
|
|
||||||
}
|
|
||||||
if let Ok(b) = value.as_bool() {
|
|
||||||
return Json::Bool(b);
|
|
||||||
}
|
|
||||||
if let Ok(i) = value.as_int() {
|
|
||||||
return Json::Number(i.into());
|
|
||||||
}
|
|
||||||
if let Ok(f) = value.as_float() {
|
|
||||||
return serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number);
|
|
||||||
}
|
|
||||||
if value.is_string() {
|
|
||||||
return Json::String(value.clone().into_string().unwrap_or_default());
|
|
||||||
}
|
|
||||||
if let Some(arr) = value.clone().try_cast::<rhai::Array>() {
|
|
||||||
return Json::Array(arr.iter().map(dynamic_to_json).collect());
|
|
||||||
}
|
|
||||||
if let Some(map) = value.clone().try_cast::<Map>() {
|
|
||||||
let mut out = serde_json::Map::new();
|
|
||||||
for (k, v) in map {
|
|
||||||
out.insert(k.to_string(), dynamic_to_json(&v));
|
|
||||||
}
|
|
||||||
return Json::Object(out);
|
|
||||||
}
|
|
||||||
// Anything else (timestamps, custom types) — best-effort string form.
|
|
||||||
Json::String(value.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Error mapping
|
// Error mapping
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub mod context;
|
|||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
|
pub mod sdk;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use engine::Engine;
|
pub use engine::Engine;
|
||||||
|
|||||||
77
crates/executor-core/src/sdk/bridge.rs
Normal file
77
crates/executor-core/src/sdk/bridge.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
//! JSON ↔ Rhai `Dynamic` value bridge.
|
||||||
|
//!
|
||||||
|
//! Originally inline in `engine.rs`; moved here for v1.1.0 so future
|
||||||
|
//! service modules (KV in v1.1.1, docs in v1.1.2, …) can convert
|
||||||
|
//! values without `engine.rs` being the only owner of the conversions.
|
||||||
|
//! Behaviour is unchanged from the pre-extraction implementation —
|
||||||
|
//! `sdk_contract.rs::json_round_trip_preserves_nested_shapes` pins the
|
||||||
|
//! observable round-trip.
|
||||||
|
|
||||||
|
use rhai::{Dynamic, Map};
|
||||||
|
use serde_json::Value as Json;
|
||||||
|
|
||||||
|
/// Convert a `serde_json::Value` into a Rhai `Dynamic` suitable for
|
||||||
|
/// pushing into a script's scope. Numbers prefer the narrowest type
|
||||||
|
/// (`i64` over `f64`); anything that can't round-trip falls back to a
|
||||||
|
/// string so the script always sees a defined value.
|
||||||
|
pub fn json_to_dynamic(value: Json) -> Dynamic {
|
||||||
|
match value {
|
||||||
|
Json::Null => Dynamic::UNIT,
|
||||||
|
Json::Bool(b) => b.into(),
|
||||||
|
Json::Number(n) => {
|
||||||
|
if let Some(i) = n.as_i64() {
|
||||||
|
i.into()
|
||||||
|
} else if let Some(f) = n.as_f64() {
|
||||||
|
f.into()
|
||||||
|
} else {
|
||||||
|
n.to_string().into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Json::String(s) => s.into(),
|
||||||
|
Json::Array(arr) => arr
|
||||||
|
.into_iter()
|
||||||
|
.map(json_to_dynamic)
|
||||||
|
.collect::<Vec<Dynamic>>()
|
||||||
|
.into(),
|
||||||
|
Json::Object(obj) => {
|
||||||
|
let mut m = Map::new();
|
||||||
|
for (k, v) in obj {
|
||||||
|
m.insert(k.into(), json_to_dynamic(v));
|
||||||
|
}
|
||||||
|
Dynamic::from(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a Rhai `Dynamic` back to a `serde_json::Value`. Custom Rhai
|
||||||
|
/// types (timestamps, user-registered modules) fall back to their
|
||||||
|
/// `Display` form so they appear as strings in JSON output rather than
|
||||||
|
/// failing the response build.
|
||||||
|
pub fn dynamic_to_json(value: &Dynamic) -> Json {
|
||||||
|
if value.is_unit() {
|
||||||
|
return Json::Null;
|
||||||
|
}
|
||||||
|
if let Ok(b) = value.as_bool() {
|
||||||
|
return Json::Bool(b);
|
||||||
|
}
|
||||||
|
if let Ok(i) = value.as_int() {
|
||||||
|
return Json::Number(i.into());
|
||||||
|
}
|
||||||
|
if let Ok(f) = value.as_float() {
|
||||||
|
return serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number);
|
||||||
|
}
|
||||||
|
if value.is_string() {
|
||||||
|
return Json::String(value.clone().into_string().unwrap_or_default());
|
||||||
|
}
|
||||||
|
if let Some(arr) = value.clone().try_cast::<rhai::Array>() {
|
||||||
|
return Json::Array(arr.iter().map(dynamic_to_json).collect());
|
||||||
|
}
|
||||||
|
if let Some(map) = value.clone().try_cast::<Map>() {
|
||||||
|
let mut out = serde_json::Map::new();
|
||||||
|
for (k, v) in map {
|
||||||
|
out.insert(k.to_string(), dynamic_to_json(&v));
|
||||||
|
}
|
||||||
|
return Json::Object(out);
|
||||||
|
}
|
||||||
|
Json::String(value.to_string())
|
||||||
|
}
|
||||||
10
crates/executor-core/src/sdk/cx.rs
Normal file
10
crates/executor-core/src/sdk/cx.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
//! Re-export of `picloud_shared::SdkCallCx`.
|
||||||
|
//!
|
||||||
|
//! The type itself lives in `picloud-shared` because future stateful
|
||||||
|
//! service impls live in `manager-core` (which `executor-core` must
|
||||||
|
//! not depend on) and need to reference the same cx shape. This
|
||||||
|
//! re-export lets executor-side code write
|
||||||
|
//! `use picloud_executor_core::sdk::SdkCallCx;` instead of reaching
|
||||||
|
//! into `picloud_shared` for one type.
|
||||||
|
|
||||||
|
pub use picloud_shared::SdkCallCx;
|
||||||
39
crates/executor-core/src/sdk/mod.rs
Normal file
39
crates/executor-core/src/sdk/mod.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
//! SDK plumbing — types and the per-call registration entry point.
|
||||||
|
//!
|
||||||
|
//! `executor-core` is responsible for building the per-invocation Rhai
|
||||||
|
//! engine and wiring stateful services into it. v1.1.0 ships the
|
||||||
|
//! shapes (`Services` bundle, `SdkCallCx`, `register_all` entry point)
|
||||||
|
//! but no actual services — subsequent v1.1.x PRs (KV in v1.1.1,
|
||||||
|
//! docs in v1.1.2, …) extend `register_all` rather than re-threading
|
||||||
|
//! plumbing through `engine.rs`.
|
||||||
|
//!
|
||||||
|
//! Bridge functions (`json_to_dynamic` / `dynamic_to_json`) also live
|
||||||
|
//! here so service modules can convert values without `engine.rs`
|
||||||
|
//! being the only home for the conversion logic.
|
||||||
|
|
||||||
|
pub mod bridge;
|
||||||
|
pub mod cx;
|
||||||
|
|
||||||
|
pub use bridge::{dynamic_to_json, json_to_dynamic};
|
||||||
|
pub use cx::SdkCallCx;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use picloud_shared::Services;
|
||||||
|
use rhai::Engine as RhaiEngine;
|
||||||
|
|
||||||
|
/// Single hook every v1.1.x stateful service registers into. Called
|
||||||
|
/// once per invocation, just after `build_engine` constructs the
|
||||||
|
/// sandboxed Rhai engine and just before script compilation.
|
||||||
|
///
|
||||||
|
/// v1.1.0 ships an intentionally empty body — the call site exists so
|
||||||
|
/// future PRs (KV first) drop their registration logic here rather
|
||||||
|
/// than reaching into `engine.rs::build_engine`. The signature is
|
||||||
|
/// locked: subsequent PRs MUST keep the same parameter shape so that
|
||||||
|
/// hosts don't have to re-thread the plumbing.
|
||||||
|
pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||||
|
// Intentionally inert in v1.1.0. The unused-suppression below is a
|
||||||
|
// load-bearing placeholder: future PRs replace this `let _` with
|
||||||
|
// real `register_kv(engine, services, cx.clone())` calls etc.
|
||||||
|
let _ = (engine, services, cx);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use picloud_shared::{ExecutionId, RequestId, ScriptId, ScriptSandbox};
|
use picloud_shared::{AppId, ExecutionId, Principal, RequestId, ScriptId, ScriptSandbox};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
@@ -50,6 +50,35 @@ pub struct ExecRequest {
|
|||||||
/// override) before the Rhai engine is built.
|
/// override) before the Rhai engine is built.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sandbox_overrides: ScriptSandbox,
|
pub sandbox_overrides: ScriptSandbox,
|
||||||
|
|
||||||
|
/// Owning application. Source of truth for every `(app_id, …)`
|
||||||
|
/// storage lookup the script makes via stateful SDK services.
|
||||||
|
/// Internal-only; not surfaced via `ctx` (which the script sees).
|
||||||
|
pub app_id: AppId,
|
||||||
|
|
||||||
|
/// Caller identity, when authenticated. `None` for unauthenticated
|
||||||
|
/// data-plane HTTP requests (the common case for public scripts);
|
||||||
|
/// `Some` when a bearer token or session cookie was resolved.
|
||||||
|
/// Internal-only — exposed via `SdkCallCx` to service trait impls.
|
||||||
|
///
|
||||||
|
/// `#[serde(skip)]`: `ExecRequest` is serializable so cluster mode
|
||||||
|
/// (v1.3+) can ship invocations to remote executors over HTTP, but
|
||||||
|
/// `Principal` has no wire derivation today. Skipping here keeps
|
||||||
|
/// v1.1.0 compiling; the cluster-mode PR will introduce a wire-safe
|
||||||
|
/// snapshot then.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub principal: Option<Principal>,
|
||||||
|
|
||||||
|
/// Triggers-framework depth. `0` for direct invocations. The
|
||||||
|
/// dispatcher (v1.1.1) increments on each indirection to bound
|
||||||
|
/// runaway feedback loops.
|
||||||
|
#[serde(default)]
|
||||||
|
pub trigger_depth: u32,
|
||||||
|
|
||||||
|
/// Originating execution id of a trigger chain. Equal to
|
||||||
|
/// `execution_id` for direct invocations; preserves the root
|
||||||
|
/// across fan-out for audit log grouping.
|
||||||
|
pub root_execution_id: ExecutionId,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -100,4 +129,11 @@ pub enum ExecError {
|
|||||||
|
|
||||||
#[error("script runtime error: {0}")]
|
#[error("script runtime error: {0}")]
|
||||||
Runtime(String),
|
Runtime(String),
|
||||||
|
|
||||||
|
/// Concurrency gate (orchestrator-core::ExecutionGate) refused
|
||||||
|
/// admission. Surfaced as HTTP 503 with a `Retry-After` header.
|
||||||
|
/// The gate enforces a global cap so a script storm can't park
|
||||||
|
/// every blocking thread.
|
||||||
|
#[error("execution declined: server at capacity (retry after {retry_after_secs}s)")]
|
||||||
|
Overloaded { retry_after_secs: u32 },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use picloud_executor_core::{Engine, ExecError, ExecRequest, InvocationType, Limits, LogLevel};
|
use picloud_executor_core::{Engine, ExecError, ExecRequest, InvocationType, Limits, LogLevel};
|
||||||
use picloud_shared::{ExecutionId, RequestId, ScriptId, ScriptSandbox};
|
use picloud_shared::{AppId, ExecutionId, RequestId, ScriptId, ScriptSandbox, Services};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
fn req(body: serde_json::Value) -> ExecRequest {
|
fn req(body: serde_json::Value) -> ExecRequest {
|
||||||
|
let execution_id = ExecutionId::new();
|
||||||
ExecRequest {
|
ExecRequest {
|
||||||
execution_id: ExecutionId::new(),
|
execution_id,
|
||||||
request_id: RequestId::new(),
|
request_id: RequestId::new(),
|
||||||
script_id: ScriptId::new(),
|
script_id: ScriptId::new(),
|
||||||
script_name: "test".into(),
|
script_name: "test".into(),
|
||||||
@@ -18,11 +19,15 @@ fn req(body: serde_json::Value) -> ExecRequest {
|
|||||||
query: BTreeMap::new(),
|
query: BTreeMap::new(),
|
||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
|
app_id: AppId::new(),
|
||||||
|
principal: None,
|
||||||
|
trigger_depth: 0,
|
||||||
|
root_execution_id: execution_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn engine() -> Engine {
|
fn engine() -> Engine {
|
||||||
Engine::new(Limits::default())
|
Engine::new(Limits::default(), Services::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -121,7 +126,7 @@ fn enforces_operation_budget() {
|
|||||||
max_operations: 1_000,
|
max_operations: 1_000,
|
||||||
..Limits::default()
|
..Limits::default()
|
||||||
};
|
};
|
||||||
let engine = Engine::new(limits);
|
let engine = Engine::new(limits, Services::new());
|
||||||
// 10_000 iterations vastly exceeds 1_000 ops.
|
// 10_000 iterations vastly exceeds 1_000 ops.
|
||||||
let src = r"let n = 0; for i in 0..10000 { n += 1; } n";
|
let src = r"let n = 0; for i in 0..10000 { n += 1; } n";
|
||||||
let err = engine
|
let err = engine
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits, LogLevel};
|
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits, LogLevel};
|
||||||
use picloud_shared::{ExecutionId, RequestId, ScriptId, ScriptSandbox};
|
use picloud_shared::{AppId, ExecutionId, RequestId, ScriptId, ScriptSandbox, Services};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -31,12 +31,13 @@ use serde_json::{json, Value};
|
|||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
fn engine() -> Engine {
|
fn engine() -> Engine {
|
||||||
Engine::new(Limits::default())
|
Engine::new(Limits::default(), Services::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn baseline_request() -> ExecRequest {
|
fn baseline_request() -> ExecRequest {
|
||||||
|
let execution_id = ExecutionId::new();
|
||||||
ExecRequest {
|
ExecRequest {
|
||||||
execution_id: ExecutionId::new(),
|
execution_id,
|
||||||
request_id: RequestId::new(),
|
request_id: RequestId::new(),
|
||||||
script_id: ScriptId::new(),
|
script_id: ScriptId::new(),
|
||||||
script_name: "contract".into(),
|
script_name: "contract".into(),
|
||||||
@@ -48,6 +49,10 @@ fn baseline_request() -> ExecRequest {
|
|||||||
query: BTreeMap::new(),
|
query: BTreeMap::new(),
|
||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
|
app_id: AppId::new(),
|
||||||
|
principal: None,
|
||||||
|
trigger_depth: 0,
|
||||||
|
root_execution_id: execution_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -270,10 +270,13 @@ async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||||
|
// Delete is gated tighter than Save: editors can edit scripts but
|
||||||
|
// only app_admin / instance admin / owner can remove them. See
|
||||||
|
// blueprint §11.6.
|
||||||
require(
|
require(
|
||||||
state.authz.as_ref(),
|
state.authz.as_ref(),
|
||||||
&principal,
|
&principal,
|
||||||
Capability::AppWriteScript(script.app_id),
|
Capability::AppAdmin(script.app_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
state.repo.delete(id).await?;
|
state.repo.delete(id).await?;
|
||||||
|
|||||||
331
crates/manager-core/src/app_members_api.rs
Normal file
331
crates/manager-core/src/app_members_api.rs
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
//! `/api/v1/admin/apps/{id_or_slug}/members/*` — CRUD over the
|
||||||
|
//! `app_members` table (blueprint §11.6).
|
||||||
|
//!
|
||||||
|
//! Every endpoint is gated on `Capability::AppAdmin(app_id)` after
|
||||||
|
//! resolving the app from `id_or_slug`. Editors and viewers receive
|
||||||
|
//! 403 from list and never see the dashboard's Members tab.
|
||||||
|
//!
|
||||||
|
//! POST is **non-idempotent on purpose**: a duplicate `(app_id,
|
||||||
|
//! user_id)` returns 409 rather than upsert-200, so the UI can show
|
||||||
|
//! "already a member — promote / demote them instead" cleanly. Role
|
||||||
|
//! changes go through PATCH.
|
||||||
|
//!
|
||||||
|
//! No last-app-admin guard: owners always implicitly satisfy
|
||||||
|
//! `Capability::AppAdmin(_)` (authz::role_grants), so removing the
|
||||||
|
//! final explicit `app_admin` membership cannot orphan an app.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::{get, patch};
|
||||||
|
use axum::{Extension, Router};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::{AdminUserId, AppRole, InstanceRole, Principal};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
|
||||||
|
use crate::app_members_repo::{
|
||||||
|
AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow,
|
||||||
|
};
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
|
use crate::repo::ScriptRepositoryError;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppMembersState {
|
||||||
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
pub users: Arc<dyn AdminUserRepository>,
|
||||||
|
pub members: Arc<dyn AppMembersRepository>,
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn app_members_router(state: AppMembersState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}/members",
|
||||||
|
get(list_members).post(grant_member),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}/members/{user_id}",
|
||||||
|
patch(patch_member).delete(remove_member),
|
||||||
|
)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// DTOs
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AppMemberDto {
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub role: AppRole,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppMembershipDetail> for AppMemberDto {
|
||||||
|
fn from(d: AppMembershipDetail) -> Self {
|
||||||
|
Self {
|
||||||
|
user_id: d.user_id,
|
||||||
|
username: d.username,
|
||||||
|
email: d.email,
|
||||||
|
instance_role: d.instance_role,
|
||||||
|
is_active: d.is_active,
|
||||||
|
role: d.role,
|
||||||
|
created_at: d.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compose a DTO from an `AdminUserRow` (fetched for validation) and
|
||||||
|
/// the `AppMembershipRow` returned by `upsert`. Saves a re-fetch on
|
||||||
|
/// POST/PATCH at the cost of trusting the two inputs reference the
|
||||||
|
/// same user_id — caller's responsibility.
|
||||||
|
fn compose_dto(user: AdminUserRow, membership: AppMembershipRow) -> AppMemberDto {
|
||||||
|
AppMemberDto {
|
||||||
|
user_id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
instance_role: user.instance_role,
|
||||||
|
is_active: user.is_active,
|
||||||
|
role: membership.role,
|
||||||
|
created_at: membership.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct GrantMemberRequest {
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub role: AppRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PatchMemberRequest {
|
||||||
|
pub role: AppRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_members(
|
||||||
|
State(s): State<AppMembersState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<AppMemberDto>>, AppMembersApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
let rows = s.members.list_for_app_enriched(app.id).await?;
|
||||||
|
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn grant_member(
|
||||||
|
State(s): State<AppMembersState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<GrantMemberRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<AppMemberDto>), AppMembersApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
|
||||||
|
let user = s
|
||||||
|
.users
|
||||||
|
.get(input.user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppMembersApiError::UserNotFound(input.user_id))?;
|
||||||
|
validate_grant_target(&user)?;
|
||||||
|
|
||||||
|
// Atomic insert — if a row already exists, returns None and we 409.
|
||||||
|
// Avoids the find-then-upsert race where two concurrent POSTs would
|
||||||
|
// both pass the existence check and the second `upsert` would
|
||||||
|
// silently rewrite the role.
|
||||||
|
let row = s
|
||||||
|
.members
|
||||||
|
.try_insert(app.id, user.id, input.role)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppMembersApiError::AlreadyMember {
|
||||||
|
username: user.username.clone(),
|
||||||
|
})?;
|
||||||
|
Ok((StatusCode::CREATED, Json(compose_dto(user, row))))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_member(
|
||||||
|
State(s): State<AppMembersState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
||||||
|
Json(input): Json<PatchMemberRequest>,
|
||||||
|
) -> Result<Json<AppMemberDto>, AppMembersApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
|
||||||
|
let user_id = AdminUserId::from(user_id);
|
||||||
|
let user = s
|
||||||
|
.users
|
||||||
|
.get(user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppMembersApiError::UserNotFound(user_id))?;
|
||||||
|
|
||||||
|
// Atomic update — returns None if no row exists, so 404 is decided
|
||||||
|
// by the same statement that does the write. Eliminates the
|
||||||
|
// find-then-upsert race where a concurrent DELETE between the two
|
||||||
|
// calls would let PATCH silently re-create the row.
|
||||||
|
let row = s
|
||||||
|
.members
|
||||||
|
.update_role(app.id, user_id, input.role)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppMembersApiError::MembershipNotFound)?;
|
||||||
|
Ok(Json(compose_dto(user, row)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_member(
|
||||||
|
State(s): State<AppMembersState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
||||||
|
) -> Result<StatusCode, AppMembersApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
s.members.remove(app.id, AdminUserId::from(user_id)).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Validation + helpers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn validate_grant_target(user: &AdminUserRow) -> Result<(), AppMembersApiError> {
|
||||||
|
if !user.is_active {
|
||||||
|
return Err(AppMembersApiError::TargetInactive {
|
||||||
|
username: user.username.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if user.instance_role != InstanceRole::Member {
|
||||||
|
return Err(AppMembersApiError::TargetNotMember {
|
||||||
|
username: user.username.clone(),
|
||||||
|
instance_role: user.instance_role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_app(
|
||||||
|
apps: &dyn AppRepository,
|
||||||
|
ident: &str,
|
||||||
|
) -> Result<picloud_shared::App, AppMembersApiError> {
|
||||||
|
crate::app_repo::resolve_app(apps, ident)
|
||||||
|
.await?
|
||||||
|
.map(|l| l.app)
|
||||||
|
.ok_or_else(|| AppMembersApiError::AppNotFound(ident.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppMembersApiError {
|
||||||
|
#[error("app not found: {0}")]
|
||||||
|
AppNotFound(String),
|
||||||
|
|
||||||
|
#[error("user not found: {0}")]
|
||||||
|
UserNotFound(AdminUserId),
|
||||||
|
|
||||||
|
#[error("no membership exists for this user on this app")]
|
||||||
|
MembershipNotFound,
|
||||||
|
|
||||||
|
#[error("{username} is already a member of this app — use PATCH to change their role")]
|
||||||
|
AlreadyMember { username: String },
|
||||||
|
|
||||||
|
#[error("{username} is deactivated and cannot be added as a member")]
|
||||||
|
TargetInactive { username: String },
|
||||||
|
|
||||||
|
#[error(
|
||||||
|
"{username} has instance_role {instance_role:?} and already has implicit access \
|
||||||
|
on every app — no explicit membership needed"
|
||||||
|
)]
|
||||||
|
TargetNotMember {
|
||||||
|
username: String,
|
||||||
|
instance_role: InstanceRole,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Members(#[from] AppMembersRepositoryError),
|
||||||
|
|
||||||
|
#[error("user repository error: {0}")]
|
||||||
|
Users(#[from] AdminUserRepositoryError),
|
||||||
|
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Apps(#[from] ScriptRepositoryError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for AppMembersApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppMembersApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, body) = match &self {
|
||||||
|
Self::AppNotFound(_)
|
||||||
|
| Self::UserNotFound(_)
|
||||||
|
| Self::MembershipNotFound
|
||||||
|
| Self::Apps(ScriptRepositoryError::NotFound(_)) => {
|
||||||
|
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
|
Self::AlreadyMember { .. } | Self::Apps(ScriptRepositoryError::Conflict(_)) => {
|
||||||
|
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
|
Self::TargetInactive { .. } | Self::TargetNotMember { .. } => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
json!({ "error": self.to_string() }),
|
||||||
|
),
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "app members authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Members(e) => {
|
||||||
|
tracing::error!(error = %e, "app members repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Users(e) => {
|
||||||
|
tracing::error!(error = %e, "admin users repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Apps(ScriptRepositoryError::Db(e)) => {
|
||||||
|
tracing::error!(error = %e, "apps repo error in app_members");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use picloud_shared::{AdminUserId, AppId, AppRole};
|
use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use crate::authz::{AuthzError, AuthzRepo};
|
use crate::authz::{AuthzError, AuthzRepo};
|
||||||
@@ -36,6 +36,20 @@ pub struct AppMembershipRow {
|
|||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `app_members` row joined with `admin_users` so the dashboard's
|
||||||
|
/// Members tab can render usernames / emails / status without an N+1
|
||||||
|
/// fetch per row. Drives `GET /apps/{id}/members`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppMembershipDetail {
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub role: AppRole,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait AppMembersRepository: Send + Sync {
|
pub trait AppMembersRepository: Send + Sync {
|
||||||
/// Single (user, app) lookup. Returns `None` for non-members and
|
/// Single (user, app) lookup. Returns `None` for non-members and
|
||||||
@@ -55,6 +69,27 @@ pub trait AppMembersRepository: Send + Sync {
|
|||||||
role: AppRole,
|
role: AppRole,
|
||||||
) -> Result<AppMembershipRow, AppMembersRepositoryError>;
|
) -> Result<AppMembershipRow, AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Atomic insert. Returns `Some(row)` on success, `None` if a
|
||||||
|
/// membership already exists. Lets the HTTP handler return 409
|
||||||
|
/// without a separate `find` round-trip (no TOCTOU between check
|
||||||
|
/// and insert).
|
||||||
|
async fn try_insert(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Atomic role update. Returns `Some(row)` on success, `None` if no
|
||||||
|
/// membership row exists. Lets PATCH return 404 without a separate
|
||||||
|
/// `find` round-trip (no TOCTOU between check and update).
|
||||||
|
async fn update_role(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
|
||||||
|
|
||||||
/// Remove a membership. No-op (Ok) when the row doesn't exist —
|
/// Remove a membership. No-op (Ok) when the row doesn't exist —
|
||||||
/// the user wasn't a member, which is the desired post-condition.
|
/// the user wasn't a member, which is the desired post-condition.
|
||||||
async fn remove(
|
async fn remove(
|
||||||
@@ -78,6 +113,14 @@ pub trait AppMembersRepository: Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>;
|
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Like `list_for_app` but joined with `admin_users` so the
|
||||||
|
/// dashboard can render member rows in one round-trip. Ordered by
|
||||||
|
/// username for a stable list.
|
||||||
|
async fn list_for_app_enriched(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PostgresAppMembersRepository {
|
pub struct PostgresAppMembersRepository {
|
||||||
@@ -143,6 +186,45 @@ impl AppMembersRepository for PostgresAppMembersRepository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn try_insert(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppMembershipRecord>(
|
||||||
|
"INSERT INTO app_members (app_id, user_id, role) \
|
||||||
|
VALUES ($1, $2, $3) \
|
||||||
|
ON CONFLICT (app_id, user_id) DO NOTHING \
|
||||||
|
RETURNING app_id, user_id, role, created_at",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.bind(role.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_role(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppMembershipRecord>(
|
||||||
|
"UPDATE app_members SET role = $1 \
|
||||||
|
WHERE app_id = $2 AND user_id = $3 \
|
||||||
|
RETURNING app_id, user_id, role, created_at",
|
||||||
|
)
|
||||||
|
.bind(role.as_str())
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_for_user(
|
async fn list_for_user(
|
||||||
&self,
|
&self,
|
||||||
user_id: AdminUserId,
|
user_id: AdminUserId,
|
||||||
@@ -172,6 +254,24 @@ impl AppMembersRepository for PostgresAppMembersRepository {
|
|||||||
.await?;
|
.await?;
|
||||||
rows.into_iter().map(TryInto::try_into).collect()
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_for_app_enriched(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AppMembershipDetailRecord>(
|
||||||
|
"SELECT au.id, au.username, au.email, au.instance_role, au.is_active, \
|
||||||
|
am.role, am.created_at \
|
||||||
|
FROM app_members am \
|
||||||
|
JOIN admin_users au ON au.id = am.user_id \
|
||||||
|
WHERE am.app_id = $1 \
|
||||||
|
ORDER BY au.username",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forwarding impl so the Postgres repo satisfies `AuthzRepo` directly
|
/// Forwarding impl so the Postgres repo satisfies `AuthzRepo` directly
|
||||||
@@ -210,3 +310,31 @@ impl TryFrom<AppMembershipRecord> for AppMembershipRow {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AppMembershipDetailRecord {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
username: String,
|
||||||
|
email: Option<String>,
|
||||||
|
instance_role: String,
|
||||||
|
is_active: bool,
|
||||||
|
role: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<AppMembershipDetailRecord> for AppMembershipDetail {
|
||||||
|
type Error = AppMembersRepositoryError;
|
||||||
|
fn try_from(r: AppMembershipDetailRecord) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
user_id: r.id.into(),
|
||||||
|
username: r.username,
|
||||||
|
email: r.email,
|
||||||
|
instance_role: InstanceRole::from_db_str(&r.instance_role)
|
||||||
|
.ok_or(AppMembersRepositoryError::InvalidRole(r.instance_role))?,
|
||||||
|
is_active: r.is_active,
|
||||||
|
role: AppRole::from_db_str(&r.role)
|
||||||
|
.ok_or(AppMembersRepositoryError::InvalidRole(r.role))?,
|
||||||
|
created_at: r.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_shared::{AdminUserId, App, AppId};
|
use picloud_shared::{AdminUserId, App, AppId};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::repo::ScriptRepositoryError;
|
use crate::repo::ScriptRepositoryError;
|
||||||
|
|
||||||
@@ -20,6 +21,32 @@ pub struct AppLookup {
|
|||||||
pub redirected: bool,
|
pub redirected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a free-form path param (UUID *or* slug *or* historical slug)
|
||||||
|
/// to an `AppLookup`. UUID lookups never set `redirected`; slug lookups
|
||||||
|
/// fall through to `app_slug_history` and set `redirected: true` when
|
||||||
|
/// they hit it.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(None)` when nothing matches — callers map that to their
|
||||||
|
/// own not-found error variant.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Propagates any underlying repository error.
|
||||||
|
pub async fn resolve_app(
|
||||||
|
apps: &dyn AppRepository,
|
||||||
|
ident: &str,
|
||||||
|
) -> Result<Option<AppLookup>, ScriptRepositoryError> {
|
||||||
|
if let Ok(uuid) = ident.parse::<Uuid>() {
|
||||||
|
return Ok(apps
|
||||||
|
.get_by_id(AppId::from(uuid))
|
||||||
|
.await?
|
||||||
|
.map(|app| AppLookup {
|
||||||
|
app,
|
||||||
|
redirected: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
apps.get_by_slug_or_history(ident).await
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait AppRepository: Send + Sync {
|
pub trait AppRepository: Send + Sync {
|
||||||
/// Every app on the instance. For owner/admin callers — `member`
|
/// Every app on the instance. For owner/admin callers — `member`
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ use axum::response::{IntoResponse, Json, Response};
|
|||||||
use axum::routing::{delete, get, post};
|
use axum::routing::{delete, get, post};
|
||||||
use axum::{Extension, Router};
|
use axum::{Extension, Router};
|
||||||
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
||||||
use picloud_shared::{App, AppDomain, AppId, InstanceRole, Principal};
|
use picloud_shared::{App, AppDomain, AppId, AppRole, InstanceRole, Principal};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
||||||
use crate::app_repo::AppRepository;
|
use crate::app_repo::AppRepository;
|
||||||
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||||
use crate::repo::ScriptRepositoryError;
|
use crate::repo::ScriptRepositoryError;
|
||||||
use crate::route_repo::RouteRepository;
|
use crate::route_repo::RouteRepository;
|
||||||
|
|
||||||
@@ -141,6 +141,12 @@ pub struct AppLookupResponse {
|
|||||||
/// at the live slug so dashboards can redirect.
|
/// at the live slug so dashboards can redirect.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub redirect_to: Option<String>,
|
pub redirect_to: Option<String>,
|
||||||
|
/// The caller's role on this app, used by the dashboard to decide
|
||||||
|
/// whether to render admin-only surfaces (Members tab, settings).
|
||||||
|
/// `Owner` and `Admin` both map to `app_admin` (implicit per
|
||||||
|
/// blueprint §11.6); `Member` carries its explicit
|
||||||
|
/// `app_members.role`.
|
||||||
|
pub my_role: Option<AppRole>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -209,12 +215,30 @@ async fn get_app(
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
let my_role = compute_my_role(s.authz.as_ref(), &principal, lookup.app.id).await?;
|
||||||
Ok(Json(AppLookupResponse {
|
Ok(Json(AppLookupResponse {
|
||||||
app: lookup.app,
|
app: lookup.app,
|
||||||
redirect_to,
|
redirect_to,
|
||||||
|
my_role,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute the caller's effective `AppRole` on a specific app. Mirrors
|
||||||
|
/// the implicit-grant logic in `authz::role_grants` but returns the
|
||||||
|
/// role itself (for UI gating) rather than a yes/no decision. `Owner`
|
||||||
|
/// and `Admin` are both implicit `AppAdmin` everywhere; `Member`
|
||||||
|
/// consults `app_members`.
|
||||||
|
async fn compute_my_role(
|
||||||
|
authz: &dyn AuthzRepo,
|
||||||
|
principal: &Principal,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AppsApiError> {
|
||||||
|
match principal.instance_role {
|
||||||
|
InstanceRole::Owner | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)),
|
||||||
|
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn patch_app(
|
async fn patch_app(
|
||||||
State(s): State<AppsState>,
|
State(s): State<AppsState>,
|
||||||
Extension(principal): Extension<Principal>,
|
Extension(principal): Extension<Principal>,
|
||||||
@@ -429,16 +453,7 @@ async fn resolve_app(
|
|||||||
apps: &dyn AppRepository,
|
apps: &dyn AppRepository,
|
||||||
ident: &str,
|
ident: &str,
|
||||||
) -> Result<crate::app_repo::AppLookup, AppsApiError> {
|
) -> Result<crate::app_repo::AppLookup, AppsApiError> {
|
||||||
if let Ok(uuid) = ident.parse::<Uuid>() {
|
crate::app_repo::resolve_app(apps, ident)
|
||||||
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?
|
.await?
|
||||||
.ok_or_else(|| AppsApiError::AppNotFound(ident.to_string()))
|
.ok_or_else(|| AppsApiError::AppNotFound(ident.to_string()))
|
||||||
}
|
}
|
||||||
@@ -546,6 +561,12 @@ impl From<AuthzDenied> for AppsApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<AuthzError> for AppsApiError {
|
||||||
|
fn from(e: AuthzError) -> Self {
|
||||||
|
Self::AuthzRepo(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IntoResponse for AppsApiError {
|
impl IntoResponse for AppsApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, body) = match &self {
|
let (status, body) = match &self {
|
||||||
|
|||||||
@@ -100,6 +100,35 @@ pub async fn require_admin(state: State<AuthState>, req: Request<Body>, next: Ne
|
|||||||
require_authenticated(state, req, next).await
|
require_authenticated(state, req, next).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opportunistic data-plane variant: always inserts an
|
||||||
|
/// `Extension<Option<Principal>>` and forwards the request. Used on
|
||||||
|
/// `/execute/{id}` and the user-route fallback, where most invocations
|
||||||
|
/// are anonymous public HTTP and the few authed ones (dashboard
|
||||||
|
/// test-runs, API keys) should still let scripts see the caller via
|
||||||
|
/// `cx.principal` once services consume it.
|
||||||
|
///
|
||||||
|
/// Failure modes — all degrade to `None` rather than rejecting:
|
||||||
|
/// * No bearer / cookie → `None`.
|
||||||
|
/// * Malformed or unknown token → `None`.
|
||||||
|
/// * DB blip while resolving → `None` (fail-open; the data plane
|
||||||
|
/// should not 500 on transient infra failures for an *optional*
|
||||||
|
/// identity check).
|
||||||
|
///
|
||||||
|
/// Admin-side routes that REQUIRE an identity keep using
|
||||||
|
/// `require_authenticated`.
|
||||||
|
pub async fn attach_principal_if_present(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
mut req: Request<Body>,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
let principal: Option<Principal> = match extract_token(&req) {
|
||||||
|
Some(token) => resolve_principal(&state, &token).await.unwrap_or(None),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
req.extensions_mut().insert(principal);
|
||||||
|
next.run(req).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Decide whether the token is an API key (pic_ prefix) or a session
|
/// Decide whether the token is an API key (pic_ prefix) or a session
|
||||||
/// token, then resolve the corresponding `Principal`. `Ok(None)`
|
/// token, then resolve the corresponding `Principal`. `Ok(None)`
|
||||||
/// means the token was structurally valid but didn't match any active
|
/// means the token was structurally valid but didn't match any active
|
||||||
|
|||||||
@@ -199,21 +199,14 @@ async fn role_grants(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Admin is implicit `editor` on every app (per blueprint §11.6). They
|
/// Admin is implicit `app_admin` on every app (per blueprint §11.6).
|
||||||
/// can create apps and manage users, but NOT touch instance-wide
|
/// They can create apps, manage users, and take any app-scoped action
|
||||||
/// settings or take app-admin-only actions on apps they're not
|
/// on any app without an explicit `app_members` row — single-human
|
||||||
/// explicitly app_admin of. Everything not in this set falls through
|
/// installs would otherwise need to add themselves to every new app.
|
||||||
/// to deny (`InstanceManageSettings`, `AppManageDomains`, `AppAdmin`).
|
/// Only `InstanceManageSettings` (sandbox ceiling, etc.) stays
|
||||||
|
/// owner-only.
|
||||||
const fn admin_grants(cap: Capability) -> bool {
|
const fn admin_grants(cap: Capability) -> bool {
|
||||||
matches!(
|
!matches!(cap, Capability::InstanceManageSettings)
|
||||||
cap,
|
|
||||||
Capability::InstanceCreateApp
|
|
||||||
| Capability::InstanceManageUsers
|
|
||||||
| Capability::AppRead(_)
|
|
||||||
| Capability::AppWriteScript(_)
|
|
||||||
| Capability::AppWriteRoute(_)
|
|
||||||
| Capability::AppLogRead(_)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Member has zero instance authority. App authority requires an
|
/// Member has zero instance authority. App authority requires an
|
||||||
@@ -357,10 +350,23 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn admin_cannot_manage_instance_settings_or_app_admin_actions() {
|
async fn admin_cannot_manage_instance_settings() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Admin);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::InstanceManageSettings)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_is_implicit_app_admin_on_every_app() {
|
||||||
let repo = InMemoryAuthzRepo::default();
|
let repo = InMemoryAuthzRepo::default();
|
||||||
let p = principal(InstanceRole::Admin);
|
let p = principal(InstanceRole::Admin);
|
||||||
let app = AppId::new();
|
let app = AppId::new();
|
||||||
|
// Instance-scoped allowances.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
|
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
|
||||||
Decision::Allow,
|
Decision::Allow,
|
||||||
@@ -371,36 +377,22 @@ mod tests {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
Decision::Allow,
|
Decision::Allow,
|
||||||
);
|
);
|
||||||
|
// Editor-like + app-admin grants both succeed without any
|
||||||
|
// app_members row.
|
||||||
|
for cap in [
|
||||||
|
Capability::AppRead(app),
|
||||||
|
Capability::AppWriteScript(app),
|
||||||
|
Capability::AppWriteRoute(app),
|
||||||
|
Capability::AppLogRead(app),
|
||||||
|
Capability::AppManageDomains(app),
|
||||||
|
Capability::AppAdmin(app),
|
||||||
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
can(&repo, &p, Capability::InstanceManageSettings)
|
can(&repo, &p, cap).await.unwrap(),
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
Decision::Deny,
|
|
||||||
);
|
|
||||||
// Editor-like grants succeed
|
|
||||||
assert_eq!(
|
|
||||||
can(&repo, &p, Capability::AppWriteScript(app))
|
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
Decision::Allow,
|
Decision::Allow,
|
||||||
|
"admin denied app-scoped capability {cap:?}"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
}
|
||||||
can(&repo, &p, Capability::AppWriteRoute(app))
|
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
Decision::Allow,
|
|
||||||
);
|
|
||||||
// App-admin grants do not
|
|
||||||
assert_eq!(
|
|
||||||
can(&repo, &p, Capability::AppManageDomains(app))
|
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
Decision::Deny,
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
|
||||||
Decision::Deny,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -474,6 +466,29 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Editors hold `AppWriteScript` (Save) but **not** `AppAdmin`
|
||||||
|
/// (Delete). The script-delete handler gates on the latter so the
|
||||||
|
/// API can't be tricked into letting an editor remove the script
|
||||||
|
/// they were only allowed to edit.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn editor_can_write_scripts_but_not_delete_them() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
let app = AppId::new();
|
||||||
|
repo.grant(p.user_id, app, AppRole::Editor).await;
|
||||||
|
|
||||||
|
assert!(can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
// Delete is gated on AppAdmin in the handler — editors must be
|
||||||
|
// denied here for that gate to bite.
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn member_with_app_admin_role_can_do_app_admin_actions() {
|
async fn member_with_app_admin_role_can_do_app_admin_actions() {
|
||||||
let repo = InMemoryAuthzRepo::default();
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod api_key_repo;
|
|||||||
pub mod api_keys_api;
|
pub mod api_keys_api;
|
||||||
pub mod app_bootstrap;
|
pub mod app_bootstrap;
|
||||||
pub mod app_domain_repo;
|
pub mod app_domain_repo;
|
||||||
|
pub mod app_members_api;
|
||||||
pub mod app_members_repo;
|
pub mod app_members_repo;
|
||||||
pub mod app_repo;
|
pub mod app_repo;
|
||||||
pub mod apps_api;
|
pub mod apps_api;
|
||||||
@@ -45,10 +46,12 @@ pub use api_key_repo::{
|
|||||||
pub use api_keys_api::{api_keys_router, ApiKeysState};
|
pub use api_keys_api::{api_keys_router, ApiKeysState};
|
||||||
pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
|
pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
|
||||||
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
|
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
|
||||||
|
pub use app_members_api::{app_members_router, AppMembersApiError, AppMembersState};
|
||||||
pub use app_members_repo::{
|
pub use app_members_repo::{
|
||||||
AppMembersRepository, AppMembersRepositoryError, AppMembershipRow, PostgresAppMembersRepository,
|
AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow,
|
||||||
|
PostgresAppMembersRepository,
|
||||||
};
|
};
|
||||||
pub use app_repo::{AppLookup, AppRepository, PostgresAppRepository};
|
pub use app_repo::{resolve_app, AppLookup, AppRepository, PostgresAppRepository};
|
||||||
pub use apps_api::{apps_router, AppsState};
|
pub use apps_api::{apps_router, AppsState};
|
||||||
pub use auth_api::auth_router;
|
pub use auth_api::auth_router;
|
||||||
pub use auth_bootstrap::{
|
pub use auth_bootstrap::{
|
||||||
@@ -56,8 +59,8 @@ pub use auth_bootstrap::{
|
|||||||
};
|
};
|
||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
pub use auth_middleware::{
|
pub use auth_middleware::{
|
||||||
require_admin, require_authenticated, AuthState, AuthedAdmin, API_KEY_PREFIX,
|
attach_principal_if_present, require_admin, require_authenticated, AuthState, AuthedAdmin,
|
||||||
API_KEY_PREFIX_LEN, SESSION_COOKIE,
|
API_KEY_PREFIX, API_KEY_PREFIX_LEN, SESSION_COOKIE,
|
||||||
};
|
};
|
||||||
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
|
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
|
||||||
pub use log_sink::PostgresExecutionLogSink;
|
pub use log_sink::PostgresExecutionLogSink;
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ use axum::{
|
|||||||
http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
|
http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::post,
|
routing::post,
|
||||||
Json, Router,
|
Extension, Json, Router,
|
||||||
};
|
};
|
||||||
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::{
|
||||||
AppId, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
|
AppId, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, Principal, RequestId,
|
||||||
|
ScriptId,
|
||||||
};
|
};
|
||||||
use serde_json::Value as Json_;
|
use serde_json::Value as Json_;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -54,6 +55,11 @@ impl<E, R> Clone for DataPlaneState<E, R> {
|
|||||||
|
|
||||||
/// Build the data-plane router. Handles `POST /execute/:id` — the
|
/// Build the data-plane router. Handles `POST /execute/:id` — the
|
||||||
/// always-available ID-based bypass.
|
/// always-available ID-based bypass.
|
||||||
|
///
|
||||||
|
/// Handlers expect an `Extension<Option<Principal>>` to be attached by
|
||||||
|
/// upstream middleware (`manager-core::attach_principal_if_present`);
|
||||||
|
/// requests without that extension panic at extraction time. The
|
||||||
|
/// picloud binary wires this in `build_app`.
|
||||||
pub fn data_plane_router<E, R>(state: DataPlaneState<E, R>) -> Router
|
pub fn data_plane_router<E, R>(state: DataPlaneState<E, R>) -> Router
|
||||||
where
|
where
|
||||||
E: ExecutorClient + 'static,
|
E: ExecutorClient + 'static,
|
||||||
@@ -67,6 +73,10 @@ where
|
|||||||
/// Build a router that handles ALL paths via the user-defined routing
|
/// Build a router that handles ALL paths via the user-defined routing
|
||||||
/// table. Intended to be merged into the picloud app router as a
|
/// table. Intended to be merged into the picloud app router as a
|
||||||
/// fallback (after the system routes are mounted).
|
/// fallback (after the system routes are mounted).
|
||||||
|
///
|
||||||
|
/// Same middleware expectation as `data_plane_router` — wrap with
|
||||||
|
/// `attach_principal_if_present` so handlers can extract
|
||||||
|
/// `Extension<Option<Principal>>`.
|
||||||
pub fn user_routes_router<E, R>(state: DataPlaneState<E, R>) -> Router
|
pub fn user_routes_router<E, R>(state: DataPlaneState<E, R>) -> Router
|
||||||
where
|
where
|
||||||
E: ExecutorClient + 'static,
|
E: ExecutorClient + 'static,
|
||||||
@@ -84,6 +94,7 @@ where
|
|||||||
async fn execute_by_id<E, R>(
|
async fn execute_by_id<E, R>(
|
||||||
State(state): State<DataPlaneState<E, R>>,
|
State(state): State<DataPlaneState<E, R>>,
|
||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
|
Extension(principal): Extension<Option<Principal>>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> Result<Response, ApiError>
|
) -> Result<Response, ApiError>
|
||||||
@@ -97,7 +108,7 @@ where
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(ApiError::NotFound(id))?;
|
.ok_or(ApiError::NotFound(id))?;
|
||||||
|
|
||||||
let mut req = build_exec_request(id, &script.name, &headers, &body)?;
|
let mut req = build_exec_request(id, &script.name, &headers, &body, script.app_id, principal)?;
|
||||||
req.sandbox_overrides = script.sandbox;
|
req.sandbox_overrides = script.sandbox;
|
||||||
let request_id = req.request_id;
|
let request_id = req.request_id;
|
||||||
let request_path = req.path.clone();
|
let request_path = req.path.clone();
|
||||||
@@ -133,6 +144,7 @@ where
|
|||||||
|
|
||||||
async fn user_route_handler<E, R>(
|
async fn user_route_handler<E, R>(
|
||||||
State(state): State<DataPlaneState<E, R>>,
|
State(state): State<DataPlaneState<E, R>>,
|
||||||
|
Extension(principal): Extension<Option<Principal>>,
|
||||||
request: Request,
|
request: Request,
|
||||||
) -> Result<Response, ApiError>
|
) -> Result<Response, ApiError>
|
||||||
where
|
where
|
||||||
@@ -195,6 +207,8 @@ where
|
|||||||
&script.name,
|
&script.name,
|
||||||
&headers,
|
&headers,
|
||||||
&body_bytes,
|
&body_bytes,
|
||||||
|
app_id,
|
||||||
|
principal,
|
||||||
)?;
|
)?;
|
||||||
req.path = path;
|
req.path = path;
|
||||||
req.params = matched.params;
|
req.params = matched.params;
|
||||||
@@ -264,6 +278,8 @@ fn build_exec_request(
|
|||||||
name: &str,
|
name: &str,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
body: &Bytes,
|
body: &Bytes,
|
||||||
|
app_id: AppId,
|
||||||
|
principal: Option<Principal>,
|
||||||
) -> Result<ExecRequest, ApiError> {
|
) -> Result<ExecRequest, ApiError> {
|
||||||
let mut hmap = BTreeMap::new();
|
let mut hmap = BTreeMap::new();
|
||||||
for (k, v) in headers {
|
for (k, v) in headers {
|
||||||
@@ -279,8 +295,9 @@ fn build_exec_request(
|
|||||||
.map_err(|e| ApiError::BadRequest(format!("invalid JSON body: {e}")))?
|
.map_err(|e| ApiError::BadRequest(format!("invalid JSON body: {e}")))?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let execution_id = ExecutionId::new();
|
||||||
Ok(ExecRequest {
|
Ok(ExecRequest {
|
||||||
execution_id: ExecutionId::new(),
|
execution_id,
|
||||||
request_id: RequestId::new(),
|
request_id: RequestId::new(),
|
||||||
script_id: id,
|
script_id: id,
|
||||||
script_name: name.to_string(),
|
script_name: name.to_string(),
|
||||||
@@ -293,6 +310,13 @@ fn build_exec_request(
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
// Overwritten by the handler after the script is resolved.
|
// Overwritten by the handler after the script is resolved.
|
||||||
sandbox_overrides: picloud_shared::ScriptSandbox::default(),
|
sandbox_overrides: picloud_shared::ScriptSandbox::default(),
|
||||||
|
app_id,
|
||||||
|
principal,
|
||||||
|
// Direct invocations are at depth 0 with a self-referential
|
||||||
|
// root. The triggers framework (v1.1.1) increments depth and
|
||||||
|
// preserves the original root for chained executions.
|
||||||
|
trigger_depth: 0,
|
||||||
|
root_execution_id: execution_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +420,22 @@ pub enum ApiError {
|
|||||||
|
|
||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
|
// Overloaded is the only variant that needs to attach an HTTP
|
||||||
|
// header (Retry-After), so it short-circuits the (status, body)
|
||||||
|
// reduction below. Axum's tuple builder makes per-arm header
|
||||||
|
// injection awkward otherwise.
|
||||||
use ApiError as E;
|
use ApiError as E;
|
||||||
|
if let E::Exec(ExecError::Overloaded { retry_after_secs }) = &self {
|
||||||
|
let retry = retry_after_secs.to_string();
|
||||||
|
let body = Json(serde_json::json!({ "error": self.to_string() }));
|
||||||
|
return (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
[(axum::http::header::RETRY_AFTER, retry)],
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
let (status, message) = match &self {
|
let (status, message) = match &self {
|
||||||
E::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
E::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||||
E::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
E::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||||
@@ -416,6 +455,7 @@ impl IntoResponse for ApiError {
|
|||||||
(StatusCode::INSUFFICIENT_STORAGE, e.to_string())
|
(StatusCode::INSUFFICIENT_STORAGE, e.to_string())
|
||||||
}
|
}
|
||||||
ExecError::Runtime(_) => (StatusCode::BAD_GATEWAY, e.to_string()),
|
ExecError::Runtime(_) => (StatusCode::BAD_GATEWAY, e.to_string()),
|
||||||
|
ExecError::Overloaded { .. } => unreachable!("handled above"),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
(status, Json(serde_json::json!({ "error": message }))).into_response()
|
(status, Json(serde_json::json!({ "error": message }))).into_response()
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ use std::time::Duration;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_executor_core::{Engine, ExecError, ExecRequest, ExecResponse};
|
use picloud_executor_core::{Engine, ExecError, ExecRequest, ExecResponse};
|
||||||
|
|
||||||
|
use crate::gate::{AcquireError, ExecutionGate};
|
||||||
|
|
||||||
/// Maximum wall-clock time we'll wait for a single invocation, regardless
|
/// Maximum wall-clock time we'll wait for a single invocation, regardless
|
||||||
/// of the per-script `timeout_seconds`. Provides a hard ceiling on
|
/// of the per-script `timeout_seconds`. Provides a hard ceiling on
|
||||||
/// resource usage independent of misconfigured scripts.
|
/// resource usage independent of misconfigured scripts.
|
||||||
@@ -30,14 +32,19 @@ pub trait ExecutorClient: Send + Sync {
|
|||||||
/// `executor-core::Engine::execute` is synchronous; we offload it to a
|
/// `executor-core::Engine::execute` is synchronous; we offload it to a
|
||||||
/// blocking thread so it doesn't park a Tokio worker, and apply the
|
/// blocking thread so it doesn't park a Tokio worker, and apply the
|
||||||
/// wall-clock timeout here.
|
/// wall-clock timeout here.
|
||||||
|
///
|
||||||
|
/// Holds an `ExecutionGate` and acquires a permit before `spawn_blocking`
|
||||||
|
/// so a script storm can't drain the blocking-thread pool. The permit
|
||||||
|
/// drops with the future, returning the slot.
|
||||||
pub struct LocalExecutorClient {
|
pub struct LocalExecutorClient {
|
||||||
engine: Arc<Engine>,
|
engine: Arc<Engine>,
|
||||||
|
gate: Arc<ExecutionGate>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LocalExecutorClient {
|
impl LocalExecutorClient {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(engine: Arc<Engine>) -> Self {
|
pub fn new(engine: Arc<Engine>, gate: Arc<ExecutionGate>) -> Self {
|
||||||
Self { engine }
|
Self { engine, gate }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +56,24 @@ impl ExecutorClient for LocalExecutorClient {
|
|||||||
req: ExecRequest,
|
req: ExecRequest,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
) -> Result<ExecResponse, ExecError> {
|
) -> Result<ExecResponse, ExecError> {
|
||||||
|
// Acquire before spending any wall-clock budget. The permit is
|
||||||
|
// held by this future; on `tokio::time::timeout` firing, the
|
||||||
|
// future drops and the permit returns to the pool — but the
|
||||||
|
// detached `spawn_blocking` thread keeps running until the
|
||||||
|
// Rhai script finishes (or panics). So in-use blocking threads
|
||||||
|
// can briefly exceed the gate's permit count after a timeout.
|
||||||
|
// That is intentional: a new admission can be served while the
|
||||||
|
// already-doomed script winds down, which is preferable to
|
||||||
|
// wedging the slot for the worst-case timeout duration.
|
||||||
|
let _permit =
|
||||||
|
self.gate
|
||||||
|
.try_acquire()
|
||||||
|
.map_err(
|
||||||
|
|AcquireError::Overloaded { retry_after_secs }| ExecError::Overloaded {
|
||||||
|
retry_after_secs,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
let timeout = timeout.min(HARD_TIMEOUT_CAP);
|
let timeout = timeout.min(HARD_TIMEOUT_CAP);
|
||||||
let timeout_secs = u32::try_from(timeout.as_secs()).unwrap_or(u32::MAX);
|
let timeout_secs = u32::try_from(timeout.as_secs()).unwrap_or(u32::MAX);
|
||||||
|
|
||||||
|
|||||||
155
crates/orchestrator-core/src/gate.rs
Normal file
155
crates/orchestrator-core/src/gate.rs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
//! Global concurrency gate for the data plane.
|
||||||
|
//!
|
||||||
|
//! Wraps a single `tokio::sync::Semaphore` so the executor can refuse
|
||||||
|
//! admission immediately when too many invocations are already in
|
||||||
|
//! flight. Designed for v1.1.0's single-node MVP — one cap across all
|
||||||
|
//! apps and scripts. Per-app or per-script caps come later when a real
|
||||||
|
//! workload surfaces the need.
|
||||||
|
//!
|
||||||
|
//! Policy: **non-blocking, no queue**. If a permit isn't free right
|
||||||
|
//! now, the call returns `AcquireError::Overloaded` and the data-plane
|
||||||
|
//! HTTP layer translates that to a 503 with `Retry-After: 1`. Pushing
|
||||||
|
//! back hard beats letting requests pile up against a finite pool of
|
||||||
|
//! blocking threads (executor work runs under `spawn_blocking`).
|
||||||
|
//!
|
||||||
|
//! Configured via the `PICLOUD_MAX_CONCURRENT_EXECUTIONS` env var.
|
||||||
|
//! Default is 32 — comfortable for a single-node Pi, low enough that
|
||||||
|
//! a script storm doesn't park every blocking thread.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::sync::{OwnedSemaphorePermit, Semaphore, TryAcquireError};
|
||||||
|
|
||||||
|
/// Env var consulted by `from_env`.
|
||||||
|
pub const ENV_MAX_CONCURRENT: &str = "PICLOUD_MAX_CONCURRENT_EXECUTIONS";
|
||||||
|
|
||||||
|
/// Default cap when the env var is unset or invalid.
|
||||||
|
pub const DEFAULT_MAX_CONCURRENT: u32 = 32;
|
||||||
|
|
||||||
|
/// `Retry-After` header value (seconds) returned alongside the 503
|
||||||
|
/// when the gate refuses. Fixed for v1.1.0; later versions may compute
|
||||||
|
/// a smarter value from in-flight latency.
|
||||||
|
pub const DEFAULT_RETRY_AFTER_SECS: u32 = 1;
|
||||||
|
|
||||||
|
/// Refused admission. The HTTP layer translates this to 503 with a
|
||||||
|
/// `Retry-After` header.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum AcquireError {
|
||||||
|
#[error("at capacity (retry after {retry_after_secs}s)")]
|
||||||
|
Overloaded { retry_after_secs: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global execution gate. Constructed once at orchestrator startup and
|
||||||
|
/// shared via `Arc`. Holds an inner `Arc<Semaphore>` so permits are
|
||||||
|
/// owned (they release on drop independent of the gate's lifetime).
|
||||||
|
pub struct ExecutionGate {
|
||||||
|
permits: Arc<Semaphore>,
|
||||||
|
max_permits: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExecutionGate {
|
||||||
|
/// Construct with an explicit cap. Mostly for tests; production
|
||||||
|
/// uses `from_env`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(max_permits: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
permits: Arc::new(Semaphore::new(max_permits as usize)),
|
||||||
|
max_permits,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `PICLOUD_MAX_CONCURRENT_EXECUTIONS` from the environment.
|
||||||
|
/// Falls back to `DEFAULT_MAX_CONCURRENT` on absence; warns and
|
||||||
|
/// falls back on parse failure or non-positive value. Mirrors the
|
||||||
|
/// `SandboxCeiling::from_env` ergonomics so operators see a
|
||||||
|
/// consistent shape across the env-tunables.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let max = match std::env::var(ENV_MAX_CONCURRENT) {
|
||||||
|
Err(_) => DEFAULT_MAX_CONCURRENT,
|
||||||
|
Ok(v) => match v.parse::<u32>() {
|
||||||
|
Ok(n) if n > 0 => n,
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::warn!(
|
||||||
|
env = ENV_MAX_CONCURRENT,
|
||||||
|
value = %v,
|
||||||
|
"value must be > 0; using default {DEFAULT_MAX_CONCURRENT}"
|
||||||
|
);
|
||||||
|
DEFAULT_MAX_CONCURRENT
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
env = ENV_MAX_CONCURRENT,
|
||||||
|
value = %v,
|
||||||
|
error = %e,
|
||||||
|
"invalid value; using default {DEFAULT_MAX_CONCURRENT}"
|
||||||
|
);
|
||||||
|
DEFAULT_MAX_CONCURRENT
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Self::new(max)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximum concurrent permits this gate was configured for. Useful
|
||||||
|
/// for diagnostics / future metrics.
|
||||||
|
#[must_use]
|
||||||
|
pub fn max_permits(&self) -> u32 {
|
||||||
|
self.max_permits
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-blocking permit acquisition. Returns the owned permit on
|
||||||
|
/// success (drop releases the slot) or `AcquireError::Overloaded`
|
||||||
|
/// when saturated. Sync because the semaphore's non-blocking try is
|
||||||
|
/// sync — no runtime hop needed.
|
||||||
|
pub fn try_acquire(&self) -> Result<OwnedSemaphorePermit, AcquireError> {
|
||||||
|
self.permits
|
||||||
|
.clone()
|
||||||
|
.try_acquire_owned()
|
||||||
|
.map_err(|err| match err {
|
||||||
|
TryAcquireError::NoPermits | TryAcquireError::Closed => AcquireError::Overloaded {
|
||||||
|
retry_after_secs: DEFAULT_RETRY_AFTER_SECS,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acquire_succeeds_under_capacity() {
|
||||||
|
let gate = ExecutionGate::new(2);
|
||||||
|
let _p1 = gate.try_acquire().expect("first permit available");
|
||||||
|
let _p2 = gate.try_acquire().expect("second permit available");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acquire_overloaded_when_saturated() {
|
||||||
|
let gate = ExecutionGate::new(1);
|
||||||
|
let _p = gate.try_acquire().expect("first permit available");
|
||||||
|
let AcquireError::Overloaded { retry_after_secs } = gate
|
||||||
|
.try_acquire()
|
||||||
|
.expect_err("second permit must be refused");
|
||||||
|
assert!(retry_after_secs > 0, "retry-after must be positive");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn permit_drop_releases_slot() {
|
||||||
|
let gate = ExecutionGate::new(1);
|
||||||
|
{
|
||||||
|
let _p = gate.try_acquire().expect("first permit available");
|
||||||
|
}
|
||||||
|
let _ = gate
|
||||||
|
.try_acquire()
|
||||||
|
.expect("slot must be returned after permit drops");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn max_permits_exposed() {
|
||||||
|
let gate = ExecutionGate::new(7);
|
||||||
|
assert_eq!(gate.max_permits(), 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,11 @@
|
|||||||
|
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
pub mod gate;
|
||||||
pub mod resolver;
|
pub mod resolver;
|
||||||
pub mod routing;
|
pub mod routing;
|
||||||
|
|
||||||
pub use api::{data_plane_router, user_routes_router, DataPlaneState};
|
pub use api::{data_plane_router, user_routes_router, DataPlaneState};
|
||||||
pub use client::{ExecutorClient, LocalExecutorClient, RemoteExecutorClient};
|
pub use client::{ExecutorClient, LocalExecutorClient, RemoteExecutorClient};
|
||||||
|
pub use gate::{AcquireError, ExecutionGate};
|
||||||
pub use resolver::{ResolverError, ScriptResolver};
|
pub use resolver::{ResolverError, ScriptResolver};
|
||||||
|
|||||||
42
crates/picloud-cli/Cargo.toml
Normal file
42
crates/picloud-cli/Cargo.toml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
[package]
|
||||||
|
name = "picloud-cli"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description = "PiCloud command-line client"
|
||||||
|
# Each top-level `tests/*.rs` would otherwise auto-discover as its own
|
||||||
|
# test binary, respawning picloud once per file. We want one binary
|
||||||
|
# with module sub-files (auth.rs, apps.rs, …) so the LazyLock fixture
|
||||||
|
# is genuinely shared.
|
||||||
|
autotests = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "pic"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "cli"
|
||||||
|
path = "tests/cli.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
picloud-shared.workspace = true
|
||||||
|
reqwest = { workspace = true, features = ["json"] }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
toml = "0.8"
|
||||||
|
directories = "5"
|
||||||
|
rpassword = "7"
|
||||||
|
anyhow = "1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
assert_cmd = "2"
|
||||||
|
predicates = "3"
|
||||||
|
tempfile = "3"
|
||||||
|
reqwest = { workspace = true, features = ["json", "blocking"] }
|
||||||
|
libc = "0.2"
|
||||||
501
crates/picloud-cli/src/client.rs
Normal file
501
crates/picloud-cli/src/client.rs
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
//! Reqwest-backed HTTP client + minimal wire DTOs.
|
||||||
|
//!
|
||||||
|
//! The CLI deliberately re-declares small request/response structs here
|
||||||
|
//! rather than depending on `manager-core` (and pulling its Postgres
|
||||||
|
//! transitive surface). Fields kept to what the CLI actually sends or
|
||||||
|
//! reads.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::{
|
||||||
|
AdminUserId, ApiKeyId, App, AppId, AppRole, ExecutionLog, InstanceRole, Scope, Script,
|
||||||
|
};
|
||||||
|
use reqwest::{header, Method, RequestBuilder, StatusCode};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::config::Credentials;
|
||||||
|
|
||||||
|
pub struct Client {
|
||||||
|
http: reqwest::Client,
|
||||||
|
url: String,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub fn from_creds(creds: &Credentials) -> Result<Self> {
|
||||||
|
Self::new(&creds.url, &creds.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new(url: &str, token: &str) -> Result<Self> {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.user_agent(concat!("pic/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.build()
|
||||||
|
.context("building HTTP client")?;
|
||||||
|
Ok(Self {
|
||||||
|
http,
|
||||||
|
url: url.trim_end_matches('/').to_string(),
|
||||||
|
token: token.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // used by the trailing-slash unit test below.
|
||||||
|
pub fn url(&self) -> &str {
|
||||||
|
&self.url
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(&self, method: Method, path: &str) -> RequestBuilder {
|
||||||
|
self.http
|
||||||
|
.request(method, format!("{}{path}", self.url))
|
||||||
|
.header(header::AUTHORIZATION, format!("Bearer {}", self.token))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/auth/me`
|
||||||
|
pub async fn auth_me(&self) -> Result<AuthMeDto> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::GET, "/api/v1/admin/auth/me")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/apps`
|
||||||
|
pub async fn apps_list(&self) -> Result<Vec<App>> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::GET, "/api/v1/admin/apps")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/apps/{id_or_slug}` — slug or UUID accepted.
|
||||||
|
pub async fn apps_get(&self, ident: &str) -> Result<AppLookupDto> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::GET, &format!("/api/v1/admin/apps/{ident}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/admin/apps`
|
||||||
|
pub async fn apps_create(&self, body: &CreateAppBody<'_>) -> Result<App> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::POST, "/api/v1/admin/apps")
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/scripts?app={ident}`
|
||||||
|
pub async fn scripts_list_by_app(&self, ident: &str) -> Result<Vec<Script>> {
|
||||||
|
let resp = self
|
||||||
|
.request(
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/v1/admin/scripts?app={}", urlencoded(ident)),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/scripts` — every script the caller can see
|
||||||
|
/// (server filters by membership for `Member`). Lets `pic scripts ls`
|
||||||
|
/// (no `--app`) collapse what used to be an N+1 per-app walk into a
|
||||||
|
/// single request that can't be partially-broken by a concurrent app
|
||||||
|
/// delete.
|
||||||
|
pub async fn scripts_list_all(&self) -> Result<Vec<Script>> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::GET, "/api/v1/admin/scripts")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/v1/admin/apps/{id_or_slug}` with optional `?force=true`.
|
||||||
|
/// Server requires `AppAdmin` capability; without `force`, returns
|
||||||
|
/// 409 if the app still has scripts.
|
||||||
|
pub async fn apps_delete(&self, ident: &str, force: bool) -> Result<()> {
|
||||||
|
let path = if force {
|
||||||
|
format!("/api/v1/admin/apps/{ident}?force=true")
|
||||||
|
} else {
|
||||||
|
format!("/api/v1/admin/apps/{ident}")
|
||||||
|
};
|
||||||
|
let resp = self.request(Method::DELETE, &path).send().await?;
|
||||||
|
decode_status(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/v1/admin/scripts/{id}` — requires `AppAdmin` on the
|
||||||
|
/// owning app (stricter than the edit endpoints, by design).
|
||||||
|
pub async fn scripts_delete(&self, id: &str) -> Result<()> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::DELETE, &format!("/api/v1/admin/scripts/{id}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode_status(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/admin/scripts`
|
||||||
|
pub async fn scripts_create(&self, body: &CreateScriptBody<'_>) -> Result<Script> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::POST, "/api/v1/admin/scripts")
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUT /api/v1/admin/scripts/{id}` — matches the dashboard, which
|
||||||
|
/// uses PUT despite the field-level update semantics.
|
||||||
|
pub async fn scripts_update_source(&self, id: &str, source: &str) -> Result<Script> {
|
||||||
|
let body = UpdateScriptBody { source };
|
||||||
|
let resp = self
|
||||||
|
.request(Method::PUT, &format!("/api/v1/admin/scripts/{id}"))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/execute/{id}` — returns the raw HTTP status, headers,
|
||||||
|
/// and JSON body (the orchestrator marshals the script's output as
|
||||||
|
/// the HTTP response itself, not a wrapper object).
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
body: Value,
|
||||||
|
headers: &[(String, String)],
|
||||||
|
) -> Result<ExecuteResponse> {
|
||||||
|
let mut req = self
|
||||||
|
.request(Method::POST, &format!("/api/v1/execute/{id}"))
|
||||||
|
.json(&body);
|
||||||
|
for (k, v) in headers {
|
||||||
|
req = req.header(k, v);
|
||||||
|
}
|
||||||
|
let resp = req.send().await?;
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
let mut headers_out: BTreeMap<String, String> = BTreeMap::new();
|
||||||
|
for (k, v) in resp.headers() {
|
||||||
|
if let Ok(val) = v.to_str() {
|
||||||
|
headers_out.insert(k.as_str().to_string(), val.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bytes = resp.bytes().await.context("reading execute response")?;
|
||||||
|
let body_json: Value = if bytes.is_empty() {
|
||||||
|
Value::Null
|
||||||
|
} else {
|
||||||
|
serde_json::from_slice(&bytes)
|
||||||
|
.unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into_owned()))
|
||||||
|
};
|
||||||
|
Ok(ExecuteResponse {
|
||||||
|
status_code: status,
|
||||||
|
headers: headers_out,
|
||||||
|
body: body_json,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/scripts/{id}/logs?limit=N`
|
||||||
|
pub async fn logs_list(&self, script_id: &str, limit: u32) -> Result<Vec<ExecutionLog>> {
|
||||||
|
let resp = self
|
||||||
|
.request(
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/v1/admin/scripts/{script_id}/logs?limit={limit}"),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/admin/auth/logout` — best-effort: server returns
|
||||||
|
/// 204 whether or not the token matched a live session, so we just
|
||||||
|
/// fire and discard the body. Caller still wipes the local creds.
|
||||||
|
pub async fn auth_logout(&self) -> Result<()> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::POST, "/api/v1/admin/auth/logout")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode_status(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/api-keys` — caller's keys only (server filters
|
||||||
|
/// by user_id, no cross-user enumeration).
|
||||||
|
pub async fn apikeys_list(&self) -> Result<Vec<ApiKeyDto>> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::GET, "/api/v1/admin/api-keys")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/admin/api-keys` — `raw_token` is in the response
|
||||||
|
/// **once** and never appears in `GET /api-keys` afterward.
|
||||||
|
pub async fn apikeys_mint(&self, body: &MintApiKeyBody<'_>) -> Result<MintApiKeyResponseDto> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::POST, "/api/v1/admin/api-keys")
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/v1/admin/api-keys/{id}` — 404 covers both "doesn't
|
||||||
|
/// exist" and "not yours" (server flattens to avoid enumeration).
|
||||||
|
pub async fn apikeys_delete(&self, id: &str) -> Result<()> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::DELETE, &format!("/api/v1/admin/api-keys/{id}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode_status(resp).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
|
||||||
|
/// it runs before any token exists. Mirrors the dashboard's login.ts
|
||||||
|
/// wire shape (see `manager-core/src/auth_api.rs:49-60`).
|
||||||
|
pub async fn auth_login(url: &str, username: &str, password: &str) -> Result<LoginResponseDto> {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.user_agent(concat!("pic/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.build()
|
||||||
|
.context("building HTTP client")?;
|
||||||
|
let body = LoginRequestBody { username, password };
|
||||||
|
let resp = http
|
||||||
|
.post(format!(
|
||||||
|
"{}/api/v1/admin/auth/login",
|
||||||
|
url.trim_end_matches('/')
|
||||||
|
))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AuthMeDto {
|
||||||
|
// Part of the wire shape (and kept for symmetry with the dashboard's
|
||||||
|
// MeDto), even though the CLI never displays it.
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
#[serde(default)]
|
||||||
|
pub email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AppLookupDto {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub app: App,
|
||||||
|
// Not surfaced yet — `pic apps ls` only shows what `apps_list` returns.
|
||||||
|
// Kept on the DTO so future `pic apps inspect <slug>` work is one-line.
|
||||||
|
#[serde(default)]
|
||||||
|
pub my_role: Option<AppRole>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreateAppBody<'a> {
|
||||||
|
pub slug: &'a str,
|
||||||
|
pub name: &'a str,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreateScriptBody<'a> {
|
||||||
|
pub app_id: AppId,
|
||||||
|
pub name: &'a str,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<&'a str>,
|
||||||
|
pub source: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct UpdateScriptBody<'a> {
|
||||||
|
source: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct LoginRequestBody<'a> {
|
||||||
|
username: &'a str,
|
||||||
|
password: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginResponseDto {
|
||||||
|
pub user: LoginUserDto,
|
||||||
|
pub token: String,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginUserDto {
|
||||||
|
pub id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
#[serde(default)]
|
||||||
|
pub email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct MintApiKeyBody<'a> {
|
||||||
|
pub name: &'a str,
|
||||||
|
pub scopes: &'a [Scope],
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub app_id: Option<AppId>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fresh-mint response. The `raw_token` field is the one and only
|
||||||
|
/// chance to capture the bearer string; subsequent `GET /api-keys`
|
||||||
|
/// returns the `ApiKeyDto` portion without it.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct MintApiKeyResponseDto {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub key: ApiKeyDto,
|
||||||
|
pub raw_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ApiKeyDto {
|
||||||
|
pub id: ApiKeyId,
|
||||||
|
pub prefix: String,
|
||||||
|
pub name: String,
|
||||||
|
pub scopes: Vec<Scope>,
|
||||||
|
pub app_id: Option<AppId>,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
pub last_used_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ExecuteResponse {
|
||||||
|
pub status_code: u16,
|
||||||
|
// Captured for completeness; not displayed today, but `pic invoke -v`
|
||||||
|
// could surface them later without changing this struct.
|
||||||
|
pub headers: BTreeMap<String, String>,
|
||||||
|
pub body: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
/// Parse `-H "Key: value"` or `-H "Key=value"` into a `(name, value)`
|
||||||
|
/// pair. Trims surrounding whitespace on both sides.
|
||||||
|
pub fn parse_kv_header(raw: &str) -> Result<(String, String), String> {
|
||||||
|
let (k, v) = raw
|
||||||
|
.split_once(':')
|
||||||
|
.or_else(|| raw.split_once('='))
|
||||||
|
.ok_or_else(|| format!("expected `Key: value` or `Key=value`, got {raw:?}"))?;
|
||||||
|
let k = k.trim();
|
||||||
|
let v = v.trim();
|
||||||
|
if k.is_empty() {
|
||||||
|
return Err(format!("empty header name in {raw:?}"));
|
||||||
|
}
|
||||||
|
Ok((k.to_string(), v.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn urlencoded(s: &str) -> String {
|
||||||
|
// Minimal pass: percent-encode the few chars that break the query.
|
||||||
|
// Slugs and UUIDs don't contain them in practice, but be safe.
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for ch in s.chars() {
|
||||||
|
match ch {
|
||||||
|
'&' | '=' | '?' | '#' | ' ' => {
|
||||||
|
out.push_str(&format!("%{:02X}", u32::from(ch)));
|
||||||
|
}
|
||||||
|
_ => out.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn decode<T: for<'de> Deserialize<'de>>(resp: reqwest::Response) -> Result<T> {
|
||||||
|
if resp.status().is_success() {
|
||||||
|
return resp.json::<T>().await.context("parsing response body");
|
||||||
|
}
|
||||||
|
Err(server_error(resp).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `decode` but for endpoints whose 2xx response has no body
|
||||||
|
/// (204 No Content) — DELETE handlers, logout.
|
||||||
|
async fn decode_status(resp: reqwest::Response) -> Result<()> {
|
||||||
|
if resp.status().is_success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(server_error(resp).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn server_error(resp: reqwest::Response) -> anyhow::Error {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
let msg = parse_error_body(&body).unwrap_or(body);
|
||||||
|
let hint = role_hint(status);
|
||||||
|
if hint.is_empty() {
|
||||||
|
anyhow!("HTTP {}: {}", status.as_u16(), msg)
|
||||||
|
} else {
|
||||||
|
anyhow!("HTTP {}: {} ({})", status.as_u16(), msg, hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_error_body(s: &str) -> Option<String> {
|
||||||
|
let v: Value = serde_json::from_str(s).ok()?;
|
||||||
|
let obj = v.as_object()?;
|
||||||
|
if let Some(m) = obj.get("message").and_then(Value::as_str) {
|
||||||
|
return Some(m.to_string());
|
||||||
|
}
|
||||||
|
if let Some(e) = obj.get("error").and_then(Value::as_str) {
|
||||||
|
return Some(e.to_string());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role_hint(status: StatusCode) -> &'static str {
|
||||||
|
match status {
|
||||||
|
StatusCode::FORBIDDEN => "your role may lack the required capability; check `pic whoami`",
|
||||||
|
StatusCode::UNAUTHORIZED => "token rejected; re-run `pic login`",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_kv_colon() {
|
||||||
|
let (k, v) = parse_kv_header("X-Foo: bar").unwrap();
|
||||||
|
assert_eq!(k, "X-Foo");
|
||||||
|
assert_eq!(v, "bar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_kv_equals() {
|
||||||
|
let (k, v) = parse_kv_header("X-Foo=bar").unwrap();
|
||||||
|
assert_eq!(k, "X-Foo");
|
||||||
|
assert_eq!(v, "bar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_kv_rejects_no_separator() {
|
||||||
|
assert!(parse_kv_header("X-Foo").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_kv_rejects_empty_name() {
|
||||||
|
assert!(parse_kv_header(": bar").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn url_strip_trailing_slash() {
|
||||||
|
let c = Client::new("http://localhost:8000/", "pic_x").unwrap();
|
||||||
|
assert_eq!(c.url(), "http://localhost:8000");
|
||||||
|
}
|
||||||
|
}
|
||||||
201
crates/picloud-cli/src/cmds/api_keys.rs
Normal file
201
crates/picloud-cli/src/cmds/api_keys.rs
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
//! `pic api-keys` — long-lived bearer-key management.
|
||||||
|
//!
|
||||||
|
//! Server semantics (mirrored from `manager-core/src/api_keys_api.rs`):
|
||||||
|
//! * `raw_token` is returned **once** on mint and never again.
|
||||||
|
//! * `app_id` (optional `--app`) binds the key to one app; instance
|
||||||
|
//! scopes (`instance:*`) are rejected when `--app` is also set.
|
||||||
|
//! * `scopes` is a `text[]` in the wire form (`script:read`, …).
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::Scope;
|
||||||
|
|
||||||
|
use crate::client::{Client, MintApiKeyBody};
|
||||||
|
use crate::config;
|
||||||
|
use crate::output::{KvBlock, OutputMode, Table};
|
||||||
|
|
||||||
|
pub async fn mint(
|
||||||
|
name: &str,
|
||||||
|
scope_strs: &[String],
|
||||||
|
app_ident: Option<&str>,
|
||||||
|
expires: Option<&str>,
|
||||||
|
mode: OutputMode,
|
||||||
|
) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
|
||||||
|
let scopes = parse_scopes(scope_strs)?;
|
||||||
|
let expires_at = expires.map(parse_expires).transpose()?;
|
||||||
|
let app_id = match app_ident {
|
||||||
|
Some(ident) => Some(client.apps_get(ident).await?.app.id),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = MintApiKeyBody {
|
||||||
|
name,
|
||||||
|
scopes: &scopes,
|
||||||
|
app_id,
|
||||||
|
expires_at,
|
||||||
|
};
|
||||||
|
let resp = client.apikeys_mint(&body).await?;
|
||||||
|
|
||||||
|
let mut block = KvBlock::new();
|
||||||
|
block
|
||||||
|
.field("id", resp.key.id.to_string())
|
||||||
|
.field("name", resp.key.name.clone())
|
||||||
|
.field("prefix", resp.key.prefix.clone())
|
||||||
|
.field(
|
||||||
|
"scopes",
|
||||||
|
resp.key
|
||||||
|
.scopes
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(","),
|
||||||
|
)
|
||||||
|
.field(
|
||||||
|
"app_id",
|
||||||
|
resp.key
|
||||||
|
.app_id
|
||||||
|
.map(|a| a.to_string())
|
||||||
|
.unwrap_or_else(|| "-".into()),
|
||||||
|
)
|
||||||
|
.field(
|
||||||
|
"expires_at",
|
||||||
|
resp.key
|
||||||
|
.expires_at
|
||||||
|
.map(|t| t.to_rfc3339())
|
||||||
|
.unwrap_or_else(|| "-".into()),
|
||||||
|
)
|
||||||
|
.field("token", resp.raw_token.clone());
|
||||||
|
block.print(mode);
|
||||||
|
if matches!(mode, OutputMode::Tsv) {
|
||||||
|
// The token row is human-easy-to-miss in a wall of metadata;
|
||||||
|
// call it out exactly once on the human path. Skip on JSON
|
||||||
|
// since machine consumers don't need the nudge.
|
||||||
|
eprintln!("Save this token — it will not be shown again.");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ls(mode: OutputMode) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
let keys = client.apikeys_list().await?;
|
||||||
|
let mut table = Table::new([
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"prefix",
|
||||||
|
"scopes",
|
||||||
|
"app_id",
|
||||||
|
"expires_at",
|
||||||
|
"last_used_at",
|
||||||
|
"created_at",
|
||||||
|
]);
|
||||||
|
for k in keys {
|
||||||
|
table.row([
|
||||||
|
k.id.to_string(),
|
||||||
|
k.name,
|
||||||
|
k.prefix,
|
||||||
|
k.scopes
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(","),
|
||||||
|
k.app_id
|
||||||
|
.map(|a| a.to_string())
|
||||||
|
.unwrap_or_else(|| "-".into()),
|
||||||
|
k.expires_at
|
||||||
|
.map(|t| t.to_rfc3339())
|
||||||
|
.unwrap_or_else(|| "-".into()),
|
||||||
|
k.last_used_at
|
||||||
|
.map(|t| t.to_rfc3339())
|
||||||
|
.unwrap_or_else(|| "-".into()),
|
||||||
|
k.created_at.to_rfc3339(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
table.print(mode);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rm(id: &str) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
client.apikeys_delete(id).await?;
|
||||||
|
println!("Revoked api-key {id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_scopes(raw: &[String]) -> Result<Vec<Scope>> {
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"at least one `--scope` is required (e.g. --scope script:read)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
raw.iter()
|
||||||
|
.map(|s| Scope::from_wire(s).ok_or_else(|| anyhow!("unknown scope: {s}")))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--expires` accepts either RFC 3339 (`2026-12-31T23:59:59Z`) or a
|
||||||
|
/// shorthand `<N>d` / `<N>h` / `<N>m` (days / hours / minutes from now).
|
||||||
|
/// Shorthand wins for the common "key good for 30 days" case; full
|
||||||
|
/// RFC 3339 keeps the door open for precise cutoffs.
|
||||||
|
fn parse_expires(raw: &str) -> Result<DateTime<Utc>> {
|
||||||
|
if let Some(spec) = raw.strip_suffix('d') {
|
||||||
|
let days: i64 = spec.parse().map_err(|_| anyhow!("bad days: {raw}"))?;
|
||||||
|
return Ok(Utc::now() + chrono::Duration::days(days));
|
||||||
|
}
|
||||||
|
if let Some(spec) = raw.strip_suffix('h') {
|
||||||
|
let hours: i64 = spec.parse().map_err(|_| anyhow!("bad hours: {raw}"))?;
|
||||||
|
return Ok(Utc::now() + chrono::Duration::hours(hours));
|
||||||
|
}
|
||||||
|
if let Some(spec) = raw.strip_suffix('m') {
|
||||||
|
let mins: i64 = spec.parse().map_err(|_| anyhow!("bad minutes: {raw}"))?;
|
||||||
|
return Ok(Utc::now() + chrono::Duration::minutes(mins));
|
||||||
|
}
|
||||||
|
DateTime::parse_from_rfc3339(raw)
|
||||||
|
.map(|d| d.with_timezone(&Utc))
|
||||||
|
.map_err(|e| anyhow!("expected RFC 3339 or `<N>d/h/m`, got {raw:?}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_scopes_accepts_wire_form() {
|
||||||
|
let scopes = parse_scopes(&["script:read".into(), "log:read".into()]).unwrap();
|
||||||
|
assert_eq!(scopes, vec![Scope::ScriptRead, Scope::LogRead]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_scopes_rejects_empty() {
|
||||||
|
let err = parse_scopes(&[]).unwrap_err();
|
||||||
|
assert!(format!("{err}").contains("at least one"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_scopes_rejects_unknown() {
|
||||||
|
let err = parse_scopes(&["script:nope".into()]).unwrap_err();
|
||||||
|
assert!(format!("{err}").contains("unknown scope"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_expires_days_shorthand() {
|
||||||
|
let d = parse_expires("7d").unwrap();
|
||||||
|
let diff = (d - Utc::now()).num_days();
|
||||||
|
assert!((6..=7).contains(&diff), "got {diff}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_expires_rfc3339_passes_through() {
|
||||||
|
let d = parse_expires("2030-01-01T00:00:00Z").unwrap();
|
||||||
|
assert_eq!(d.timestamp(), 1893456000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_expires_garbage_errors() {
|
||||||
|
assert!(parse_expires("tomorrow").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
84
crates/picloud-cli/src/cmds/apps.rs
Normal file
84
crates/picloud-cli/src/cmds/apps.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
//! `pic apps` subcommands: `ls`, `create`, `show`, `delete`.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use picloud_shared::AppRole;
|
||||||
|
|
||||||
|
use crate::client::{Client, CreateAppBody};
|
||||||
|
use crate::config;
|
||||||
|
use crate::output::{KvBlock, OutputMode, Table};
|
||||||
|
|
||||||
|
pub async fn ls(mode: OutputMode) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
let apps = client.apps_list().await?;
|
||||||
|
let mut table = Table::new(["slug", "name", "my_role", "created_at"]);
|
||||||
|
for app in apps {
|
||||||
|
// The list endpoint returns App without my_role. We do a per-app
|
||||||
|
// lookup only on demand; for `ls` we leave the column dashed so
|
||||||
|
// the call stays cheap (one HTTP request).
|
||||||
|
table.row([
|
||||||
|
app.slug.clone(),
|
||||||
|
app.name.clone(),
|
||||||
|
"-".to_string(),
|
||||||
|
app.created_at.to_rfc3339(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
table.print(mode);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
let body = CreateAppBody {
|
||||||
|
slug,
|
||||||
|
name: name.unwrap_or(slug),
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
let app = client.apps_create(&body).await?;
|
||||||
|
println!("Created app {}", app.slug);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pic apps show <slug>` — single-app inspect using the lookup
|
||||||
|
/// endpoint, which carries `my_role` for the caller (the `ls` endpoint
|
||||||
|
/// doesn't).
|
||||||
|
pub async fn show(ident: &str, mode: OutputMode) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
let lookup = client.apps_get(ident).await?;
|
||||||
|
let mut block = KvBlock::new();
|
||||||
|
block
|
||||||
|
.field("id", lookup.app.id.to_string())
|
||||||
|
.field("slug", lookup.app.slug.clone())
|
||||||
|
.field("name", lookup.app.name.clone())
|
||||||
|
.field(
|
||||||
|
"description",
|
||||||
|
lookup.app.description.clone().unwrap_or_else(|| "-".into()),
|
||||||
|
)
|
||||||
|
.field("my_role", role_label(lookup.my_role.as_ref()))
|
||||||
|
.field("created_at", lookup.app.created_at.to_rfc3339())
|
||||||
|
.field("updated_at", lookup.app.updated_at.to_rfc3339());
|
||||||
|
block.print(mode);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pic apps delete <slug> [--force]`. Without `--force` the server
|
||||||
|
/// returns 409 if the app still owns scripts — surface that as a
|
||||||
|
/// useful error rather than swallowing.
|
||||||
|
pub async fn delete(ident: &str, force: bool) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
client.apps_delete(ident, force).await?;
|
||||||
|
println!("Deleted app {ident}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role_label(role: Option<&AppRole>) -> String {
|
||||||
|
// Use the wire form so the CLI label matches what the dashboard
|
||||||
|
// shows and what the membership APIs accept.
|
||||||
|
match role {
|
||||||
|
Some(r) => r.as_str().to_string(),
|
||||||
|
None => "-".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
129
crates/picloud-cli/src/cmds/login.rs
Normal file
129
crates/picloud-cli/src/cmds/login.rs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
//! `pic login` — primary auth entry point.
|
||||||
|
//!
|
||||||
|
//! Two flows:
|
||||||
|
//! * **username + password** (default, interactive): POST
|
||||||
|
//! `/api/v1/admin/auth/login` with the credentials and persist the
|
||||||
|
//! returned session token. Mirrors the dashboard's login form.
|
||||||
|
//! * **paste-a-token** (`--token <T>`, or `PICLOUD_TOKEN` env): skip
|
||||||
|
//! the credential exchange and persist a bearer string directly.
|
||||||
|
//! Used by CI and by anyone using a long-lived API key minted via
|
||||||
|
//! `pic api-keys mint`. Validated against `/auth/me` before save.
|
||||||
|
//!
|
||||||
|
//! `--url <U>` (or `PICLOUD_URL`) overrides the URL prompt non-interactively.
|
||||||
|
|
||||||
|
use std::io::{self, BufRead, Write};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use picloud_shared::InstanceRole;
|
||||||
|
|
||||||
|
use crate::client::{self, Client};
|
||||||
|
use crate::config::{save, Credentials};
|
||||||
|
|
||||||
|
const DEFAULT_URL: &str = "http://localhost:8000";
|
||||||
|
|
||||||
|
pub async fn run(url_arg: Option<&str>, token_arg: Option<&str>) -> Result<()> {
|
||||||
|
let url = resolve_url(url_arg)?;
|
||||||
|
let token_from_env = std::env::var("PICLOUD_TOKEN")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
let bearer_token = token_arg.map(str::to_string).or(token_from_env);
|
||||||
|
|
||||||
|
let (token, username, role) = match bearer_token {
|
||||||
|
Some(t) => login_with_bearer(&url, &t).await?,
|
||||||
|
None => login_with_password(&url).await?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let creds = Credentials {
|
||||||
|
url: url.clone(),
|
||||||
|
token,
|
||||||
|
username: username.clone(),
|
||||||
|
};
|
||||||
|
save(&creds)?;
|
||||||
|
println!(
|
||||||
|
"Logged in as {username} ({}) at {url}",
|
||||||
|
instance_role_label(&role)
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login_with_password(url: &str) -> Result<(String, String, InstanceRole)> {
|
||||||
|
let username = prompt_line("Username: ")?;
|
||||||
|
if username.is_empty() {
|
||||||
|
anyhow::bail!("username is required");
|
||||||
|
}
|
||||||
|
let password = read_password()?;
|
||||||
|
let resp = client::auth_login(url, &username, &password).await?;
|
||||||
|
Ok((resp.token, resp.user.username, resp.user.instance_role))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a password without echoing it where possible. Falls back to a
|
||||||
|
/// plain stdin read when no controlling terminal is attached — CI
|
||||||
|
/// systems and `cargo test`'s piped stdin both land here, and dying
|
||||||
|
/// outright would block scripted use entirely. The fallback is louder
|
||||||
|
/// (visible characters), but it's that or no functioning login.
|
||||||
|
fn read_password() -> Result<String> {
|
||||||
|
match rpassword::prompt_password("Password: ") {
|
||||||
|
Ok(p) => Ok(p),
|
||||||
|
Err(_) => {
|
||||||
|
eprint!("Password: ");
|
||||||
|
io::stderr().flush()?;
|
||||||
|
let mut buf = String::new();
|
||||||
|
io::stdin()
|
||||||
|
.lock()
|
||||||
|
.read_line(&mut buf)
|
||||||
|
.context("reading password from stdin")?;
|
||||||
|
Ok(buf.trim_end_matches(['\r', '\n']).to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bearer-token path: validate against `/auth/me` so a typo doesn't get
|
||||||
|
/// persisted, then trust the username the server reports rather than
|
||||||
|
/// whatever the user typed (which they didn't type at all in this mode).
|
||||||
|
async fn login_with_bearer(url: &str, token: &str) -> Result<(String, String, InstanceRole)> {
|
||||||
|
let client = Client::new(url, token)?;
|
||||||
|
let me = client.auth_me().await?;
|
||||||
|
Ok((token.to_string(), me.username, me.instance_role))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instance_role_label(role: &InstanceRole) -> &'static str {
|
||||||
|
match role {
|
||||||
|
InstanceRole::Owner => "owner",
|
||||||
|
InstanceRole::Admin => "admin",
|
||||||
|
InstanceRole::Member => "member",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_url(url_arg: Option<&str>) -> Result<String> {
|
||||||
|
if let Some(u) = url_arg {
|
||||||
|
return Ok(u.trim_end_matches('/').to_string());
|
||||||
|
}
|
||||||
|
if let Ok(env_url) = std::env::var("PICLOUD_URL") {
|
||||||
|
if !env_url.is_empty() {
|
||||||
|
return Ok(env_url.trim_end_matches('/').to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let typed = prompt_with_default("PiCloud URL", DEFAULT_URL)?;
|
||||||
|
Ok(typed.trim_end_matches('/').to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_line(label: &str) -> Result<String> {
|
||||||
|
print!("{label}");
|
||||||
|
io::stdout().flush()?;
|
||||||
|
let mut buf = String::new();
|
||||||
|
io::stdin().lock().read_line(&mut buf)?;
|
||||||
|
Ok(buf.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_with_default(label: &str, default: &str) -> Result<String> {
|
||||||
|
print!("{label} [{default}]: ");
|
||||||
|
io::stdout().flush()?;
|
||||||
|
let mut buf = String::new();
|
||||||
|
io::stdin().lock().read_line(&mut buf)?;
|
||||||
|
let trimmed = buf.trim();
|
||||||
|
Ok(if trimmed.is_empty() {
|
||||||
|
default.to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
29
crates/picloud-cli/src/cmds/logout.rs
Normal file
29
crates/picloud-cli/src/cmds/logout.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
//! `pic logout` — revoke the saved session server-side, then wipe the
|
||||||
|
//! local credentials file.
|
||||||
|
//!
|
||||||
|
//! Idempotent: if the file doesn't exist or the server already forgot
|
||||||
|
//! the session, we still succeed. The point is leaving the user in a
|
||||||
|
//! clean "no token" state, not enforcing that a session existed.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::client::Client;
|
||||||
|
use crate::config;
|
||||||
|
|
||||||
|
pub async fn run() -> Result<()> {
|
||||||
|
// Load before delete so we have a token to POST /logout with; if
|
||||||
|
// there's no creds file there's also nothing to revoke server-side.
|
||||||
|
let creds = config::load().ok();
|
||||||
|
|
||||||
|
if let Some(creds) = creds {
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
// Best-effort: a 4xx (token already invalid) or network error
|
||||||
|
// shouldn't block the local wipe. The whole point of logout is
|
||||||
|
// leaving no credentials on disk.
|
||||||
|
let _ = client.auth_logout().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
config::delete()?;
|
||||||
|
println!("Logged out");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
79
crates/picloud-cli/src/cmds/logs.rs
Normal file
79
crates/picloud-cli/src/cmds/logs.rs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
//! `pic logs <script-id>` — print recent execution log rows.
|
||||||
|
//!
|
||||||
|
//! In TSV mode emits a header + truncated-summary rows (`pic logs` was
|
||||||
|
//! previously headerless — inconsistent with `apps ls` / `scripts ls`).
|
||||||
|
//! In JSON mode emits the raw `ExecutionLog` array (no truncation),
|
||||||
|
//! letting `jq` consumers see request/response bodies in full.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use picloud_shared::{ExecutionLog, ExecutionStatus};
|
||||||
|
|
||||||
|
use crate::client::Client;
|
||||||
|
use crate::config;
|
||||||
|
use crate::output::{OutputMode, Table};
|
||||||
|
|
||||||
|
pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
let entries = client.logs_list(script_id, limit).await?;
|
||||||
|
match mode {
|
||||||
|
OutputMode::Tsv => render_tsv(&entries),
|
||||||
|
OutputMode::Json => render_json(&entries),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_tsv(entries: &[ExecutionLog]) {
|
||||||
|
let mut table = Table::new(["created_at", "status", "summary"]);
|
||||||
|
for e in entries {
|
||||||
|
let summary = summarize(&e.response_body, &e.script_logs);
|
||||||
|
table.row([
|
||||||
|
e.created_at.to_rfc3339(),
|
||||||
|
status_label(&e.status).to_string(),
|
||||||
|
truncate(&summary, 120),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
table.print(OutputMode::Tsv);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_json(entries: &[ExecutionLog]) {
|
||||||
|
// Pretty for human jq-piping; consumers that want compact can pipe
|
||||||
|
// through `jq -c`.
|
||||||
|
let s = serde_json::to_string_pretty(entries).unwrap_or_else(|_| "[]".to_string());
|
||||||
|
println!("{s}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_label(s: &ExecutionStatus) -> &'static str {
|
||||||
|
match s {
|
||||||
|
ExecutionStatus::Success => "success",
|
||||||
|
ExecutionStatus::Error => "error",
|
||||||
|
ExecutionStatus::Timeout => "timeout",
|
||||||
|
ExecutionStatus::BudgetExceeded => "budget_exceeded",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize(response_body: &Option<serde_json::Value>, script_logs: &serde_json::Value) -> String {
|
||||||
|
// Prefer the last script-side log line (often the most useful for
|
||||||
|
// grepping). Fall back to the response body.
|
||||||
|
if let Some(arr) = script_logs.as_array() {
|
||||||
|
if let Some(last) = arr.last() {
|
||||||
|
if let Some(msg) = last.get("message").and_then(|m| m.as_str()) {
|
||||||
|
return msg.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response_body
|
||||||
|
.as_ref()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.unwrap_or_else(|| "-".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(s: &str, n: usize) -> String {
|
||||||
|
let normalized = s.replace('\n', " ");
|
||||||
|
if normalized.chars().count() <= n {
|
||||||
|
normalized
|
||||||
|
} else {
|
||||||
|
let head: String = normalized.chars().take(n).collect();
|
||||||
|
format!("{head}…")
|
||||||
|
}
|
||||||
|
}
|
||||||
7
crates/picloud-cli/src/cmds/mod.rs
Normal file
7
crates/picloud-cli/src/cmds/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod api_keys;
|
||||||
|
pub mod apps;
|
||||||
|
pub mod login;
|
||||||
|
pub mod logout;
|
||||||
|
pub mod logs;
|
||||||
|
pub mod scripts;
|
||||||
|
pub mod whoami;
|
||||||
197
crates/picloud-cli/src/cmds/scripts.rs
Normal file
197
crates/picloud-cli/src/cmds/scripts.rs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
//! `pic scripts ls | deploy | invoke | delete`.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{self, Read, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use picloud_shared::AppId;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::client::{Client, CreateScriptBody};
|
||||||
|
use crate::config;
|
||||||
|
use crate::output::{OutputMode, Table};
|
||||||
|
|
||||||
|
pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
|
||||||
|
let mut table = Table::new(["id", "app_slug", "name", "version", "updated_at"]);
|
||||||
|
|
||||||
|
if let Some(ident) = app {
|
||||||
|
let app = client.apps_get(ident).await?;
|
||||||
|
let scripts = client.scripts_list_by_app(&app.app.slug).await?;
|
||||||
|
for s in scripts {
|
||||||
|
table.row([
|
||||||
|
s.id.to_string(),
|
||||||
|
app.app.slug.clone(),
|
||||||
|
s.name,
|
||||||
|
s.version.to_string(),
|
||||||
|
s.updated_at.to_rfc3339(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No filter → use the single `GET /admin/scripts` call. Server
|
||||||
|
// filters by membership for `Member`; for `Admin`/`Owner` it
|
||||||
|
// returns every script. Two requests total (apps + scripts) run
|
||||||
|
// in parallel; the per-app walk we used to do here aborted on
|
||||||
|
// the first 404 when another caller deleted an app mid-listing,
|
||||||
|
// and was the entire reason a 5× retry existed in the tests.
|
||||||
|
let (apps, scripts) = tokio::try_join!(client.apps_list(), client.scripts_list_all())?;
|
||||||
|
let slug_by_id: HashMap<AppId, String> = apps.into_iter().map(|a| (a.id, a.slug)).collect();
|
||||||
|
for s in scripts {
|
||||||
|
let app_slug = slug_by_id
|
||||||
|
.get(&s.app_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
table.row([
|
||||||
|
s.id.to_string(),
|
||||||
|
app_slug,
|
||||||
|
s.name,
|
||||||
|
s.version.to_string(),
|
||||||
|
s.updated_at.to_rfc3339(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
table.print(mode);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn deploy(
|
||||||
|
file: &Path,
|
||||||
|
app_ident: &str,
|
||||||
|
name_override: Option<&str>,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
|
||||||
|
let source =
|
||||||
|
std::fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?;
|
||||||
|
let name = match name_override {
|
||||||
|
Some(n) => n.to_string(),
|
||||||
|
None => file
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow!(
|
||||||
|
"could not derive script name from path {} (use --name)",
|
||||||
|
file.display()
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Slug-or-id resolution: a single GET satisfies both lookups and
|
||||||
|
// gives us the canonical app_id needed for create.
|
||||||
|
let app = client.apps_get(app_ident).await?;
|
||||||
|
|
||||||
|
let existing = client.scripts_list_by_app(app_ident).await?;
|
||||||
|
if let Some(s) = existing.into_iter().find(|s| s.name == name) {
|
||||||
|
let updated = client
|
||||||
|
.scripts_update_source(&s.id.to_string(), &source)
|
||||||
|
.await?;
|
||||||
|
println!("Updated {} v{}", updated.name, updated.version);
|
||||||
|
} else {
|
||||||
|
let body = CreateScriptBody {
|
||||||
|
app_id: app.app.id,
|
||||||
|
name: &name,
|
||||||
|
description,
|
||||||
|
source: &source,
|
||||||
|
};
|
||||||
|
let created = client.scripts_create(&body).await?;
|
||||||
|
println!("Created {} v{}", created.name, created.version);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn invoke(id: &str, body_arg: Option<&str>, headers: &[(String, String)]) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
|
||||||
|
let body = parse_body_arg(body_arg)?;
|
||||||
|
let resp = client.execute(id, body, headers).await?;
|
||||||
|
// Status to stderr so stdout stays JSON for piping into jq.
|
||||||
|
let _ = writeln!(io::stderr(), "<- HTTP {}", resp.status_code);
|
||||||
|
let pretty = serde_json::to_string_pretty(&resp.body).unwrap_or_else(|_| resp.body.to_string());
|
||||||
|
println!("{pretty}");
|
||||||
|
if (200..400).contains(&resp.status_code) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("execute returned HTTP {}", resp.status_code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pic scripts delete <id>`. Requires `AppAdmin` on the owning app
|
||||||
|
/// server-side, which is stricter than the edit endpoints — Editor
|
||||||
|
/// can deploy/update but not destroy. Surfaces that as a 403 with the
|
||||||
|
/// usual role hint.
|
||||||
|
pub async fn delete(id: &str) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
client.scripts_delete(id).await?;
|
||||||
|
println!("Deleted script {id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_body_arg(arg: Option<&str>) -> Result<Value> {
|
||||||
|
match arg {
|
||||||
|
None => Ok(Value::Object(serde_json::Map::new())),
|
||||||
|
Some("@-") => {
|
||||||
|
let mut buf = String::new();
|
||||||
|
io::stdin()
|
||||||
|
.read_to_string(&mut buf)
|
||||||
|
.context("reading stdin")?;
|
||||||
|
parse_or_string(&buf)
|
||||||
|
}
|
||||||
|
Some(raw) if raw.starts_with('@') => {
|
||||||
|
let path = &raw[1..];
|
||||||
|
let text = std::fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("reading body file {path}"))?;
|
||||||
|
parse_or_string(&text)
|
||||||
|
}
|
||||||
|
Some(raw) => parse_or_string(raw),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_or_string(s: &str) -> Result<Value> {
|
||||||
|
let trimmed = s.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(Value::Object(serde_json::Map::new()));
|
||||||
|
}
|
||||||
|
serde_json::from_str(trimmed)
|
||||||
|
.with_context(|| format!("body is not valid JSON: {}", truncate(trimmed, 80)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(s: &str, n: usize) -> String {
|
||||||
|
if s.len() <= n {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}…", &s[..n])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_body_inline_json() {
|
||||||
|
let v = parse_body_arg(Some(r#"{"x":1}"#)).unwrap();
|
||||||
|
assert_eq!(v["x"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_body_none_is_empty_object() {
|
||||||
|
let v = parse_body_arg(None).unwrap();
|
||||||
|
assert!(v.is_object());
|
||||||
|
assert_eq!(v.as_object().unwrap().len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_body_invalid_json_reports() {
|
||||||
|
let err = parse_body_arg(Some("not-json{")).unwrap_err();
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(msg.contains("not valid JSON"), "got: {msg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
34
crates/picloud-cli/src/cmds/whoami.rs
Normal file
34
crates/picloud-cli/src/cmds/whoami.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
//! `pic whoami` — re-validates the saved token by hitting `/auth/me`
|
||||||
|
//! every time. Cached username in the credentials file is for
|
||||||
|
//! display-only contexts; this command is the source of truth.
|
||||||
|
//!
|
||||||
|
//! TSV output uses `KvBlock` (aligned `key: value` rows), JSON output
|
||||||
|
//! is a flat object — both downstream-friendly without the user having
|
||||||
|
//! to parse a headerless tab-line.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use picloud_shared::InstanceRole;
|
||||||
|
|
||||||
|
use crate::client::Client;
|
||||||
|
use crate::config;
|
||||||
|
use crate::output::{KvBlock, OutputMode};
|
||||||
|
|
||||||
|
pub async fn run(mode: OutputMode) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
let me = client.auth_me().await?;
|
||||||
|
let role = match me.instance_role {
|
||||||
|
InstanceRole::Owner => "owner",
|
||||||
|
InstanceRole::Admin => "admin",
|
||||||
|
InstanceRole::Member => "member",
|
||||||
|
};
|
||||||
|
let email = me.email.as_deref().unwrap_or("-");
|
||||||
|
let mut block = KvBlock::new();
|
||||||
|
block
|
||||||
|
.field("username", me.username)
|
||||||
|
.field("role", role)
|
||||||
|
.field("email", email)
|
||||||
|
.field("url", creds.url.clone());
|
||||||
|
block.print(mode);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
153
crates/picloud-cli/src/config.rs
Normal file
153
crates/picloud-cli/src/config.rs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
//! On-disk credentials store.
|
||||||
|
//!
|
||||||
|
//! Path is resolved via `directories::ProjectDirs` so the file lives in
|
||||||
|
//! the platform-appropriate config dir (XDG on Linux, Library on macOS,
|
||||||
|
//! AppData on Windows). On POSIX the file is forced to mode 0600 so the
|
||||||
|
//! pasted bearer token isn't world-readable.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use directories::ProjectDirs;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Credentials {
|
||||||
|
pub url: String,
|
||||||
|
pub token: String,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the credentials file path. Honors `PICLOUD_CONFIG_DIR` as an
|
||||||
|
/// override (used by tests to redirect to a tempdir) before falling
|
||||||
|
/// back to the platform default.
|
||||||
|
pub fn credentials_path() -> Result<PathBuf> {
|
||||||
|
if let Ok(dir) = std::env::var("PICLOUD_CONFIG_DIR") {
|
||||||
|
return Ok(PathBuf::from(dir).join("credentials"));
|
||||||
|
}
|
||||||
|
let dirs = ProjectDirs::from("dev", "picloud", "picloud")
|
||||||
|
.ok_or_else(|| anyhow!("could not determine config directory"))?;
|
||||||
|
Ok(dirs.config_dir().join("credentials"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Result<Credentials> {
|
||||||
|
let path = credentials_path()?;
|
||||||
|
let body = fs::read_to_string(&path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"no credentials at {}. run `pic login` first",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
toml::from_str(&body).with_context(|| format!("failed to parse {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolution order used by every non-login command:
|
||||||
|
/// 1. If both `PICLOUD_URL` and `PICLOUD_TOKEN` are set (and non-empty),
|
||||||
|
/// use them directly. Matches gcloud/aws/kubectl semantics — env
|
||||||
|
/// wins so CI never accidentally reads a developer's stale file.
|
||||||
|
/// 2. Otherwise fall back to the on-disk credentials file.
|
||||||
|
///
|
||||||
|
/// Username is best-effort: env mode has no way to know the real one
|
||||||
|
/// (no round-trip to `/auth/me`), so it shows as `"-"` in `whoami`
|
||||||
|
/// output. Callers that need the canonical username re-fetch via
|
||||||
|
/// `Client::auth_me`.
|
||||||
|
pub fn resolve() -> Result<Credentials> {
|
||||||
|
if let (Ok(url), Ok(token)) = (std::env::var("PICLOUD_URL"), std::env::var("PICLOUD_TOKEN")) {
|
||||||
|
if !url.is_empty() && !token.is_empty() {
|
||||||
|
return Ok(Credentials {
|
||||||
|
url,
|
||||||
|
token,
|
||||||
|
username: "-".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the on-disk credentials file. Idempotent — silently succeeds
|
||||||
|
/// if the file is already gone (the user already logged out, or never
|
||||||
|
/// logged in to begin with).
|
||||||
|
pub fn delete() -> Result<()> {
|
||||||
|
let path = credentials_path()?;
|
||||||
|
match fs::remove_file(&path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(err) => Err(err).with_context(|| format!("removing {}", path.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(creds: &Credentials) -> Result<()> {
|
||||||
|
let path = credentials_path()?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let body = toml::to_string(creds).context("serializing credentials")?;
|
||||||
|
write_private(&path, body.as_bytes())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
let mut f = fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(path)
|
||||||
|
.with_context(|| format!("opening {}", path.display()))?;
|
||||||
|
f.write_all(bytes)
|
||||||
|
.with_context(|| format!("writing {}", path.display()))?;
|
||||||
|
// Belt-and-suspenders: re-set perms in case the file already existed
|
||||||
|
// with a wider mode (mode() on create doesn't downgrade existing).
|
||||||
|
let mut perms = fs::metadata(path)?.permissions();
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
perms.set_mode(0o600);
|
||||||
|
fs::set_permissions(path, perms)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||||
|
fs::write(path, bytes).with_context(|| format!("writing {}", path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_toml() {
|
||||||
|
let creds = Credentials {
|
||||||
|
url: "http://localhost:8000".to_string(),
|
||||||
|
token: "pic_abc".to_string(),
|
||||||
|
username: "admin".to_string(),
|
||||||
|
};
|
||||||
|
let serialized = toml::to_string(&creds).unwrap();
|
||||||
|
let parsed: Credentials = toml::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(creds, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn posix_mode_is_0600() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::env::set_var("PICLOUD_CONFIG_DIR", dir.path());
|
||||||
|
let creds = Credentials {
|
||||||
|
url: "http://localhost:8000".to_string(),
|
||||||
|
token: "pic_secret".to_string(),
|
||||||
|
username: "admin".to_string(),
|
||||||
|
};
|
||||||
|
save(&creds).unwrap();
|
||||||
|
let path = credentials_path().unwrap();
|
||||||
|
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||||
|
assert_eq!(mode, 0o600, "credentials must be readable only by owner");
|
||||||
|
std::env::remove_var("PICLOUD_CONFIG_DIR");
|
||||||
|
}
|
||||||
|
}
|
||||||
268
crates/picloud-cli/src/main.rs
Normal file
268
crates/picloud-cli/src/main.rs
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
//! PiCloud command-line client.
|
||||||
|
//!
|
||||||
|
//! Thin client over the existing admin + execute HTTP surface — the
|
||||||
|
//! server gains nothing for the CLI; the CLI is just a developer
|
||||||
|
//! ergonomics layer over endpoints the dashboard already uses.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use clap::{Args, Parser, Subcommand};
|
||||||
|
|
||||||
|
mod client;
|
||||||
|
mod cmds;
|
||||||
|
mod config;
|
||||||
|
mod output;
|
||||||
|
|
||||||
|
use crate::output::OutputMode;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "pic", version, about = "PiCloud command-line client")]
|
||||||
|
struct Cli {
|
||||||
|
/// Output format for `ls` / `show` / `whoami` / `logs` commands.
|
||||||
|
/// TSV stays pipe-friendly; JSON is `jq`-ready.
|
||||||
|
#[arg(long, value_enum, global = true, default_value_t = OutputMode::Tsv)]
|
||||||
|
output: OutputMode,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: Cmd,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Cmd {
|
||||||
|
/// Authenticate with the server. Default flow prompts for username
|
||||||
|
/// + password and saves the returned session token; `--token` skips
|
||||||
|
/// the password exchange and persists a bearer string directly (use
|
||||||
|
/// this for long-lived API keys minted via `pic api-keys mint`).
|
||||||
|
Login(LoginArgs),
|
||||||
|
|
||||||
|
/// Revoke the saved session server-side and delete the local
|
||||||
|
/// credentials file. Idempotent.
|
||||||
|
Logout,
|
||||||
|
|
||||||
|
/// Print the principal the saved token resolves to.
|
||||||
|
Whoami,
|
||||||
|
|
||||||
|
/// App management.
|
||||||
|
Apps {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: AppsCmd,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Script management.
|
||||||
|
Scripts {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: ScriptsCmd,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Long-lived bearer API key management.
|
||||||
|
#[command(name = "api-keys")]
|
||||||
|
ApiKeys {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: ApiKeysCmd,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Tail recent execution logs for a script.
|
||||||
|
Logs(LogsArgs),
|
||||||
|
|
||||||
|
/// Top-level alias for `pic scripts invoke <id>`.
|
||||||
|
Invoke(InvokeArgs),
|
||||||
|
|
||||||
|
/// Top-level alias for `pic scripts deploy <file> --app <slug>`.
|
||||||
|
Deploy(DeployArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct LoginArgs {
|
||||||
|
/// Override the URL prompt non-interactively. Also reads
|
||||||
|
/// `PICLOUD_URL`.
|
||||||
|
#[arg(long)]
|
||||||
|
url: Option<String>,
|
||||||
|
|
||||||
|
/// Skip the username + password exchange and persist this bearer
|
||||||
|
/// directly (validated against `/auth/me` first). Also reads
|
||||||
|
/// `PICLOUD_TOKEN`.
|
||||||
|
#[arg(long)]
|
||||||
|
token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum AppsCmd {
|
||||||
|
/// List apps the caller can see.
|
||||||
|
Ls,
|
||||||
|
|
||||||
|
/// Create a new app.
|
||||||
|
Create {
|
||||||
|
slug: String,
|
||||||
|
#[arg(long)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
description: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show a single app, including the caller's role in it.
|
||||||
|
Show { ident: String },
|
||||||
|
|
||||||
|
/// Delete an app. Without `--force`, the server rejects if the app
|
||||||
|
/// still owns scripts.
|
||||||
|
Delete {
|
||||||
|
ident: String,
|
||||||
|
#[arg(long)]
|
||||||
|
force: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum ScriptsCmd {
|
||||||
|
/// List scripts. With `--app`, scoped to one app; without, one
|
||||||
|
/// `GET /admin/scripts` for everything the caller can see.
|
||||||
|
Ls {
|
||||||
|
#[arg(long)]
|
||||||
|
app: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Upload a `.rhai` file. Patches the existing script with the
|
||||||
|
/// matching name in `--app` if one exists, otherwise creates it.
|
||||||
|
Deploy(DeployArgs),
|
||||||
|
|
||||||
|
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
|
||||||
|
/// `--body @-` for stdin, or inline JSON.
|
||||||
|
Invoke(InvokeArgs),
|
||||||
|
|
||||||
|
/// Delete a script. Requires AppAdmin on the owning app.
|
||||||
|
Delete { id: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct DeployArgs {
|
||||||
|
file: PathBuf,
|
||||||
|
#[arg(long)]
|
||||||
|
app: String,
|
||||||
|
#[arg(long)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct InvokeArgs {
|
||||||
|
id: String,
|
||||||
|
#[arg(long)]
|
||||||
|
body: Option<String>,
|
||||||
|
#[arg(short = 'H', long = "header", value_parser = client::parse_kv_header)]
|
||||||
|
headers: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum ApiKeysCmd {
|
||||||
|
/// Mint a new long-lived bearer key. Token printed exactly once.
|
||||||
|
Mint {
|
||||||
|
name: String,
|
||||||
|
/// Repeat for multiple scopes: `--scope script:read --scope log:read`.
|
||||||
|
#[arg(long = "scope", required = true)]
|
||||||
|
scopes: Vec<String>,
|
||||||
|
/// Bind the key to a single app (slug or id). Rejects
|
||||||
|
/// `instance:*` scopes when set.
|
||||||
|
#[arg(long)]
|
||||||
|
app: Option<String>,
|
||||||
|
/// Absolute RFC 3339 (`2026-12-31T23:59:59Z`) or shorthand
|
||||||
|
/// `<N>d`/`<N>h`/`<N>m`.
|
||||||
|
#[arg(long)]
|
||||||
|
expires: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// List the caller's keys (no `raw_token` after mint).
|
||||||
|
Ls,
|
||||||
|
|
||||||
|
/// Revoke a key by id.
|
||||||
|
Rm { id: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct LogsArgs {
|
||||||
|
script_id: String,
|
||||||
|
#[arg(long, default_value_t = 50)]
|
||||||
|
limit: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main(flavor = "current_thread")]
|
||||||
|
async fn main() -> ExitCode {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let mode = cli.output;
|
||||||
|
let result = match cli.cmd {
|
||||||
|
Cmd::Login(args) => cmds::login::run(args.url.as_deref(), args.token.as_deref()).await,
|
||||||
|
Cmd::Logout => cmds::logout::run().await,
|
||||||
|
Cmd::Whoami => cmds::whoami::run(mode).await,
|
||||||
|
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await,
|
||||||
|
Cmd::Apps {
|
||||||
|
cmd:
|
||||||
|
AppsCmd::Create {
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
} => cmds::apps::create(&slug, name.as_deref(), description.as_deref()).await,
|
||||||
|
Cmd::Apps {
|
||||||
|
cmd: AppsCmd::Show { ident },
|
||||||
|
} => cmds::apps::show(&ident, mode).await,
|
||||||
|
Cmd::Apps {
|
||||||
|
cmd: AppsCmd::Delete { ident, force },
|
||||||
|
} => cmds::apps::delete(&ident, force).await,
|
||||||
|
Cmd::Scripts {
|
||||||
|
cmd: ScriptsCmd::Ls { app },
|
||||||
|
} => cmds::scripts::ls(app.as_deref(), mode).await,
|
||||||
|
Cmd::Scripts {
|
||||||
|
cmd: ScriptsCmd::Deploy(args),
|
||||||
|
} => {
|
||||||
|
cmds::scripts::deploy(
|
||||||
|
&args.file,
|
||||||
|
&args.app,
|
||||||
|
args.name.as_deref(),
|
||||||
|
args.description.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Cmd::Scripts {
|
||||||
|
cmd: ScriptsCmd::Invoke(args),
|
||||||
|
} => cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await,
|
||||||
|
Cmd::Scripts {
|
||||||
|
cmd: ScriptsCmd::Delete { id },
|
||||||
|
} => cmds::scripts::delete(&id).await,
|
||||||
|
Cmd::ApiKeys {
|
||||||
|
cmd:
|
||||||
|
ApiKeysCmd::Mint {
|
||||||
|
name,
|
||||||
|
scopes,
|
||||||
|
app,
|
||||||
|
expires,
|
||||||
|
},
|
||||||
|
} => cmds::api_keys::mint(&name, &scopes, app.as_deref(), expires.as_deref(), mode).await,
|
||||||
|
Cmd::ApiKeys {
|
||||||
|
cmd: ApiKeysCmd::Ls,
|
||||||
|
} => cmds::api_keys::ls(mode).await,
|
||||||
|
Cmd::ApiKeys {
|
||||||
|
cmd: ApiKeysCmd::Rm { id },
|
||||||
|
} => cmds::api_keys::rm(&id).await,
|
||||||
|
Cmd::Logs(LogsArgs { script_id, limit }) => cmds::logs::run(&script_id, limit, mode).await,
|
||||||
|
Cmd::Invoke(args) => {
|
||||||
|
cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await
|
||||||
|
}
|
||||||
|
Cmd::Deploy(args) => {
|
||||||
|
cmds::scripts::deploy(
|
||||||
|
&args.file,
|
||||||
|
&args.app,
|
||||||
|
args.name.as_deref(),
|
||||||
|
args.description.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(err) => {
|
||||||
|
output::print_error(&err);
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
252
crates/picloud-cli/src/output.rs
Normal file
252
crates/picloud-cli/src/output.rs
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
//! Output rendering for the CLI.
|
||||||
|
//!
|
||||||
|
//! Two formats:
|
||||||
|
//! * **TSV** (default): aligned columns separated by `\t`. Stays
|
||||||
|
//! pipe-friendly — `pic apps ls | awk -F'\t' '{print $1}'` works
|
||||||
|
//! without parsing box-drawing.
|
||||||
|
//! * **JSON**: array of `{column: value, …}` objects (for tables) or
|
||||||
|
//! a flat object (for single-row `show`/`whoami`). Designed to be
|
||||||
|
//! `jq`-friendly without escaping the table column names.
|
||||||
|
//!
|
||||||
|
//! Mode is set globally by the top-level `--output` flag and threaded
|
||||||
|
//! through every command. Single-row commands (`whoami`, `apps show`)
|
||||||
|
//! use `KvBlock`; everything plural uses `Table`.
|
||||||
|
|
||||||
|
use std::io::{self, Write};
|
||||||
|
|
||||||
|
use clap::ValueEnum;
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
|
||||||
|
#[clap(rename_all = "lowercase")]
|
||||||
|
pub enum OutputMode {
|
||||||
|
#[default]
|
||||||
|
Tsv,
|
||||||
|
Json,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Table — list views (`apps ls`, `scripts ls`, `logs`)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct Table {
|
||||||
|
headers: Vec<String>,
|
||||||
|
rows: Vec<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Table {
|
||||||
|
pub fn new<I, S>(headers: I) -> Self
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: Into<String>,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
headers: headers.into_iter().map(Into::into).collect(),
|
||||||
|
rows: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn row<I, S>(&mut self, cells: I) -> &mut Self
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: Into<String>,
|
||||||
|
{
|
||||||
|
self.rows.push(cells.into_iter().map(Into::into).collect());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_tsv(&self) -> String {
|
||||||
|
let mut widths: Vec<usize> = self.headers.iter().map(String::len).collect();
|
||||||
|
for row in &self.rows {
|
||||||
|
for (i, cell) in row.iter().enumerate() {
|
||||||
|
if i >= widths.len() {
|
||||||
|
widths.push(cell.len());
|
||||||
|
} else if cell.len() > widths[i] {
|
||||||
|
widths[i] = cell.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
write_row(&mut out, &self.headers, &widths);
|
||||||
|
for row in &self.rows {
|
||||||
|
write_row(&mut out, row, &widths);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON form: `[{header: cell, …}, …]`. Cells go in as strings even
|
||||||
|
/// when they happen to look like numbers — the CLI doesn't carry
|
||||||
|
/// type information all the way through (e.g., `version` is already
|
||||||
|
/// `to_string`'d at the call site). Consumers that need typed
|
||||||
|
/// numbers should parse `jq -r '.[].version|tonumber'`.
|
||||||
|
pub fn render_json(&self) -> String {
|
||||||
|
let arr: Vec<Value> = self
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
let mut obj = Map::new();
|
||||||
|
for (i, header) in self.headers.iter().enumerate() {
|
||||||
|
let cell = row.get(i).cloned().unwrap_or_default();
|
||||||
|
obj.insert(header.clone(), Value::String(cell));
|
||||||
|
}
|
||||||
|
Value::Object(obj)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
serde_json::to_string_pretty(&Value::Array(arr)).unwrap_or_else(|_| "[]".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print(&self, mode: OutputMode) {
|
||||||
|
let s = match mode {
|
||||||
|
OutputMode::Tsv => self.render_tsv(),
|
||||||
|
OutputMode::Json => {
|
||||||
|
let mut s = self.render_json();
|
||||||
|
s.push('\n');
|
||||||
|
s
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Best-effort write — broken pipe from `| head` etc. shouldn't
|
||||||
|
// surface as an error.
|
||||||
|
let _ = io::stdout().write_all(s.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_row(out: &mut String, row: &[String], widths: &[usize]) {
|
||||||
|
for (i, cell) in row.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
out.push('\t');
|
||||||
|
}
|
||||||
|
out.push_str(cell);
|
||||||
|
// Right-pad with spaces so tabs land on the column grid for
|
||||||
|
// human readers. Skip on the final column.
|
||||||
|
if i + 1 < row.len() {
|
||||||
|
let w = widths.get(i).copied().unwrap_or(cell.len());
|
||||||
|
for _ in cell.len()..w {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// KvBlock — single-row views (`whoami`, `apps show`)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// One row's worth of fields, rendered as aligned `key: value` lines in
|
||||||
|
/// TSV mode (one line per field — easier on the eye than a 1-row table)
|
||||||
|
/// or a flat JSON object.
|
||||||
|
pub struct KvBlock {
|
||||||
|
fields: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KvBlock {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { fields: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn field(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
|
||||||
|
self.fields.push((key.into(), value.into()));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_tsv(&self) -> String {
|
||||||
|
let key_width = self.fields.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
|
||||||
|
let mut out = String::new();
|
||||||
|
for (k, v) in &self.fields {
|
||||||
|
out.push_str(k);
|
||||||
|
for _ in k.len()..key_width {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
out.push('\t');
|
||||||
|
out.push_str(v);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_json(&self) -> String {
|
||||||
|
let mut obj = Map::new();
|
||||||
|
for (k, v) in &self.fields {
|
||||||
|
obj.insert(k.clone(), Value::String(v.clone()));
|
||||||
|
}
|
||||||
|
serde_json::to_string_pretty(&Value::Object(obj)).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print(&self, mode: OutputMode) {
|
||||||
|
let s = match mode {
|
||||||
|
OutputMode::Tsv => self.render_tsv(),
|
||||||
|
OutputMode::Json => {
|
||||||
|
let mut s = self.render_json();
|
||||||
|
s.push('\n');
|
||||||
|
s
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = io::stdout().write_all(s.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn print_error(err: &anyhow::Error) {
|
||||||
|
let mut stderr = io::stderr();
|
||||||
|
let _ = writeln!(stderr, "error: {err:#}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_aligns_columns_tsv() {
|
||||||
|
let mut t = Table::new(["slug", "name"]);
|
||||||
|
t.row(["a", "Alpha"]).row(["bravo", "B"]);
|
||||||
|
let out = t.render_tsv();
|
||||||
|
assert_eq!(out, "slug \tname\na \tAlpha\nbravo\tB\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_empty_rows_tsv() {
|
||||||
|
let t = Table::new(["a", "b"]);
|
||||||
|
assert_eq!(t.render_tsv(), "a\tb\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_render_json_is_array_of_objects() {
|
||||||
|
let mut t = Table::new(["slug", "name"]);
|
||||||
|
t.row(["a", "Alpha"]).row(["bravo", "B"]);
|
||||||
|
let raw = t.render_json();
|
||||||
|
let v: Value = serde_json::from_str(&raw).expect("valid JSON");
|
||||||
|
let arr = v.as_array().expect("array");
|
||||||
|
assert_eq!(arr.len(), 2);
|
||||||
|
assert_eq!(arr[0]["slug"], "a");
|
||||||
|
assert_eq!(arr[0]["name"], "Alpha");
|
||||||
|
assert_eq!(arr[1]["slug"], "bravo");
|
||||||
|
assert_eq!(arr[1]["name"], "B");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kv_block_tsv_aligns_keys() {
|
||||||
|
let mut b = KvBlock::new();
|
||||||
|
b.field("username", "admin").field("role", "owner");
|
||||||
|
let out = b.render_tsv();
|
||||||
|
// username (8 chars) defines the key width.
|
||||||
|
assert_eq!(out, "username\tadmin\nrole \towner\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kv_block_json_is_flat_object() {
|
||||||
|
let mut b = KvBlock::new();
|
||||||
|
b.field("username", "admin").field("role", "owner");
|
||||||
|
let raw = b.render_json();
|
||||||
|
let v: Value = serde_json::from_str(&raw).expect("valid JSON");
|
||||||
|
assert_eq!(v["username"], "admin");
|
||||||
|
assert_eq!(v["role"], "owner");
|
||||||
|
}
|
||||||
|
}
|
||||||
170
crates/picloud-cli/tests/api_keys.rs
Normal file
170
crates/picloud-cli/tests/api_keys.rs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
//! `pic api-keys` — mint / ls / rm journeys.
|
||||||
|
//!
|
||||||
|
//! Server semantics asserted here:
|
||||||
|
//! * `mint` emits the `raw_token` *exactly once* and never on `ls`.
|
||||||
|
//! * A minted key is a valid bearer for `/auth/me`.
|
||||||
|
//! * After `rm`, the same token is rejected (401).
|
||||||
|
|
||||||
|
use predicates::prelude::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn mint_prints_raw_token_once_and_ls_omits_it() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let name = format!("pic-cli-mint-{}", common::unique_slug("k"));
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"--output",
|
||||||
|
"json",
|
||||||
|
"api-keys",
|
||||||
|
"mint",
|
||||||
|
&name,
|
||||||
|
"--scope",
|
||||||
|
"script:read",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.expect("api-keys mint");
|
||||||
|
assert!(out.status.success(), "mint failed: {out:?}");
|
||||||
|
let body: Value = serde_json::from_slice(&out.stdout).expect("JSON");
|
||||||
|
let token = body["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("mint should expose `token`")
|
||||||
|
.to_string();
|
||||||
|
let key_id = body["id"]
|
||||||
|
.as_str()
|
||||||
|
.expect("mint should expose `id`")
|
||||||
|
.to_string();
|
||||||
|
assert!(
|
||||||
|
token.starts_with("pic_"),
|
||||||
|
"tokens are pic_-prefixed: {token}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// `ls` must NEVER carry the raw token. The key row should appear,
|
||||||
|
// identified by name, but `token` is mint-only.
|
||||||
|
let ls = common::pic_as(&env)
|
||||||
|
.args(["--output", "json", "api-keys", "ls"])
|
||||||
|
.output()
|
||||||
|
.expect("api-keys ls");
|
||||||
|
assert!(ls.status.success(), "ls failed: {ls:?}");
|
||||||
|
let ls_body: Value = serde_json::from_slice(&ls.stdout).expect("JSON");
|
||||||
|
let arr = ls_body.as_array().expect("array");
|
||||||
|
let row = arr
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.get("id").and_then(Value::as_str) == Some(key_id.as_str()))
|
||||||
|
.expect("our key in ls");
|
||||||
|
assert!(
|
||||||
|
row.get("token").is_none(),
|
||||||
|
"ls must not expose raw_token: {row}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup so we don't leak keys across runs.
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["api-keys", "rm", &key_id])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn minted_key_works_as_bearer() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let name = format!("pic-cli-bearer-{}", common::unique_slug("k"));
|
||||||
|
|
||||||
|
let mint = common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"--output",
|
||||||
|
"json",
|
||||||
|
"api-keys",
|
||||||
|
"mint",
|
||||||
|
&name,
|
||||||
|
"--scope",
|
||||||
|
"script:read",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.expect("mint");
|
||||||
|
assert!(mint.status.success());
|
||||||
|
let body: Value = serde_json::from_slice(&mint.stdout).unwrap();
|
||||||
|
let token = body["token"].as_str().unwrap().to_string();
|
||||||
|
let id = body["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// Drive whoami with the minted token — proves the bearer string we
|
||||||
|
// captured really is what the server stamped.
|
||||||
|
let key_env = common::custom_env(&fx.url, &token);
|
||||||
|
common::seed_credentials(&key_env, &fx.admin_username);
|
||||||
|
common::pic_as(&key_env)
|
||||||
|
.args(["whoami"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(fx.admin_username.as_str()));
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["api-keys", "rm", &id])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After `rm`, the bearer token is dead server-side: a follow-up
|
||||||
|
/// `whoami` driven by it must 401, not 500.
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn rm_revokes_the_token() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let name = format!("pic-cli-rm-{}", common::unique_slug("k"));
|
||||||
|
|
||||||
|
let mint = common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"--output",
|
||||||
|
"json",
|
||||||
|
"api-keys",
|
||||||
|
"mint",
|
||||||
|
&name,
|
||||||
|
"--scope",
|
||||||
|
"script:read",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.expect("mint");
|
||||||
|
let body: Value = serde_json::from_slice(&mint.stdout).unwrap();
|
||||||
|
let token = body["token"].as_str().unwrap().to_string();
|
||||||
|
let id = body["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["api-keys", "rm", &id])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(format!("Revoked api-key {id}")));
|
||||||
|
|
||||||
|
let dead = common::custom_env(&fx.url, &token);
|
||||||
|
common::pic_as(&dead)
|
||||||
|
.args(["whoami"])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("HTTP 401"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn mint_with_unknown_scope_is_rejected_client_side() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["api-keys", "mint", "doomed", "--scope", "script:nope"])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("unknown scope"));
|
||||||
|
}
|
||||||
268
crates/picloud-cli/tests/apps.rs
Normal file
268
crates/picloud-cli/tests/apps.rs
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
//! `pic apps create` / `pic apps ls` edge cases. The integration smoke
|
||||||
|
//! test covers the happy path; this module covers conflict, validation,
|
||||||
|
//! and the persistence of the optional `--name` / `--description` flags
|
||||||
|
//! (which `apps ls` doesn't surface).
|
||||||
|
|
||||||
|
use predicates::prelude::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
use crate::common::cleanup::AppGuard;
|
||||||
|
use crate::common::member;
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn create_with_name_and_description_persists() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("apps-named");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"apps",
|
||||||
|
"create",
|
||||||
|
&slug,
|
||||||
|
"--name",
|
||||||
|
"Pretty Name",
|
||||||
|
"--description",
|
||||||
|
"test description",
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
// `apps ls` only shows slug+name+role+created_at, so verify the
|
||||||
|
// persisted shape via the admin GET endpoint.
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.get(format!("{}/api/v1/admin/apps/{}", env.url, slug))
|
||||||
|
.bearer_auth(&env.token)
|
||||||
|
.send()
|
||||||
|
.expect("GET app");
|
||||||
|
assert!(resp.status().is_success(), "GET app failed: {resp:?}");
|
||||||
|
let body: Value = resp.json().expect("app json");
|
||||||
|
assert_eq!(body["slug"].as_str(), Some(slug.as_str()));
|
||||||
|
assert_eq!(body["name"].as_str(), Some("Pretty Name"));
|
||||||
|
assert_eq!(body["description"].as_str(), Some("test description"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn create_duplicate_slug_conflicts() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("apps-dup");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("409").or(predicate::str::contains("conflict")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn create_invalid_slug_rejected() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
|
||||||
|
// Server slug regex is `^[a-z0-9][a-z0-9-]{0,62}$` — uppercase
|
||||||
|
// breaks the rule on the very first char. The server returns 422
|
||||||
|
// (`InvalidSlug` → `UNPROCESSABLE_ENTITY`), not 400 — the previous
|
||||||
|
// `"HTTP 4"` predicate would have silently matched any other 4xx
|
||||||
|
// (a regressed 401 from broken auth, for example).
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", "NotALowerSlug"])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("HTTP 422"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn ls_includes_created_app_with_expected_columns() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("apps-ls");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["apps", "ls"])
|
||||||
|
.output()
|
||||||
|
.expect("apps ls");
|
||||||
|
assert!(out.status.success(), "apps ls failed: {out:?}");
|
||||||
|
let stdout = String::from_utf8(out.stdout).expect("utf8 stdout");
|
||||||
|
let mut lines = stdout.lines();
|
||||||
|
let header = lines.next().expect("header row");
|
||||||
|
assert_eq!(
|
||||||
|
common::cells(header),
|
||||||
|
vec!["slug", "name", "my_role", "created_at"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// The slug must appear in some data row and its row's my_role column
|
||||||
|
// is dashed (the ls endpoint doesn't compute it per-app).
|
||||||
|
let row = lines
|
||||||
|
.map(common::cells)
|
||||||
|
.find(|c| c.first().copied() == Some(slug.as_str()))
|
||||||
|
.unwrap_or_else(|| panic!("slug {slug} not in apps ls output: {stdout}"));
|
||||||
|
assert_eq!(row.len(), 4, "row should have 4 cells: {row:?}");
|
||||||
|
assert_eq!(row[2], "-", "my_role column should be dashed: {row:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn delete_removes_app_from_ls() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("apps-del");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "delete", &slug])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(format!("Deleted app {slug}")));
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["apps", "ls"])
|
||||||
|
.output()
|
||||||
|
.expect("apps ls");
|
||||||
|
assert!(out.status.success());
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
!stdout.lines().any(|l| l.starts_with(&slug)),
|
||||||
|
"deleted slug should not appear in ls: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn delete_with_scripts_errors_without_force() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("apps-del-busy");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
// AppGuard is the safety net: if the no-force delete fails (as
|
||||||
|
// expected) the app stays around; AppGuard force-deletes on drop.
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
let fixture = common::fixture_path("hello.rhai");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "delete", &slug])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
// Server `HasScripts` → 409 with a "scripts present" message.
|
||||||
|
.stderr(predicate::str::contains("HTTP 409"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn delete_with_scripts_succeeds_with_force() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("apps-del-force");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let fixture = common::fixture_path("hello.rhai");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "delete", &slug, "--force"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(format!("Deleted app {slug}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn show_prints_my_role_for_member() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let admin_env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("apps-show");
|
||||||
|
common::pic_as(&admin_env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _g = AppGuard::new(&admin_env.url, &admin_env.token, &slug);
|
||||||
|
|
||||||
|
let m = member::member_user(fx, &common::unique_username("show"));
|
||||||
|
member::grant_membership(fx, &slug, &m.id, "viewer");
|
||||||
|
|
||||||
|
let member_env = common::custom_env(&fx.url, &m.token);
|
||||||
|
common::seed_credentials(&member_env, &m.username);
|
||||||
|
|
||||||
|
let out = common::pic_as(&member_env)
|
||||||
|
.args(["apps", "show", &slug])
|
||||||
|
.output()
|
||||||
|
.expect("apps show");
|
||||||
|
assert!(out.status.success(), "apps show failed: {out:?}");
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
// KvBlock output: `my_role` row carries the wire form (`viewer`).
|
||||||
|
assert!(
|
||||||
|
stdout
|
||||||
|
.lines()
|
||||||
|
.any(|l| l.starts_with("my_role") && l.trim_end().ends_with("viewer")),
|
||||||
|
"show should surface my_role=viewer, got: {stdout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stdout.lines().any(|l| l.starts_with("slug")),
|
||||||
|
"show should include slug row: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
288
crates/picloud-cli/tests/auth.rs
Normal file
288
crates/picloud-cli/tests/auth.rs
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
//! Login + whoami journeys beyond the happy path: bad tokens, missing
|
||||||
|
//! credentials file, stale on-disk creds, and the role-label rendered
|
||||||
|
//! by `pic login`.
|
||||||
|
|
||||||
|
use predicates::prelude::*;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn login_persists_credentials_with_correct_perms() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
|
||||||
|
common::pic_as(&env).args(["login"]).assert().success();
|
||||||
|
|
||||||
|
let creds_path = env.config_dir.path().join("credentials");
|
||||||
|
let body = std::fs::read_to_string(&creds_path).expect("credentials file");
|
||||||
|
assert!(
|
||||||
|
body.contains(&format!("url = \"{}\"", env.url)),
|
||||||
|
"creds missing url line: {body}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains(&format!("token = \"{}\"", env.token)),
|
||||||
|
"creds missing token line: {body}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains(&format!("username = \"{}\"", fx.admin_username)),
|
||||||
|
"creds missing username line: {body}",
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mode = std::fs::metadata(&creds_path).unwrap().permissions().mode() & 0o777;
|
||||||
|
assert_eq!(mode, 0o600, "credentials file must be 0600, got {mode:o}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn login_rejects_bad_token() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::custom_env(&fx.url, "pic_garbage_token");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["login"])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("401").or(predicate::str::contains("token rejected")));
|
||||||
|
|
||||||
|
let creds_path = env.config_dir.path().join("credentials");
|
||||||
|
assert!(
|
||||||
|
!creds_path.exists(),
|
||||||
|
"failed login must not persist credentials"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn whoami_without_credentials_errors() {
|
||||||
|
let Some(_fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Build a TestEnv directly so the config dir stays empty —
|
||||||
|
// `admin_env` would seed a credentials file, masking the bug
|
||||||
|
// this test is supposed to catch.
|
||||||
|
let env = common::TestEnv {
|
||||||
|
url: String::new(),
|
||||||
|
token: String::new(),
|
||||||
|
config_dir: tempfile::TempDir::new().unwrap(),
|
||||||
|
home: tempfile::TempDir::new().unwrap(),
|
||||||
|
};
|
||||||
|
|
||||||
|
common::pic_no_env(&env)
|
||||||
|
.args(["whoami"])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("pic login"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn whoami_with_stale_token_errors() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
|
||||||
|
let body = format!(
|
||||||
|
"url = \"{}\"\ntoken = \"pic_stale_token\"\nusername = \"ghost\"\n",
|
||||||
|
env.url
|
||||||
|
);
|
||||||
|
std::fs::write(env.config_dir.path().join("credentials"), body).unwrap();
|
||||||
|
|
||||||
|
common::pic_no_env(&env)
|
||||||
|
.args(["whoami"])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("401").or(predicate::str::contains("token rejected")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn login_prints_member_role_label() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let username = common::unique_username("auth");
|
||||||
|
let m = common::member::member_user(fx, &username);
|
||||||
|
let env = common::custom_env(&fx.url, &m.token);
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["login"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(format!(
|
||||||
|
"Logged in as {} (member)",
|
||||||
|
m.username
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive the real username+password flow end-to-end. `pic_no_env`
|
||||||
|
/// strips `PICLOUD_TOKEN` so login can't short-circuit through the
|
||||||
|
/// bearer path; stdin feeds `username\npassword\n` (the URL is supplied
|
||||||
|
/// via `--url` to avoid the third prompt).
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn login_with_username_and_password_persists() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let username = common::unique_username("lpw");
|
||||||
|
let m = common::member::member_user(fx, &username);
|
||||||
|
let env = common::custom_env(&fx.url, ""); // empty token — file gets written by login
|
||||||
|
|
||||||
|
let stdin_payload = format!("{}\n{}\n", m.username, common::member::MEMBER_PASSWORD);
|
||||||
|
common::pic_no_env(&env)
|
||||||
|
.args(["login", "--url", &fx.url])
|
||||||
|
.write_stdin(stdin_payload)
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(format!(
|
||||||
|
"Logged in as {} (member)",
|
||||||
|
m.username
|
||||||
|
)));
|
||||||
|
|
||||||
|
let creds_path = env.config_dir.path().join("credentials");
|
||||||
|
let body = std::fs::read_to_string(&creds_path).expect("credentials file");
|
||||||
|
assert!(
|
||||||
|
body.contains(&format!("username = \"{}\"", m.username)),
|
||||||
|
"creds should carry the canonical username: {body}",
|
||||||
|
);
|
||||||
|
// The token persisted must be a real session token, not whatever
|
||||||
|
// the user typed — a regression where we accidentally saved the
|
||||||
|
// password as the token would fail this check.
|
||||||
|
assert!(
|
||||||
|
!body.contains(common::member::MEMBER_PASSWORD),
|
||||||
|
"password leaked into credentials file: {body}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn login_with_wrong_password_errors() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let username = common::unique_username("lpwbad");
|
||||||
|
let m = common::member::member_user(fx, &username);
|
||||||
|
let env = common::custom_env(&fx.url, "");
|
||||||
|
|
||||||
|
let stdin_payload = format!("{}\nwrong-password\n", m.username);
|
||||||
|
common::pic_no_env(&env)
|
||||||
|
.args(["login", "--url", &fx.url])
|
||||||
|
.write_stdin(stdin_payload)
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("HTTP 401"));
|
||||||
|
|
||||||
|
let creds_path = env.config_dir.path().join("credentials");
|
||||||
|
assert!(
|
||||||
|
!creds_path.exists(),
|
||||||
|
"failed login must not persist credentials"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logout_clears_local_credentials() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Use a member's token so we don't yank the admin session out from
|
||||||
|
// under parallel tests. The local-file cleanup is the same.
|
||||||
|
let username = common::unique_username("lout");
|
||||||
|
let m = common::member::member_user(fx, &username);
|
||||||
|
let env = common::custom_env(&fx.url, &m.token);
|
||||||
|
common::seed_credentials(&env, &m.username);
|
||||||
|
|
||||||
|
let creds_path = env.config_dir.path().join("credentials");
|
||||||
|
assert!(creds_path.exists(), "precondition: creds file seeded");
|
||||||
|
|
||||||
|
common::pic_no_env(&env)
|
||||||
|
.args(["logout"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("Logged out"));
|
||||||
|
assert!(
|
||||||
|
!creds_path.exists(),
|
||||||
|
"credentials file should be removed after logout"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pic logout` is meant to be idempotent: running it with no
|
||||||
|
/// credentials file present is not an error.
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logout_is_idempotent_when_already_logged_out() {
|
||||||
|
let Some(_fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::TestEnv {
|
||||||
|
url: String::new(),
|
||||||
|
token: String::new(),
|
||||||
|
config_dir: tempfile::TempDir::new().unwrap(),
|
||||||
|
home: tempfile::TempDir::new().unwrap(),
|
||||||
|
};
|
||||||
|
common::pic_no_env(&env)
|
||||||
|
.args(["logout"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("Logged out"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-side session invalidation: after `pic logout`, a subsequent
|
||||||
|
/// `pic whoami` driven by the same (now-stale) token must 401.
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logout_invalidates_server_session() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let username = common::unique_username("lout2");
|
||||||
|
let m = common::member::member_user(fx, &username);
|
||||||
|
let env = common::custom_env(&fx.url, &m.token);
|
||||||
|
common::seed_credentials(&env, &m.username);
|
||||||
|
|
||||||
|
common::pic_no_env(&env).args(["logout"]).assert().success();
|
||||||
|
|
||||||
|
// Replay the member's old token explicitly — pic_no_env reads the
|
||||||
|
// (now-deleted) file, so we go back to env-driven mode with the
|
||||||
|
// stale bearer.
|
||||||
|
let stale = common::custom_env(&fx.url, &m.token);
|
||||||
|
common::pic_as(&stale)
|
||||||
|
.args(["whoami"])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("HTTP 401"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Env vars must override the on-disk credentials file globally. Write
|
||||||
|
/// garbage into the file, set env to the real admin creds, and prove
|
||||||
|
/// every read-side command (here `whoami`) goes via env.
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn env_vars_override_credentials_file() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::custom_env(&fx.url, &fx.admin_token);
|
||||||
|
// Garbage in the file: would 401 if used.
|
||||||
|
let body = format!(
|
||||||
|
"url = \"{}\"\ntoken = \"pic_stale_garbage_token\"\nusername = \"ghost\"\n",
|
||||||
|
env.url
|
||||||
|
);
|
||||||
|
std::fs::write(env.config_dir.path().join("credentials"), body).unwrap();
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["whoami"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(fx.admin_username.as_str()));
|
||||||
|
}
|
||||||
23
crates/picloud-cli/tests/cli.rs
Normal file
23
crates/picloud-cli/tests/cli.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
//! Integration-test binary for the `pic` CLI.
|
||||||
|
//!
|
||||||
|
//! Every `#[test]` in this binary routes through `common::fixture()`, a
|
||||||
|
//! `LazyLock` that spawns picloud once on a private port and reuses it
|
||||||
|
//! across all journey modules. Mirrors the dashboard Playwright suite,
|
||||||
|
//! which spins backend + Vite up once for 63 specs.
|
||||||
|
//!
|
||||||
|
//! Gated on `DATABASE_URL`. To run:
|
||||||
|
//!
|
||||||
|
//! docker compose up -d postgres
|
||||||
|
//! DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \
|
||||||
|
//! cargo test -p picloud-cli --test cli -- --include-ignored
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
mod api_keys;
|
||||||
|
mod apps;
|
||||||
|
mod auth;
|
||||||
|
mod invoke;
|
||||||
|
mod logs;
|
||||||
|
mod output;
|
||||||
|
mod roles;
|
||||||
|
mod scripts;
|
||||||
61
crates/picloud-cli/tests/common/cleanup.rs
Normal file
61
crates/picloud-cli/tests/common/cleanup.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
//! RAII guards that delete server-side resources on `Drop`.
|
||||||
|
//!
|
||||||
|
//! Each guard owns the minimum it needs to issue a single DELETE: the
|
||||||
|
//! base URL, an admin bearer token, and the resource identifier.
|
||||||
|
//! Failures are swallowed because Drop runs during teardown — a panic
|
||||||
|
//! here would just mask the real failure that the test was reporting.
|
||||||
|
|
||||||
|
pub struct AppGuard {
|
||||||
|
url: String,
|
||||||
|
token: String,
|
||||||
|
slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppGuard {
|
||||||
|
pub fn new(url: &str, token: &str, slug: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
url: url.to_string(),
|
||||||
|
token: token.to_string(),
|
||||||
|
slug: slug.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for AppGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let _ = client
|
||||||
|
.delete(format!(
|
||||||
|
"{}/api/v1/admin/apps/{}?force=true",
|
||||||
|
self.url, self.slug
|
||||||
|
))
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UserGuard {
|
||||||
|
url: String,
|
||||||
|
token: String,
|
||||||
|
user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserGuard {
|
||||||
|
pub fn new(url: &str, token: &str, user_id: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
url: url.to_string(),
|
||||||
|
token: token.to_string(),
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for UserGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let _ = client
|
||||||
|
.delete(format!("{}/api/v1/admin/admins/{}", self.url, self.user_id))
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.send();
|
||||||
|
}
|
||||||
|
}
|
||||||
99
crates/picloud-cli/tests/common/member.rs
Normal file
99
crates/picloud-cli/tests/common/member.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
//! Helpers for non-admin (`instance_role: Member`) user lifecycle plus
|
||||||
|
//! direct API calls for granting / updating app memberships.
|
||||||
|
//!
|
||||||
|
//! These talk to the manager HTTP surface directly instead of going
|
||||||
|
//! through the CLI, so role-gated tests can stage state without
|
||||||
|
//! requiring `pic` to grow new commands.
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use super::cleanup::UserGuard;
|
||||||
|
use super::Fixture;
|
||||||
|
|
||||||
|
pub const MEMBER_PASSWORD: &str = "pic-cli-test-pw-12345678";
|
||||||
|
|
||||||
|
pub struct MemberUser {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub token: String,
|
||||||
|
pub _guard: UserGuard,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a fresh `instance_role: Member` user, log them in for a bearer
|
||||||
|
/// token, and register a `UserGuard` for teardown.
|
||||||
|
pub fn member_user(fx: &Fixture, username: &str) -> MemberUser {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
|
||||||
|
let create = client
|
||||||
|
.post(format!("{}/api/v1/admin/admins", fx.url))
|
||||||
|
.bearer_auth(&fx.admin_token)
|
||||||
|
.json(&json!({
|
||||||
|
"username": username,
|
||||||
|
"password": MEMBER_PASSWORD,
|
||||||
|
// InstanceRole / AppRole serialize via `rename_all =
|
||||||
|
// "snake_case"` — wire forms are always lowercase.
|
||||||
|
"instance_role": "member",
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.expect("create member user");
|
||||||
|
assert!(
|
||||||
|
create.status().is_success(),
|
||||||
|
"create member user failed: {} {}",
|
||||||
|
create.status(),
|
||||||
|
create.text().unwrap_or_default(),
|
||||||
|
);
|
||||||
|
let body: Value = create.json().expect("admin create json");
|
||||||
|
let id = body["id"]
|
||||||
|
.as_str()
|
||||||
|
.expect("admin create returns id")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Register cleanup before we attempt anything else that could fail.
|
||||||
|
let guard = UserGuard::new(&fx.url, &fx.admin_token, &id);
|
||||||
|
|
||||||
|
let token = super::server::login_for_bearer_token(&fx.url, username, MEMBER_PASSWORD);
|
||||||
|
|
||||||
|
MemberUser {
|
||||||
|
id,
|
||||||
|
username: username.to_string(),
|
||||||
|
token,
|
||||||
|
_guard: guard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/admin/apps/{slug}/members` — grant `role` to `user_id`.
|
||||||
|
pub fn grant_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{}/api/v1/admin/apps/{}/members", fx.url, app_slug))
|
||||||
|
.bearer_auth(&fx.admin_token)
|
||||||
|
.json(&json!({ "user_id": user_id, "role": role }))
|
||||||
|
.send()
|
||||||
|
.expect("grant membership");
|
||||||
|
assert!(
|
||||||
|
resp.status().is_success(),
|
||||||
|
"grant membership failed: {} {}",
|
||||||
|
resp.status(),
|
||||||
|
resp.text().unwrap_or_default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PATCH /api/v1/admin/apps/{slug}/members/{user_id}` — promote/demote.
|
||||||
|
pub fn update_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.patch(format!(
|
||||||
|
"{}/api/v1/admin/apps/{}/members/{}",
|
||||||
|
fx.url, app_slug, user_id
|
||||||
|
))
|
||||||
|
.bearer_auth(&fx.admin_token)
|
||||||
|
.json(&json!({ "role": role }))
|
||||||
|
.send()
|
||||||
|
.expect("update membership");
|
||||||
|
assert!(
|
||||||
|
resp.status().is_success(),
|
||||||
|
"update membership failed: {} {}",
|
||||||
|
resp.status(),
|
||||||
|
resp.text().unwrap_or_default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
314
crates/picloud-cli/tests/common/mod.rs
Normal file
314
crates/picloud-cli/tests/common/mod.rs
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
//! Shared fixture and helpers for the CLI integration test binary.
|
||||||
|
//!
|
||||||
|
//! All tests in `tests/cli.rs` route through `fixture()`, a `LazyLock`
|
||||||
|
//! that spawns picloud on a private port the first time it's touched
|
||||||
|
//! and reuses that subprocess for every subsequent test. The dashboard
|
||||||
|
//! Playwright suite pays the same cost once for 63 tests; we do the
|
||||||
|
//! same here.
|
||||||
|
|
||||||
|
#![allow(dead_code)] // shared helpers — not every module uses every fn.
|
||||||
|
|
||||||
|
pub mod cleanup;
|
||||||
|
pub mod member;
|
||||||
|
pub mod server;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Child;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{LazyLock, Mutex, OnceLock};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use assert_cmd::Command as AssertCommand;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// Fixture
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct Fixture {
|
||||||
|
pub url: String,
|
||||||
|
pub admin_token: String,
|
||||||
|
pub admin_username: String,
|
||||||
|
// Held in a Mutex so Drop can kill it without UB; we never re-enter.
|
||||||
|
child: Mutex<Option<Child>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Ok(mut guard) = self.child.lock() {
|
||||||
|
if let Some(mut child) = guard.take() {
|
||||||
|
server::kill_subprocess(&mut child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static FIXTURE: LazyLock<Fixture> = LazyLock::new(init_fixture);
|
||||||
|
|
||||||
|
fn init_fixture() -> Fixture {
|
||||||
|
let database_url =
|
||||||
|
std::env::var("DATABASE_URL").expect("DATABASE_URL is required to spawn picloud");
|
||||||
|
let username = admin_username();
|
||||||
|
let password = admin_password();
|
||||||
|
let port = server::pick_free_port();
|
||||||
|
let url = format!("http://127.0.0.1:{port}");
|
||||||
|
let mut child = server::spawn_picloud(&database_url, port, &username, &password);
|
||||||
|
if let Err(e) = server::wait_for_health(&url, Duration::from_secs(60)) {
|
||||||
|
server::kill_subprocess(&mut child);
|
||||||
|
panic!("picloud failed to become healthy: {e}");
|
||||||
|
}
|
||||||
|
let token = server::login_for_bearer_token(&url, &username, &password);
|
||||||
|
Fixture {
|
||||||
|
url,
|
||||||
|
admin_token: token,
|
||||||
|
admin_username: username,
|
||||||
|
child: Mutex::new(Some(child)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the shared fixture, spawning the picloud subprocess on first
|
||||||
|
/// call. Returns `None` (and prints a skip message) when `DATABASE_URL`
|
||||||
|
/// is absent — matching the existing convention so the suite is a
|
||||||
|
/// no-op outside the integration environment.
|
||||||
|
pub fn fixture_or_skip() -> Option<&'static Fixture> {
|
||||||
|
if std::env::var("DATABASE_URL").is_err() {
|
||||||
|
eprintln!("skipping: DATABASE_URL not set");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(&FIXTURE)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// Per-test env
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct TestEnv {
|
||||||
|
pub url: String,
|
||||||
|
pub token: String,
|
||||||
|
pub config_dir: TempDir,
|
||||||
|
pub home: TempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-test env pre-loaded with the admin token, and a credentials
|
||||||
|
/// file already on disk so non-login commands ("pic apps create", …)
|
||||||
|
/// can run without first calling `pic login`. As of the env-var
|
||||||
|
/// consistency fix, `PICLOUD_URL`/`PICLOUD_TOKEN` (set by `pic_as`)
|
||||||
|
/// also work for *every* command, not just `login` — `config::resolve`
|
||||||
|
/// reads them first and falls back to the on-disk file.
|
||||||
|
pub fn admin_env(fx: &Fixture) -> TestEnv {
|
||||||
|
let env = TestEnv {
|
||||||
|
url: fx.url.clone(),
|
||||||
|
token: fx.admin_token.clone(),
|
||||||
|
config_dir: TempDir::new().expect("config tempdir"),
|
||||||
|
home: TempDir::new().expect("home tempdir"),
|
||||||
|
};
|
||||||
|
seed_credentials(&env, &fx.admin_username);
|
||||||
|
env
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-test env pre-loaded with a specific (URL, token) pair. Used by
|
||||||
|
/// tests that want a non-admin token, a bogus token, or an unreachable
|
||||||
|
/// URL. Does **not** seed a credentials file — call `seed_credentials`
|
||||||
|
/// explicitly when the test needs to run non-login commands.
|
||||||
|
pub fn custom_env(url: &str, token: &str) -> TestEnv {
|
||||||
|
TestEnv {
|
||||||
|
url: url.to_string(),
|
||||||
|
token: token.to_string(),
|
||||||
|
config_dir: TempDir::new().expect("config tempdir"),
|
||||||
|
home: TempDir::new().expect("home tempdir"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a valid credentials TOML into `env.config_dir` so subsequent
|
||||||
|
/// `pic_as(&env)` invocations can issue non-login subcommands. Mirrors
|
||||||
|
/// the file shape `pic login` produces (url/token/username). Tests that
|
||||||
|
/// exercise the "no credentials" / "stale token" error paths construct
|
||||||
|
/// `TestEnv` directly to keep the config dir empty.
|
||||||
|
pub fn seed_credentials(env: &TestEnv, username: &str) {
|
||||||
|
let body = format!(
|
||||||
|
"url = \"{}\"\ntoken = \"{}\"\nusername = \"{}\"\n",
|
||||||
|
env.url, env.token, username,
|
||||||
|
);
|
||||||
|
std::fs::write(env.config_dir.path().join("credentials"), body).expect("seed credentials file");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pic` invocation with the env wired up — credentials dir, HOME, and
|
||||||
|
/// the `PICLOUD_URL`/`PICLOUD_TOKEN` shortcut env vars.
|
||||||
|
pub fn pic_as(env: &TestEnv) -> AssertCommand {
|
||||||
|
let mut cmd = AssertCommand::cargo_bin("pic").expect("pic binary");
|
||||||
|
cmd.env("PICLOUD_URL", &env.url)
|
||||||
|
.env("PICLOUD_TOKEN", &env.token)
|
||||||
|
.env("PICLOUD_CONFIG_DIR", env.config_dir.path())
|
||||||
|
.env("HOME", env.home.path());
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pic` invocation with `PICLOUD_URL`/`PICLOUD_TOKEN` *cleared*, so the
|
||||||
|
/// command sees only the on-disk credentials file (or lack thereof).
|
||||||
|
pub fn pic_no_env(env: &TestEnv) -> AssertCommand {
|
||||||
|
let mut cmd = AssertCommand::cargo_bin("pic").expect("pic binary");
|
||||||
|
cmd.env_remove("PICLOUD_URL")
|
||||||
|
.env_remove("PICLOUD_TOKEN")
|
||||||
|
.env("PICLOUD_CONFIG_DIR", env.config_dir.path())
|
||||||
|
.env("HOME", env.home.path());
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// Unique slugs / usernames
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
|
||||||
|
static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
pub fn unique_slug(prefix: &str) -> String {
|
||||||
|
let ms = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_millis();
|
||||||
|
let n = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
format!("pic-cli-{prefix}-{ms}-{n:x}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unique_username(prefix: &str) -> String {
|
||||||
|
// Server regex: [a-z0-9._-]{2,32}. Build out of lowercase
|
||||||
|
// alphanumerics only; "piccli" prefix keeps collisions with other
|
||||||
|
// test suites obvious. Caller's `prefix` must be ≤8 chars and
|
||||||
|
// already match the regex.
|
||||||
|
let ms = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_millis();
|
||||||
|
let n = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let name = format!("piccli{prefix}{ms:x}{n:x}");
|
||||||
|
assert!(name.len() <= 32, "username overflow: {name}");
|
||||||
|
name
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// Misc helpers
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn admin_username() -> String {
|
||||||
|
std::env::var("PICLOUD_CLI_E2E_USERNAME").unwrap_or_else(|_| "admin".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn admin_password() -> String {
|
||||||
|
std::env::var("PICLOUD_CLI_E2E_PASSWORD").unwrap_or_else(|_| "admin".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fixture_path(name: &str) -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("tests")
|
||||||
|
.join("fixtures")
|
||||||
|
.join(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a fresh app and deploy a `tests/fixtures/<fixture_name>` into
|
||||||
|
/// it. Returns the new script id plus the `AppGuard` that cleans the
|
||||||
|
/// app (and its scripts via `force=true`) on Drop. Used by invoke /
|
||||||
|
/// logs / output journeys that all need "deploy something, then drive
|
||||||
|
/// `pic` against it".
|
||||||
|
pub fn deploy_fixture(
|
||||||
|
env: &TestEnv,
|
||||||
|
app_label: &str,
|
||||||
|
fixture_name: &str,
|
||||||
|
) -> (String, cleanup::AppGuard) {
|
||||||
|
let slug = unique_slug(app_label);
|
||||||
|
pic_as(env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let guard = cleanup::AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
let fixture = fixture_path(fixture_name);
|
||||||
|
pic_as(env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let out = pic_as(env)
|
||||||
|
.args(["scripts", "ls", "--app", &slug])
|
||||||
|
.output()
|
||||||
|
.expect("scripts ls");
|
||||||
|
let id = parse_first_id(std::str::from_utf8(&out.stdout).unwrap())
|
||||||
|
.expect("scripts ls should produce one row");
|
||||||
|
(id, guard)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a row from `pic apps ls` / `pic scripts ls` into trimmed
|
||||||
|
/// cells. The output writer space-pads each cell to its column's max
|
||||||
|
/// width before the tab, so raw `split('\t')` leaves trailing spaces;
|
||||||
|
/// this helper hides that detail from tests that only care about the
|
||||||
|
/// logical values.
|
||||||
|
pub fn cells(row: &str) -> Vec<&str> {
|
||||||
|
row.split('\t').map(str::trim).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First data row's first tab-delimited cell, used to extract IDs from
|
||||||
|
/// `pic scripts ls` output. The header is expected to start with "id".
|
||||||
|
pub fn parse_first_id(table: &str) -> Option<String> {
|
||||||
|
let mut lines = table.lines().filter(|l| !l.trim().is_empty());
|
||||||
|
let header = lines.next()?;
|
||||||
|
if !header.starts_with("id") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let row = lines.next()?;
|
||||||
|
let first = row.split('\t').next()?.trim();
|
||||||
|
if first.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(first.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// Fixture-sharing sanity check
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Two tests record `fixture().url` into the same `OnceLock` — if the
|
||||||
|
// fixture isn't actually shared, the second test sees a different URL
|
||||||
|
// and panics. Belt-and-suspenders: pointer identity on `&Fixture`.
|
||||||
|
|
||||||
|
static OBSERVED_URL: OnceLock<String> = OnceLock::new();
|
||||||
|
|
||||||
|
fn observe_fixture_url(label: &str) {
|
||||||
|
let Some(fx) = fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let url = fx.url.clone();
|
||||||
|
match OBSERVED_URL.get() {
|
||||||
|
Some(prev) => assert_eq!(
|
||||||
|
prev, &url,
|
||||||
|
"{label} observed a different fixture URL: prior={prev} now={url}"
|
||||||
|
),
|
||||||
|
None => {
|
||||||
|
let _ = OBSERVED_URL.set(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Same `&'static Fixture` from every call — proves the LazyLock is
|
||||||
|
// sharing, not respawning.
|
||||||
|
let a = fixture_or_skip().unwrap();
|
||||||
|
let b = fixture_or_skip().unwrap();
|
||||||
|
assert!(
|
||||||
|
std::ptr::eq(a, b),
|
||||||
|
"fixture_or_skip should return the same &'static Fixture"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn fixture_url_is_shared_a() {
|
||||||
|
observe_fixture_url("fixture_url_is_shared_a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn fixture_url_is_shared_b() {
|
||||||
|
observe_fixture_url("fixture_url_is_shared_b");
|
||||||
|
}
|
||||||
166
crates/picloud-cli/tests/common/server.rs
Normal file
166
crates/picloud-cli/tests/common/server.rs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
//! Picloud subprocess lifecycle for the CLI integration test binary.
|
||||||
|
//!
|
||||||
|
//! Mirrors what the original seed test did inline, lifted out so it can
|
||||||
|
//! be shared across modules via `common::fixture()`. The Fixture lives
|
||||||
|
//! in a `LazyLock` — and `LazyLock<T>` never drops, so we register an
|
||||||
|
//! `atexit` handler that SIGTERMs the child when the test binary exits
|
||||||
|
//! normally (which is the common case under `cargo test`).
|
||||||
|
//!
|
||||||
|
//! `PR_SET_PDEATHSIG` is intentionally *not* used: it fires when the
|
||||||
|
//! creating thread dies, and cargo runs each `#[test]` on its own
|
||||||
|
//! worker thread that exits as soon as the test returns — which would
|
||||||
|
//! kill picloud after the first test that triggered the LazyLock,
|
||||||
|
//! breaking every test after it.
|
||||||
|
//!
|
||||||
|
//! Abnormal exit (SIGKILL of the test binary) leaks the child; the
|
||||||
|
//! dashboard Playwright suite accepts the same tradeoff.
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Child, Command as StdCommand, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicI32, Ordering};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
pub fn pick_free_port() -> u16 {
|
||||||
|
let listener =
|
||||||
|
std::net::TcpListener::bind("127.0.0.1:0").expect("bind 127.0.0.1:0 to pick port");
|
||||||
|
listener.local_addr().expect("local addr").port()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn picloud_binary_path() -> PathBuf {
|
||||||
|
// The integration test binary lives at
|
||||||
|
// `<target>/debug/deps/cli-<hash>`. Walk up two levels to reach
|
||||||
|
// `<target>/debug` and look for `picloud` next to ourselves.
|
||||||
|
let exe = std::env::current_exe().expect("current_exe");
|
||||||
|
let debug_dir = exe
|
||||||
|
.parent()
|
||||||
|
.and_then(|p| p.parent())
|
||||||
|
.expect("test binary should live under target/debug/deps");
|
||||||
|
debug_dir.join(if cfg!(windows) {
|
||||||
|
"picloud.exe"
|
||||||
|
} else {
|
||||||
|
"picloud"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_picloud(database_url: &str, port: u16, admin_user: &str, admin_pass: &str) -> Child {
|
||||||
|
let binary = picloud_binary_path();
|
||||||
|
assert!(
|
||||||
|
binary.exists(),
|
||||||
|
"expected picloud binary at {}. Run `cargo build -p picloud` first \
|
||||||
|
(or use `cargo test --workspace -- --include-ignored` which builds it)",
|
||||||
|
binary.display()
|
||||||
|
);
|
||||||
|
let mut child = StdCommand::new(&binary)
|
||||||
|
.env("PICLOUD_BIND", format!("127.0.0.1:{port}"))
|
||||||
|
.env("DATABASE_URL", database_url)
|
||||||
|
.env("PICLOUD_ADMIN_USERNAME", admin_user)
|
||||||
|
.env("PICLOUD_ADMIN_PASSWORD", admin_pass)
|
||||||
|
.env("RUST_LOG", "warn")
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.expect("spawn picloud");
|
||||||
|
|
||||||
|
// Drain stderr in a side thread so the pipe buffer doesn't fill and
|
||||||
|
// block the server.
|
||||||
|
if let Some(err) = child.stderr.take().map(BufReader::new) {
|
||||||
|
let (tx, _rx) = mpsc::channel::<String>();
|
||||||
|
thread::spawn(move || {
|
||||||
|
for line in err.lines().map_while(Result::ok) {
|
||||||
|
let _ = tx.send(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
register_atexit_killer(child.id());
|
||||||
|
child
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// atexit-based child cleanup
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
|
||||||
|
static PICLOUD_PID: AtomicI32 = AtomicI32::new(0);
|
||||||
|
|
||||||
|
fn register_atexit_killer(pid: u32) {
|
||||||
|
// First spawn wins; subsequent spawns (none expected today) would
|
||||||
|
// overwrite, but the previous child would already be tracked via
|
||||||
|
// its Drop path on the Fixture.
|
||||||
|
PICLOUD_PID.store(pid as i32, Ordering::SeqCst);
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::sync::Once;
|
||||||
|
static REGISTERED: Once = Once::new();
|
||||||
|
REGISTERED.call_once(|| {
|
||||||
|
// SAFETY: atexit's contract is a `extern "C" fn()` callback;
|
||||||
|
// ours signals a child PID we own.
|
||||||
|
unsafe {
|
||||||
|
libc::atexit(kill_picloud_at_exit);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
extern "C" fn kill_picloud_at_exit() {
|
||||||
|
let pid = PICLOUD_PID.swap(0, Ordering::SeqCst);
|
||||||
|
if pid > 0 {
|
||||||
|
// SAFETY: SIGTERM to a PID we recorded; if PID has been reused
|
||||||
|
// we're killing the wrong process — accepted risk for a test
|
||||||
|
// helper.
|
||||||
|
unsafe {
|
||||||
|
libc::kill(pid, libc::SIGTERM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wait_for_health(url: &str, timeout: Duration) -> Result<(), String> {
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(2))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if let Ok(resp) = client.get(format!("{url}/healthz")).send() {
|
||||||
|
if resp.status().is_success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(250));
|
||||||
|
}
|
||||||
|
Err(format!("/healthz never returned 200 within {timeout:?}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn login_for_bearer_token(url: &str, username: &str, password: &str) -> String {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{url}/api/v1/admin/auth/login"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.expect("login request");
|
||||||
|
assert!(
|
||||||
|
resp.status().is_success(),
|
||||||
|
"login should succeed, got {}: {}",
|
||||||
|
resp.status(),
|
||||||
|
resp.text().unwrap_or_default()
|
||||||
|
);
|
||||||
|
let v: Value = resp.json().expect("login json");
|
||||||
|
v["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("login returns token")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kill_subprocess(child: &mut Child) {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
7
crates/picloud-cli/tests/fixtures/boom.rhai
vendored
Normal file
7
crates/picloud-cli/tests/fixtures/boom.rhai
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Returns a structured 500. The execution is still `Success` in the
|
||||||
|
// log because the script ran cleanly — for an `Error`-status log entry
|
||||||
|
// use throw.rhai instead.
|
||||||
|
#{
|
||||||
|
statusCode: 500,
|
||||||
|
body: #{ ok: false, why: "intentional" },
|
||||||
|
}
|
||||||
6
crates/picloud-cli/tests/fixtures/echo.rhai
vendored
Normal file
6
crates/picloud-cli/tests/fixtures/echo.rhai
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Echoes the request body and headers back so invoke tests can verify
|
||||||
|
// that `--body` (inline / @file / @-) and `-H` flow through end-to-end.
|
||||||
|
#{
|
||||||
|
body: ctx.request.body,
|
||||||
|
headers: ctx.request.headers,
|
||||||
|
}
|
||||||
4
crates/picloud-cli/tests/fixtures/hello.rhai
vendored
Normal file
4
crates/picloud-cli/tests/fixtures/hello.rhai
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Smallest possible Rhai script for the integration test: returns a JSON
|
||||||
|
// object so the orchestrator wraps it as the HTTP response body.
|
||||||
|
let body = #{ ok: true, greeting: "hello from pic" };
|
||||||
|
body
|
||||||
5
crates/picloud-cli/tests/fixtures/loud.rhai
vendored
Normal file
5
crates/picloud-cli/tests/fixtures/loud.rhai
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Logs a long line so the logs-truncation test has something to chew on.
|
||||||
|
// `pic logs` truncates the summary cell to 120 characters; this line is
|
||||||
|
// 240 chars after the prefix so the truncation is unambiguous.
|
||||||
|
log::info("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
|
||||||
|
#{ ok: true }
|
||||||
4
crates/picloud-cli/tests/fixtures/throw.rhai
vendored
Normal file
4
crates/picloud-cli/tests/fixtures/throw.rhai
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Throws a Rhai runtime error. The orchestrator records this as
|
||||||
|
// `ExecutionStatus::Error` in the execution log (a structured 5xx
|
||||||
|
// response is recorded as `Success`).
|
||||||
|
throw "boom";
|
||||||
171
crates/picloud-cli/tests/invoke.rs
Normal file
171
crates/picloud-cli/tests/invoke.rs
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
//! `pic scripts invoke` — body sources (inline, `@file`, `@-`), header
|
||||||
|
//! propagation, exit-code semantics for non-2xx responses, and 404
|
||||||
|
//! handling for unknown ids.
|
||||||
|
|
||||||
|
use predicates::prelude::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn invoke_with_inline_json_body_echoes() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "invoke-inline", "echo.rhai");
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id, "--body", r#"{"x":1}"#])
|
||||||
|
.output()
|
||||||
|
.expect("invoke");
|
||||||
|
assert!(out.status.success(), "invoke failed: {out:?}");
|
||||||
|
let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
|
||||||
|
assert_eq!(parsed["body"]["x"], 1, "echoed body: {parsed}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn invoke_with_file_body() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "invoke-file", "echo.rhai");
|
||||||
|
|
||||||
|
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
|
||||||
|
std::fs::write(tmp.path(), r#"{"src":"file"}"#).unwrap();
|
||||||
|
let body_arg = format!("@{}", tmp.path().display());
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id, "--body", &body_arg])
|
||||||
|
.output()
|
||||||
|
.expect("invoke");
|
||||||
|
assert!(out.status.success(), "invoke failed: {out:?}");
|
||||||
|
let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
|
||||||
|
assert_eq!(parsed["body"]["src"], "file", "echoed body: {parsed}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn invoke_with_stdin_body() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "invoke-stdin", "echo.rhai");
|
||||||
|
|
||||||
|
let assert = common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id, "--body", "@-"])
|
||||||
|
.write_stdin(r#"{"src":"stdin"}"#)
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let out = assert.get_output();
|
||||||
|
let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
|
||||||
|
assert_eq!(parsed["body"]["src"], "stdin", "echoed body: {parsed}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn invoke_propagates_headers() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "invoke-hdr", "echo.rhai");
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"invoke",
|
||||||
|
&id,
|
||||||
|
"-H",
|
||||||
|
"X-Foo: bar",
|
||||||
|
"-H",
|
||||||
|
"X-Baz=qux",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.expect("invoke");
|
||||||
|
assert!(out.status.success(), "invoke failed: {out:?}");
|
||||||
|
let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
|
||||||
|
// HTTP normalises header names to lowercase.
|
||||||
|
assert_eq!(parsed["headers"]["x-foo"], "bar", "echoed: {parsed}");
|
||||||
|
assert_eq!(parsed["headers"]["x-baz"], "qux", "echoed: {parsed}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn invoke_unknown_script_id_errors() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
|
||||||
|
// Any well-formed UUID that doesn't exist server-side. The
|
||||||
|
// orchestrator's `/execute/{id}` handler returns 404 specifically
|
||||||
|
// for unknown ids — tighten the predicate so a regressed 401
|
||||||
|
// wouldn't sneak through.
|
||||||
|
let bogus = "00000000-0000-0000-0000-000000000000";
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", bogus])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("HTTP 404"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pic invoke <id>` (top-level alias) and `pic scripts invoke <id>`
|
||||||
|
/// must hit the same handler and produce identical-shape stdout.
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn top_level_invoke_alias_works() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "inv-alias", "hello.rhai");
|
||||||
|
|
||||||
|
let nested = common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id])
|
||||||
|
.output()
|
||||||
|
.expect("scripts invoke");
|
||||||
|
assert!(nested.status.success());
|
||||||
|
let nested_body: Value = serde_json::from_slice(&nested.stdout).unwrap();
|
||||||
|
|
||||||
|
let aliased = common::pic_as(&env)
|
||||||
|
.args(["invoke", &id])
|
||||||
|
.output()
|
||||||
|
.expect("invoke (top-level)");
|
||||||
|
assert!(aliased.status.success());
|
||||||
|
let aliased_body: Value = serde_json::from_slice(&aliased.stdout).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
nested_body, aliased_body,
|
||||||
|
"top-level alias should produce identical body to scripts invoke"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn invoke_non_2xx_exits_nonzero_but_prints_body() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "invoke-500", "boom.rhai");
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id])
|
||||||
|
.output()
|
||||||
|
.expect("invoke");
|
||||||
|
assert!(!out.status.success(), "expected non-zero exit: {out:?}");
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("<- HTTP 500"),
|
||||||
|
"stderr should report HTTP 500: {stderr}"
|
||||||
|
);
|
||||||
|
let parsed: Value = serde_json::from_slice(&out.stdout)
|
||||||
|
.unwrap_or_else(|e| panic!("stdout was not JSON ({e}): {:?}", out.stdout));
|
||||||
|
assert_eq!(parsed["ok"], false, "boom body: {parsed}");
|
||||||
|
assert_eq!(parsed["why"], "intentional", "boom body: {parsed}");
|
||||||
|
}
|
||||||
179
crates/picloud-cli/tests/logs.rs
Normal file
179
crates/picloud-cli/tests/logs.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
//! `pic logs <script-id>` — emptiness, status labels, `--limit`
|
||||||
|
//! clamping, error path for unknown ids, and the 120-char truncate
|
||||||
|
//! applied to the summary column.
|
||||||
|
|
||||||
|
use predicates::prelude::*;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
|
||||||
|
/// Pick out the data rows from `pic logs` TSV output — the header line
|
||||||
|
/// (`created_at\tstatus\tsummary`) is now always present, so the old
|
||||||
|
/// "no non-empty lines means no logs" check needs to skip it.
|
||||||
|
fn data_rows(stdout: &str) -> Vec<&str> {
|
||||||
|
stdout
|
||||||
|
.lines()
|
||||||
|
.filter(|l| !l.trim().is_empty())
|
||||||
|
.filter(|l| !l.starts_with("created_at"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logs_for_fresh_script_is_empty() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "logs-empty", "hello.rhai");
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["logs", &id])
|
||||||
|
.output()
|
||||||
|
.expect("logs");
|
||||||
|
assert!(out.status.success(), "logs failed: {out:?}");
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
data_rows(&stdout).is_empty(),
|
||||||
|
"expected no log rows (header is allowed), got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logs_after_invoke_records_success_row() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "logs-ok", "hello.rhai");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["logs", &id])
|
||||||
|
.output()
|
||||||
|
.expect("logs");
|
||||||
|
assert!(out.status.success(), "logs failed: {out:?}");
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let rows = data_rows(&stdout);
|
||||||
|
assert_eq!(rows.len(), 1, "expected 1 data row, got: {stdout}");
|
||||||
|
let cols: Vec<&str> = rows[0].split('\t').map(str::trim).collect();
|
||||||
|
assert_eq!(
|
||||||
|
cols.len(),
|
||||||
|
3,
|
||||||
|
"row should be 3 tab-delimited cells: {rows:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(cols[1], "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logs_records_error_for_throwing_script() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "logs-err", "throw.rhai");
|
||||||
|
|
||||||
|
// The invoke is expected to fail — we only care that the execution
|
||||||
|
// gets recorded with `Error` status.
|
||||||
|
let _ = common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["logs", &id])
|
||||||
|
.output()
|
||||||
|
.expect("logs");
|
||||||
|
assert!(out.status.success(), "logs failed: {out:?}");
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let row = data_rows(&stdout)
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.expect("at least one data row");
|
||||||
|
let cols: Vec<&str> = row.split('\t').map(str::trim).collect();
|
||||||
|
assert_eq!(cols[1], "error", "expected error status, got row: {row}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logs_respects_limit_flag() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "logs-limit", "hello.rhai");
|
||||||
|
|
||||||
|
for _ in 0..3 {
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["logs", &id, "--limit", "1"])
|
||||||
|
.output()
|
||||||
|
.expect("logs");
|
||||||
|
assert!(out.status.success(), "logs failed: {out:?}");
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let rows = data_rows(&stdout).len();
|
||||||
|
assert_eq!(rows, 1, "expected --limit 1, got rows: {stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logs_for_unknown_id_errors() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
|
||||||
|
let bogus = "00000000-0000-0000-0000-000000000000";
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["logs", bogus])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
// 404 specifically — same `NotFound(ScriptId)` path the get/edit
|
||||||
|
// endpoints use.
|
||||||
|
.stderr(predicate::str::contains("HTTP 404"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logs_truncates_long_summary() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "logs-loud", "loud.rhai");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["logs", &id])
|
||||||
|
.output()
|
||||||
|
.expect("logs");
|
||||||
|
assert!(out.status.success(), "logs failed: {out:?}");
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let row = data_rows(&stdout)
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.expect("at least one data row");
|
||||||
|
let summary = row.split('\t').nth(2).expect("summary column");
|
||||||
|
assert!(
|
||||||
|
summary.ends_with('…'),
|
||||||
|
"summary should be truncated with `…`, got: {summary}"
|
||||||
|
);
|
||||||
|
let chars = summary.chars().count();
|
||||||
|
assert!(
|
||||||
|
chars <= 121,
|
||||||
|
"summary should be ≤120 chars + the truncation marker, got {chars}: {summary}"
|
||||||
|
);
|
||||||
|
}
|
||||||
289
crates/picloud-cli/tests/output.rs
Normal file
289
crates/picloud-cli/tests/output.rs
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
//! Output-shape invariants — the contracts downstream `jq`/`awk`
|
||||||
|
//! pipelines depend on: column headers, stdout-vs-stderr separation,
|
||||||
|
//! and RFC3339 timestamps.
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
use crate::common::cleanup::AppGuard;
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn apps_ls_header_columns() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["apps", "ls"])
|
||||||
|
.output()
|
||||||
|
.expect("apps ls");
|
||||||
|
assert!(out.status.success());
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let header = stdout.lines().next().expect("header row");
|
||||||
|
assert_eq!(
|
||||||
|
common::cells(header),
|
||||||
|
vec!["slug", "name", "my_role", "created_at"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn scripts_ls_header_columns() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("out-ls");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["scripts", "ls", "--app", &slug])
|
||||||
|
.output()
|
||||||
|
.expect("scripts ls");
|
||||||
|
assert!(out.status.success());
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let header = stdout.lines().next().expect("header row");
|
||||||
|
assert_eq!(
|
||||||
|
common::cells(header),
|
||||||
|
vec!["id", "app_slug", "name", "version", "updated_at"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn invoke_separates_stdout_and_stderr() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "out-inv", "hello.rhai");
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id])
|
||||||
|
.output()
|
||||||
|
.expect("invoke");
|
||||||
|
assert!(out.status.success());
|
||||||
|
|
||||||
|
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||||
|
assert!(
|
||||||
|
stderr.starts_with("<- HTTP 200"),
|
||||||
|
"stderr should announce HTTP status: {stderr:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let parsed: Value = serde_json::from_slice(&out.stdout)
|
||||||
|
.expect("stdout should be JSON only, with no status prefix");
|
||||||
|
assert_eq!(parsed["ok"], true, "body: {parsed}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn error_goes_to_stderr_not_stdout() {
|
||||||
|
let Some(_fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Use a pristine env (no credentials file) so `whoami` is guaranteed
|
||||||
|
// to fail at the `config::load` step — `admin_env` would pre-seed
|
||||||
|
// creds and the command would succeed.
|
||||||
|
let env = common::TestEnv {
|
||||||
|
url: String::new(),
|
||||||
|
token: String::new(),
|
||||||
|
config_dir: tempfile::TempDir::new().unwrap(),
|
||||||
|
home: tempfile::TempDir::new().unwrap(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let out = common::pic_no_env(&env)
|
||||||
|
.args(["whoami"])
|
||||||
|
.output()
|
||||||
|
.expect("whoami");
|
||||||
|
assert!(!out.status.success(), "expected failure, got: {out:?}");
|
||||||
|
assert!(
|
||||||
|
out.stdout.is_empty(),
|
||||||
|
"stdout should be empty on error, got: {:?}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
);
|
||||||
|
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||||
|
assert!(
|
||||||
|
stderr.contains("error:"),
|
||||||
|
"stderr should be prefixed with `error:`: {stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn apps_ls_created_at_is_rfc3339() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("out-date");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["apps", "ls"])
|
||||||
|
.output()
|
||||||
|
.expect("apps ls");
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let row = stdout
|
||||||
|
.lines()
|
||||||
|
.map(common::cells)
|
||||||
|
.find(|c| c.first().copied() == Some(slug.as_str()))
|
||||||
|
.unwrap_or_else(|| panic!("slug {slug} missing in: {stdout}"));
|
||||||
|
let created_at = row.get(3).expect("created_at cell");
|
||||||
|
|
||||||
|
// Accept the RFC3339 shape without pulling in chrono — `YYYY-MM-DDTHH:MM:SS`
|
||||||
|
// with optional fraction + timezone is enough of a contract for the test.
|
||||||
|
assert!(
|
||||||
|
created_at.len() >= 20
|
||||||
|
&& created_at.as_bytes()[4] == b'-'
|
||||||
|
&& created_at.as_bytes()[7] == b'-'
|
||||||
|
&& created_at.as_bytes()[10] == b'T'
|
||||||
|
&& created_at.as_bytes()[13] == b':'
|
||||||
|
&& created_at.as_bytes()[16] == b':',
|
||||||
|
"created_at not RFC3339-shaped: {created_at}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--output json` is the global pipeline-friendly format. Validates
|
||||||
|
/// `apps ls` returns a real JSON array (not a TSV-with-quotes hack).
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn apps_ls_json_output_is_valid_array() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("out-json-apps");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["--output", "json", "apps", "ls"])
|
||||||
|
.output()
|
||||||
|
.expect("apps ls --output json");
|
||||||
|
assert!(out.status.success(), "apps ls failed: {out:?}");
|
||||||
|
let v: Value = serde_json::from_slice(&out.stdout).expect("stdout should be JSON");
|
||||||
|
let arr = v.as_array().expect("apps ls JSON should be an array");
|
||||||
|
assert!(
|
||||||
|
arr.iter()
|
||||||
|
.any(|row| row.get("slug").and_then(Value::as_str) == Some(slug.as_str())),
|
||||||
|
"json should include created slug: {v}"
|
||||||
|
);
|
||||||
|
// The header row must NOT bleed into JSON output — the rendered
|
||||||
|
// objects use header *keys*, not data cells.
|
||||||
|
assert!(
|
||||||
|
arr.iter().all(|row| row.get("slug").is_some()),
|
||||||
|
"every row should have a `slug` key: {v}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn scripts_ls_json_output_has_app_slug() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("out-json-scr");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
let fixture = common::fixture_path("hello.rhai");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["--output", "json", "scripts", "ls", "--app", &slug])
|
||||||
|
.output()
|
||||||
|
.expect("scripts ls --output json");
|
||||||
|
assert!(out.status.success());
|
||||||
|
let v: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
|
||||||
|
let arr = v.as_array().expect("array");
|
||||||
|
let row = arr
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.get("name").and_then(Value::as_str) == Some("hello"))
|
||||||
|
.expect("hello row");
|
||||||
|
assert_eq!(row["app_slug"].as_str(), Some(slug.as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logs_json_output_is_array_of_objects() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "out-json-log", "hello.rhai");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["--output", "json", "logs", &id])
|
||||||
|
.output()
|
||||||
|
.expect("logs --output json");
|
||||||
|
assert!(out.status.success());
|
||||||
|
let v: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
|
||||||
|
let arr = v.as_array().expect("array");
|
||||||
|
assert!(!arr.is_empty(), "expected at least one log");
|
||||||
|
// Schema: each row carries the raw `ExecutionLog`, not the
|
||||||
|
// truncated summary the TSV form uses.
|
||||||
|
assert!(
|
||||||
|
arr[0].get("status").is_some(),
|
||||||
|
"log row missing status: {arr:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TSV `whoami` used to be a single tab-separated line with no labels;
|
||||||
|
/// downstream tools couldn't tell which column was the role. Now it's
|
||||||
|
/// a key/value block with stable labels.
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn whoami_tsv_has_labeled_rows() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["whoami"])
|
||||||
|
.output()
|
||||||
|
.expect("whoami");
|
||||||
|
assert!(out.status.success());
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let labels: Vec<&str> = stdout
|
||||||
|
.lines()
|
||||||
|
.filter_map(|l| l.split('\t').next())
|
||||||
|
.map(str::trim)
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
labels.contains(&"username"),
|
||||||
|
"missing username row: {stdout}"
|
||||||
|
);
|
||||||
|
assert!(labels.contains(&"role"), "missing role row: {stdout}");
|
||||||
|
assert!(labels.contains(&"email"), "missing email row: {stdout}");
|
||||||
|
assert!(labels.contains(&"url"), "missing url row: {stdout}");
|
||||||
|
}
|
||||||
146
crates/picloud-cli/tests/roles.rs
Normal file
146
crates/picloud-cli/tests/roles.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
//! RBAC mirror of the dashboard's role-shadowing specs. A Member user
|
||||||
|
//! is minted via the admin API, granted (or denied) membership on an
|
||||||
|
//! app, then `pic` is driven against the member's bearer token to
|
||||||
|
//! confirm the server's capability gates surface as expected exit
|
||||||
|
//! codes / error messages.
|
||||||
|
|
||||||
|
use predicates::prelude::*;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
use crate::common::cleanup::AppGuard;
|
||||||
|
use crate::common::member;
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn member_apps_ls_only_shows_their_apps() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let admin_env = common::admin_env(fx);
|
||||||
|
|
||||||
|
let slug_visible = common::unique_slug("roles-visible");
|
||||||
|
let slug_hidden = common::unique_slug("roles-hidden");
|
||||||
|
common::pic_as(&admin_env)
|
||||||
|
.args(["apps", "create", &slug_visible])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _g1 = AppGuard::new(&admin_env.url, &admin_env.token, &slug_visible);
|
||||||
|
common::pic_as(&admin_env)
|
||||||
|
.args(["apps", "create", &slug_hidden])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _g2 = AppGuard::new(&admin_env.url, &admin_env.token, &slug_hidden);
|
||||||
|
|
||||||
|
let m = member::member_user(fx, &common::unique_username("rls"));
|
||||||
|
member::grant_membership(fx, &slug_visible, &m.id, "viewer");
|
||||||
|
let member_env = common::custom_env(&fx.url, &m.token);
|
||||||
|
common::seed_credentials(&member_env, &m.username);
|
||||||
|
|
||||||
|
let out = common::pic_as(&member_env)
|
||||||
|
.args(["apps", "ls"])
|
||||||
|
.output()
|
||||||
|
.expect("apps ls");
|
||||||
|
assert!(out.status.success(), "apps ls failed: {out:?}");
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
stdout.contains(&slug_visible),
|
||||||
|
"member should see {slug_visible}, got: {stdout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!stdout.contains(&slug_hidden),
|
||||||
|
"member should NOT see {slug_hidden}, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn viewer_cannot_deploy_but_editor_can() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let admin_env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("roles-write");
|
||||||
|
common::pic_as(&admin_env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _g = AppGuard::new(&admin_env.url, &admin_env.token, &slug);
|
||||||
|
|
||||||
|
let m = member::member_user(fx, &common::unique_username("vw"));
|
||||||
|
member::grant_membership(fx, &slug, &m.id, "viewer");
|
||||||
|
let member_env = common::custom_env(&fx.url, &m.token);
|
||||||
|
common::seed_credentials(&member_env, &m.username);
|
||||||
|
|
||||||
|
let fixture = common::fixture_path("hello.rhai");
|
||||||
|
common::pic_as(&member_env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
// `Forbidden` → 403. A regressed predicate of `"HTTP 4"` would
|
||||||
|
// have masked an auth break (401) as an authz issue.
|
||||||
|
.stderr(predicate::str::contains("HTTP 403"));
|
||||||
|
|
||||||
|
// Promote to Editor and retry — the same command should now succeed.
|
||||||
|
member::update_membership(fx, &slug, &m.id, "editor");
|
||||||
|
common::pic_as(&member_env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("Created hello v1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn member_can_invoke_any_script_with_id() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// `/api/v1/execute/{id}` is the unguarded data-plane ingress — even
|
||||||
|
// a member with no app membership can hit it as long as they hold
|
||||||
|
// a valid token (the orchestrator doesn't gate it).
|
||||||
|
let admin_env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&admin_env, "roles-inv", "hello.rhai");
|
||||||
|
|
||||||
|
let m = member::member_user(fx, &common::unique_username("inv"));
|
||||||
|
let member_env = common::custom_env(&fx.url, &m.token);
|
||||||
|
common::seed_credentials(&member_env, &m.username);
|
||||||
|
|
||||||
|
common::pic_as(&member_env)
|
||||||
|
.args(["scripts", "invoke", &id])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn non_member_cannot_read_logs() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let admin_env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&admin_env, "roles-log", "hello.rhai");
|
||||||
|
|
||||||
|
let m = member::member_user(fx, &common::unique_username("rl"));
|
||||||
|
let member_env = common::custom_env(&fx.url, &m.token);
|
||||||
|
common::seed_credentials(&member_env, &m.username);
|
||||||
|
|
||||||
|
common::pic_as(&member_env)
|
||||||
|
.args(["logs", &id])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
// Non-member → 403 from the authz layer, not 404 — the script
|
||||||
|
// exists; the caller just can't see it.
|
||||||
|
.stderr(predicate::str::contains("HTTP 403"));
|
||||||
|
}
|
||||||
240
crates/picloud-cli/tests/scripts.rs
Normal file
240
crates/picloud-cli/tests/scripts.rs
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
//! `pic scripts deploy` / `pic scripts ls` edge cases beyond the
|
||||||
|
//! smoke test: unknown app, name override, version bumping, missing
|
||||||
|
//! file, and the no-`--app` walk across every accessible app.
|
||||||
|
|
||||||
|
use predicates::prelude::*;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
use crate::common::cleanup::AppGuard;
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn deploy_against_unknown_app_errors() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let fixture = common::fixture_path("hello.rhai");
|
||||||
|
let bogus_slug = common::unique_slug("nope");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&bogus_slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
// Specifically 404 — `apps_get` short-circuits before the deploy
|
||||||
|
// request even starts. Loose `"HTTP 4"` would have matched a
|
||||||
|
// regressed 401 from broken auth.
|
||||||
|
.stderr(predicate::str::contains("HTTP 404"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn deploy_with_name_override() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("scripts-named");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
let fixture = common::fixture_path("hello.rhai");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
"--name",
|
||||||
|
"custom-name",
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("Created custom-name v1"));
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
"--name",
|
||||||
|
"custom-name",
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("Updated custom-name v2"));
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["scripts", "ls", "--app", &slug])
|
||||||
|
.output()
|
||||||
|
.expect("scripts ls");
|
||||||
|
assert!(out.status.success());
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
stdout
|
||||||
|
.lines()
|
||||||
|
.map(common::cells)
|
||||||
|
.any(|c| c.get(2).copied() == Some("custom-name") && c.get(3).copied() == Some("2")),
|
||||||
|
"expected custom-name v2 row, got: {stdout}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn deploy_bumps_version_each_redeploy() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("scripts-bump");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
let fixture = common::fixture_path("hello.rhai");
|
||||||
|
for expected in ["Created hello v1", "Updated hello v2", "Updated hello v3"] {
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(expected));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn deploy_missing_file_errors() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("scripts-missing");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
let missing = std::env::temp_dir().join(common::unique_slug("ghost") + ".rhai");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
missing.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("reading"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn ls_without_app_walks_every_accessible_app() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug_a = common::unique_slug("scripts-walk-a");
|
||||||
|
let slug_b = common::unique_slug("scripts-walk-b");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug_a])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard_a = AppGuard::new(&env.url, &env.token, &slug_a);
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug_b])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard_b = AppGuard::new(&env.url, &env.token, &slug_b);
|
||||||
|
|
||||||
|
let fixture = common::fixture_path("hello.rhai");
|
||||||
|
for slug in [&slug_a, &slug_b] {
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
// `pic scripts ls` (no `--app`) issues a single `GET /admin/scripts`
|
||||||
|
// against the server now — there's nothing per-app to race against
|
||||||
|
// a concurrent AppGuard drop. The previous implementation walked
|
||||||
|
// `apps_list` followed by per-app `scripts_list_by_app` calls and
|
||||||
|
// aborted on the first 404, which forced this test to retry 5× to
|
||||||
|
// paper over the bug. Both the walk and the retry are gone.
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["scripts", "ls"])
|
||||||
|
.output()
|
||||||
|
.expect("scripts ls");
|
||||||
|
assert!(out.status.success(), "scripts ls failed: {out:?}");
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
|
||||||
|
let slugs: std::collections::HashSet<&str> = stdout
|
||||||
|
.lines()
|
||||||
|
.map(common::cells)
|
||||||
|
.filter_map(|c| c.get(1).copied())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
slugs.contains(slug_a.as_str()),
|
||||||
|
"missing app A in: {stdout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
slugs.contains(slug_b.as_str()),
|
||||||
|
"missing app B in: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn delete_removes_script_from_ls() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "scripts-del", "hello.rhai");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "delete", &id])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(format!("Deleted script {id}")));
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["scripts", "ls"])
|
||||||
|
.output()
|
||||||
|
.expect("scripts ls");
|
||||||
|
assert!(out.status.success());
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
!stdout.contains(&id),
|
||||||
|
"deleted script id should not appear in ls: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,21 +10,23 @@ 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, admins_router, api_keys_router, apps_api, apps_router, auth_router,
|
admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
|
||||||
compile_routes, migrations, require_authenticated, route_admin_router, AdminSessionRepository,
|
attach_principal_if_present, auth_router, compile_routes, migrations, require_authenticated,
|
||||||
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
|
route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
|
||||||
AppDomainRepository, AppRepository, AppsState, AuthState, AuthzRepo,
|
ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState,
|
||||||
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository,
|
AppRepository, AppsState, AuthState, AuthzRepo, PostgresAdminSessionRepository,
|
||||||
PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository,
|
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
|
||||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
PostgresAppMembersRepository, PostgresAppRepository, PostgresExecutionLogRepository,
|
||||||
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
PostgresExecutionLogSink, PostgresRouteRepository, PostgresScriptRepository, RepoResolver,
|
||||||
|
RouteAdminState, RouteRepository, SandboxCeiling,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::routing::{AppDomainTable, 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, ExecutionGate, LocalExecutorClient,
|
||||||
};
|
};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
ExecutionLogSink, ScriptValidator, API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION,
|
ExecutionLogSink, ScriptValidator, Services, API_VERSION, PRODUCT_VERSION, SDK_VERSION,
|
||||||
|
WIRE_VERSION,
|
||||||
};
|
};
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -79,8 +81,11 @@ fn read_session_ttl() -> Duration {
|
|||||||
/// the `require_admin` middleware. The data plane
|
/// the `require_admin` middleware. The data plane
|
||||||
/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`,
|
/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`,
|
||||||
/// `/version`) stays open — it's the public ingress for user scripts.
|
/// `/version`) stays open — it's the public ingress for user scripts.
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||||
let engine = Arc::new(Engine::new(Limits::default()));
|
// `Services` is the SDK service bundle. Empty in v1.1.0; the
|
||||||
|
// v1.1.1 KV PR will populate it with `kv: Arc::new(...)` here.
|
||||||
|
let engine = Arc::new(Engine::new(Limits::default(), Services::new()));
|
||||||
|
|
||||||
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()));
|
||||||
@@ -89,9 +94,13 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
||||||
let domains_repo: Arc<dyn AppDomainRepository> =
|
let domains_repo: Arc<dyn AppDomainRepository> =
|
||||||
Arc::new(PostgresAppDomainRepository::new(pool.clone()));
|
Arc::new(PostgresAppDomainRepository::new(pool.clone()));
|
||||||
// Authz: app_members repo doubles as the AuthzRepo impl for the
|
// The Postgres app_members repo implements both `AppMembersRepository`
|
||||||
// per-handler capability checks introduced in Phase 3.5.
|
// (CRUD over the table) and `AuthzRepo` (single-row membership lookup
|
||||||
let authz: Arc<dyn AuthzRepo> = Arc::new(PostgresAppMembersRepository::new(pool));
|
// for capability checks). Construct it once and clone the Arc into
|
||||||
|
// both trait views — same allocation, two vtables.
|
||||||
|
let members_concrete = Arc::new(PostgresAppMembersRepository::new(pool));
|
||||||
|
let members: Arc<dyn AppMembersRepository> = members_concrete.clone();
|
||||||
|
let authz: Arc<dyn AuthzRepo> = members_concrete;
|
||||||
|
|
||||||
// Compile the routes table once at startup; admin writes refresh it.
|
// Compile the routes table once at startup; admin writes refresh it.
|
||||||
let route_table = Arc::new(RouteTable::new());
|
let route_table = Arc::new(RouteTable::new());
|
||||||
@@ -120,7 +129,10 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
|
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
|
||||||
script_repo.clone(),
|
script_repo.clone(),
|
||||||
)));
|
)));
|
||||||
let executor = Arc::new(LocalExecutorClient::new(engine.clone()));
|
// Single global gate — overflow is rejected with 503 + Retry-After.
|
||||||
|
// See `ExecutionGate` docs and `PICLOUD_MAX_CONCURRENT_EXECUTIONS`.
|
||||||
|
let gate = Arc::new(ExecutionGate::from_env());
|
||||||
|
let executor = Arc::new(LocalExecutorClient::new(engine.clone(), gate));
|
||||||
|
|
||||||
let admin = AdminState {
|
let admin = AdminState {
|
||||||
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
|
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
|
||||||
@@ -159,9 +171,15 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
ttl: auth.ttl,
|
ttl: auth.ttl,
|
||||||
};
|
};
|
||||||
let admins_state = AdminsState {
|
let admins_state = AdminsState {
|
||||||
users: auth.users,
|
users: auth.users.clone(),
|
||||||
sessions: auth.sessions,
|
sessions: auth.sessions,
|
||||||
keys: auth.keys.clone(),
|
keys: auth.keys.clone(),
|
||||||
|
authz: authz.clone(),
|
||||||
|
};
|
||||||
|
let app_members_state = AppMembersState {
|
||||||
|
apps: apps_state.apps.clone(),
|
||||||
|
users: auth.users,
|
||||||
|
members,
|
||||||
authz,
|
authz,
|
||||||
};
|
};
|
||||||
let api_keys_state = ApiKeysState { keys: auth.keys };
|
let api_keys_state = ApiKeysState { keys: auth.keys };
|
||||||
@@ -177,6 +195,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
.merge(route_admin_router(route_admin))
|
.merge(route_admin_router(route_admin))
|
||||||
.merge(admins_router(admins_state))
|
.merge(admins_router(admins_state))
|
||||||
.merge(apps_router(apps_state))
|
.merge(apps_router(apps_state))
|
||||||
|
.merge(app_members_router(app_members_state))
|
||||||
.merge(api_keys_router(api_keys_state))
|
.merge(api_keys_router(api_keys_state))
|
||||||
.layer(from_fn_with_state(
|
.layer(from_fn_with_state(
|
||||||
auth_state.clone(),
|
auth_state.clone(),
|
||||||
@@ -187,16 +206,31 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
// facade above; the bare module path is retained so it's discoverable.
|
// facade above; the bare module path is retained so it's discoverable.
|
||||||
let _ = apps_api::AppsState::clone;
|
let _ = apps_api::AppsState::clone;
|
||||||
|
|
||||||
|
// Opportunistic principal extraction on every data-plane request.
|
||||||
|
// Always inserts `Extension<Option<Principal>>`: Some for authed
|
||||||
|
// ingress (bearer / cookie), None otherwise. Handlers depend on
|
||||||
|
// this layer being applied — scoped to the data-plane routers so
|
||||||
|
// the admin path (which uses `require_authenticated`) doesn't
|
||||||
|
// double-resolve the same token.
|
||||||
|
let data_plane_routed = data_plane_router(data_plane.clone()).layer(from_fn_with_state(
|
||||||
|
auth_state.clone(),
|
||||||
|
attach_principal_if_present,
|
||||||
|
));
|
||||||
|
let user_routes = user_routes_router(data_plane).layer(from_fn_with_state(
|
||||||
|
auth_state.clone(),
|
||||||
|
attach_principal_if_present,
|
||||||
|
));
|
||||||
|
|
||||||
let api_v1 = Router::new()
|
let api_v1 = Router::new()
|
||||||
.nest("/admin", auth_router(auth_state))
|
.nest("/admin", auth_router(auth_state))
|
||||||
.nest("/admin", guarded_admin)
|
.nest("/admin", guarded_admin)
|
||||||
.merge(data_plane_router(data_plane.clone()));
|
.merge(data_plane_routed);
|
||||||
|
|
||||||
Ok(Router::new()
|
Ok(Router::new()
|
||||||
.route("/healthz", get(healthz))
|
.route("/healthz", get(healthz))
|
||||||
.route("/version", get(version))
|
.route("/version", get(version))
|
||||||
.nest(&format!("/api/v{API_VERSION}"), api_v1)
|
.nest(&format!("/api/v{API_VERSION}"), api_v1)
|
||||||
.merge(user_routes_router(data_plane))
|
.merge(user_routes)
|
||||||
.layer(TraceLayer::new_for_http()))
|
.layer(TraceLayer::new_for_http()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,72 @@ async fn mint_key(server: &TestServer, cred_token: &str, body: Value) -> axum_te
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- app members helpers ----------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_members(
|
||||||
|
server: &TestServer,
|
||||||
|
token: &str,
|
||||||
|
app_ident: &str,
|
||||||
|
) -> axum_test::TestResponse {
|
||||||
|
server
|
||||||
|
.get(&format!("/api/v1/admin/apps/{app_ident}/members"))
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_member(
|
||||||
|
server: &TestServer,
|
||||||
|
token: &str,
|
||||||
|
app_ident: &str,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> axum_test::TestResponse {
|
||||||
|
server
|
||||||
|
.post(&format!("/api/v1/admin/apps/{app_ident}/members"))
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.json(&json!({ "user_id": user_id, "role": role.as_str() }))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_member_role(
|
||||||
|
server: &TestServer,
|
||||||
|
token: &str,
|
||||||
|
app_ident: &str,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> axum_test::TestResponse {
|
||||||
|
server
|
||||||
|
.patch(&format!("/api/v1/admin/apps/{app_ident}/members/{user_id}",))
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.json(&json!({ "role": role.as_str() }))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_member(
|
||||||
|
server: &TestServer,
|
||||||
|
token: &str,
|
||||||
|
app_ident: &str,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> axum_test::TestResponse {
|
||||||
|
server
|
||||||
|
.delete(&format!("/api/v1/admin/apps/{app_ident}/members/{user_id}",))
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Direct-DB inactive-user seed — the create-then-deactivate dance
|
||||||
|
/// through the API is more ceremony than the test needs.
|
||||||
|
async fn seed_inactive_user(pool: &PgPool, username: &str, password: &str) -> AdminUserId {
|
||||||
|
let repo = PostgresAdminUserRepository::new(pool.clone());
|
||||||
|
let hash = hash_password(password).expect("hash");
|
||||||
|
let row = repo
|
||||||
|
.create(username, &hash, InstanceRole::Member, None)
|
||||||
|
.await
|
||||||
|
.expect("seed user");
|
||||||
|
repo.set_active(row.id, false).await.expect("deactivate");
|
||||||
|
row.id
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// 1. Bootstrap admin → owner
|
// 1. Bootstrap admin → owner
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -227,7 +293,7 @@ async fn owner_access_matrix(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 admin_can_manage_users_but_not_app_admin_settings(pool: PgPool) {
|
async fn admin_is_implicit_app_admin_on_every_app(pool: PgPool) {
|
||||||
let s = boot(pool.clone()).await;
|
let s = boot(pool.clone()).await;
|
||||||
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
|
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
|
||||||
let token = login_token(&s.server, "alice", "alice-pw").await;
|
let token = login_token(&s.server, "alice", "alice-pw").await;
|
||||||
@@ -239,24 +305,34 @@ async fn admin_can_manage_users_but_not_app_admin_settings(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status_ok();
|
.assert_status_ok();
|
||||||
|
|
||||||
// Allowed: read default app (admin is implicit editor everywhere).
|
// Allowed: read default app — admin is implicit app_admin
|
||||||
|
// everywhere (per blueprint §11.6).
|
||||||
s.server
|
s.server
|
||||||
.get("/api/v1/admin/apps/default")
|
.get("/api/v1/admin/apps/default")
|
||||||
.add_header("authorization", format!("Bearer {token}"))
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
.await
|
.await
|
||||||
.assert_status_ok();
|
.assert_status_ok();
|
||||||
|
|
||||||
// Allowed: write scripts (implicit editor).
|
// Allowed: write scripts.
|
||||||
let script = create_script_via_api(&s.server, &token, s.default_app, "admin-write").await;
|
let script = create_script_via_api(&s.server, &token, s.default_app, "admin-write").await;
|
||||||
assert!(script["id"].is_string());
|
assert!(script["id"].is_string());
|
||||||
|
|
||||||
// Denied: delete the default app (AppAdmin only).
|
// Allowed: list app members (AppAdmin gate). Pre-3.5.x this
|
||||||
let denied = s
|
// 403'd; now it's the same allow as the owner sees.
|
||||||
.server
|
s.server
|
||||||
.delete("/api/v1/admin/apps/default")
|
.get("/api/v1/admin/apps/default/members")
|
||||||
.add_header("authorization", format!("Bearer {token}"))
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
.await;
|
.await
|
||||||
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
|
.assert_status_ok();
|
||||||
|
|
||||||
|
// Allowed: delete the default app (AppAdmin). ?force=true because
|
||||||
|
// the script we created above pushes us past the soft no-cascade
|
||||||
|
// guard — this test is about the capability, not the cascade.
|
||||||
|
s.server
|
||||||
|
.delete("/api/v1/admin/apps/default?force=true")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
@@ -645,3 +721,389 @@ async fn list_active_owners_drives_the_multi_owner_warning(pool: PgPool) {
|
|||||||
.expect("count");
|
.expect("count");
|
||||||
assert_eq!(remaining, 1, "one other owner should remain (owner2)");
|
assert_eq!(remaining, 1, "one other owner should remain (owner2)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 12. `my_role` on GET /apps/{id_or_slug} reflects the caller's effective role
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn my_role_field_matches_caller_role(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
|
||||||
|
// Owner → implicit app_admin everywhere.
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let r = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/apps/default")
|
||||||
|
.add_header("authorization", format!("Bearer {owner_token}"))
|
||||||
|
.await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
assert_eq!(
|
||||||
|
r.json::<Value>()["my_role"].as_str(),
|
||||||
|
Some("app_admin"),
|
||||||
|
"owner reports app_admin"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Admin → implicit app_admin everywhere (post-§11.6 update).
|
||||||
|
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
|
||||||
|
let admin_token = login_token(&s.server, "alice", "alice-pw").await;
|
||||||
|
let r = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/apps/default")
|
||||||
|
.add_header("authorization", format!("Bearer {admin_token}"))
|
||||||
|
.await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
assert_eq!(
|
||||||
|
r.json::<Value>()["my_role"].as_str(),
|
||||||
|
Some("app_admin"),
|
||||||
|
"admin reports app_admin"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Member with explicit `viewer` membership → viewer.
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
|
||||||
|
let bob_token = login_token(&s.server, "bob", "bob-pw").await;
|
||||||
|
let r = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/apps/default")
|
||||||
|
.add_header("authorization", format!("Bearer {bob_token}"))
|
||||||
|
.await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
assert_eq!(
|
||||||
|
r.json::<Value>()["my_role"].as_str(),
|
||||||
|
Some("viewer"),
|
||||||
|
"member with viewer row reports viewer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 13. App members CRUD — `/api/v1/admin/apps/{id_or_slug}/members[/{user_id}]`
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn list_members_includes_seeded_member(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
|
||||||
|
|
||||||
|
let r = list_members(&s.server, &owner_token, "default").await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
let rows = r.json::<Vec<Value>>();
|
||||||
|
let bob_row = rows
|
||||||
|
.iter()
|
||||||
|
.find(|v| v["username"] == "bob")
|
||||||
|
.expect("bob in list");
|
||||||
|
assert_eq!(bob_row["role"], "viewer");
|
||||||
|
assert_eq!(bob_row["instance_role"], "member");
|
||||||
|
assert_eq!(bob_row["is_active"], true);
|
||||||
|
assert!(bob_row["created_at"].is_string(), "carries created_at");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn list_members_requires_app_admin(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
|
||||||
|
// Bob has explicit editor on default app — enough to read scripts,
|
||||||
|
// not enough to see the member list.
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::Editor).await;
|
||||||
|
let bob_token = login_token(&s.server, "bob", "bob-pw").await;
|
||||||
|
|
||||||
|
let r = list_members(&s.server, &bob_token, "default").await;
|
||||||
|
r.assert_status(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn add_member_creates_row(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
|
||||||
|
let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Viewer).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let body = r.json::<Value>();
|
||||||
|
assert_eq!(body["username"], "bob");
|
||||||
|
assert_eq!(body["role"], "viewer");
|
||||||
|
assert_eq!(body["instance_role"], "member");
|
||||||
|
|
||||||
|
// Visible on subsequent list.
|
||||||
|
let rows = list_members(&s.server, &owner_token, "default")
|
||||||
|
.await
|
||||||
|
.json::<Vec<Value>>();
|
||||||
|
assert!(rows.iter().any(|v| v["username"] == "bob"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn add_member_duplicate_returns_409(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
|
||||||
|
|
||||||
|
let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Editor).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
|
let err = r.json::<Value>()["error"]
|
||||||
|
.as_str()
|
||||||
|
.expect("error message")
|
||||||
|
.to_string();
|
||||||
|
assert!(err.contains("already a member"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn add_member_inactive_user_returns_422(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let bob = seed_inactive_user(&s.pool, "bob", "bob-pw").await;
|
||||||
|
|
||||||
|
let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Viewer).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
let err = r.json::<Value>()["error"]
|
||||||
|
.as_str()
|
||||||
|
.expect("error message")
|
||||||
|
.to_string();
|
||||||
|
assert!(err.contains("deactivated"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn add_member_admin_target_returns_422(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let alice = seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
|
||||||
|
|
||||||
|
let r = add_member(&s.server, &owner_token, "default", alice, AppRole::Viewer).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
let err = r.json::<Value>()["error"]
|
||||||
|
.as_str()
|
||||||
|
.expect("error message")
|
||||||
|
.to_string();
|
||||||
|
assert!(err.contains("implicit access"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn add_member_owner_target_returns_422(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let other_owner = seed_user(&s.pool, "owner2", "ow2-pw", InstanceRole::Owner).await;
|
||||||
|
|
||||||
|
let r = add_member(
|
||||||
|
&s.server,
|
||||||
|
&owner_token,
|
||||||
|
"default",
|
||||||
|
other_owner,
|
||||||
|
AppRole::Viewer,
|
||||||
|
)
|
||||||
|
.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 patch_member_promotes_role(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
|
||||||
|
|
||||||
|
let r = patch_member_role(&s.server, &owner_token, "default", bob, AppRole::Editor).await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
assert_eq!(r.json::<Value>()["role"], "editor");
|
||||||
|
|
||||||
|
// Editor can now create a script (capability promotion observable
|
||||||
|
// end-to-end, not just via the role string).
|
||||||
|
let bob_token = login_token(&s.server, "bob", "bob-pw").await;
|
||||||
|
create_script_via_api(&s.server, &bob_token, s.default_app, "bob-script").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn patch_member_without_existing_returns_404(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
|
||||||
|
// No grant yet — PATCH must 404.
|
||||||
|
let r = patch_member_role(&s.server, &owner_token, "default", bob, AppRole::Editor).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn patch_member_same_role_is_idempotent(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
|
||||||
|
|
||||||
|
let r = patch_member_role(&s.server, &owner_token, "default", bob, AppRole::Viewer).await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
assert_eq!(r.json::<Value>()["role"], "viewer");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn delete_member_removes_row(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
|
||||||
|
|
||||||
|
let r = remove_member(&s.server, &owner_token, "default", bob).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let rows = list_members(&s.server, &owner_token, "default")
|
||||||
|
.await
|
||||||
|
.json::<Vec<Value>>();
|
||||||
|
assert!(rows.iter().all(|v| v["username"] != "bob"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn delete_member_missing_returns_204(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
// No grant ever happened — delete is idempotent.
|
||||||
|
|
||||||
|
let r = remove_member(&s.server, &owner_token, "default", bob).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn mutating_endpoints_require_app_admin(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
|
||||||
|
let bob_token = login_token(&s.server, "bob", "bob-pw").await;
|
||||||
|
|
||||||
|
let target = seed_user(&s.pool, "carol", "carol-pw", InstanceRole::Member).await;
|
||||||
|
|
||||||
|
let r = add_member(&s.server, &bob_token, "default", target, AppRole::Viewer).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
let r = patch_member_role(&s.server, &bob_token, "default", bob, AppRole::Editor).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
let r = remove_member(&s.server, &bob_token, "default", bob).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn members_endpoint_resolves_by_id_or_slug(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
|
||||||
|
let by_slug = list_members(&s.server, &owner_token, "default").await;
|
||||||
|
by_slug.assert_status_ok();
|
||||||
|
let by_id = list_members(&s.server, &owner_token, &s.default_app.to_string()).await;
|
||||||
|
by_id.assert_status_ok();
|
||||||
|
assert_eq!(
|
||||||
|
by_slug.json::<Value>(),
|
||||||
|
by_id.json::<Value>(),
|
||||||
|
"id and slug return identical bodies",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn member_app_admin_can_manage_members(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
|
||||||
|
// Bob is a member with explicit app_admin role on default.
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::AppAdmin).await;
|
||||||
|
let bob_token = login_token(&s.server, "bob", "bob-pw").await;
|
||||||
|
|
||||||
|
// Bob can list members.
|
||||||
|
let r = list_members(&s.server, &bob_token, "default").await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
|
||||||
|
// Bob can add carol as viewer.
|
||||||
|
let carol = seed_user(&s.pool, "carol", "carol-pw", InstanceRole::Member).await;
|
||||||
|
let r = add_member(&s.server, &bob_token, "default", carol, AppRole::Viewer).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
// Bob can promote carol to editor.
|
||||||
|
let r = patch_member_role(&s.server, &bob_token, "default", carol, AppRole::Editor).await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
|
||||||
|
// Bob can remove carol.
|
||||||
|
let r = remove_member(&s.server, &bob_token, "default", carol).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
// And bob can even remove himself — owner's implicit AppAdmin
|
||||||
|
// means the app isn't orphaned. This is the load-bearing test for
|
||||||
|
// the no-last-app-admin-guard decision.
|
||||||
|
let r = remove_member(&s.server, &bob_token, "default", bob).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn membership_makes_app_appear_in_members_app_list(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
let bob_token = login_token(&s.server, "bob", "bob-pw").await;
|
||||||
|
|
||||||
|
// Before grant: bob sees no apps.
|
||||||
|
let r = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/apps")
|
||||||
|
.add_header("authorization", format!("Bearer {bob_token}"))
|
||||||
|
.await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
assert!(
|
||||||
|
r.json::<Vec<Value>>().is_empty(),
|
||||||
|
"bob has no memberships → empty apps list"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grant via the public POST endpoint — exercises the full
|
||||||
|
// round-trip the dashboard goes through, not just the repo seam.
|
||||||
|
let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Viewer).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
// After grant: bob sees the default app.
|
||||||
|
let r = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/apps")
|
||||||
|
.add_header("authorization", format!("Bearer {bob_token}"))
|
||||||
|
.await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
let apps = r.json::<Vec<Value>>();
|
||||||
|
assert_eq!(apps.len(), 1);
|
||||||
|
assert_eq!(apps[0]["slug"], "default");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn add_member_with_missing_user_id_is_rejected(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
|
||||||
|
// Body missing `user_id` — Axum's Json extractor produces a 4xx
|
||||||
|
// before our handler runs. Pinning the status to keep the contract
|
||||||
|
// honest if anyone ever swaps the extractor.
|
||||||
|
let r = s
|
||||||
|
.server
|
||||||
|
.post("/api/v1/admin/apps/default/members")
|
||||||
|
.add_header("authorization", format!("Bearer {owner_token}"))
|
||||||
|
.json(&json!({ "role": "viewer" }))
|
||||||
|
.await;
|
||||||
|
let status = r.status_code().as_u16();
|
||||||
|
assert!(
|
||||||
|
(400..500).contains(&status),
|
||||||
|
"malformed body should produce a 4xx, got {status}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
119
crates/shared/src/events.rs
Normal file
119
crates/shared/src/events.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
//! `ServiceEventEmitter` — the contract every stateful SDK service uses
|
||||||
|
//! to publish events into the (future) triggers framework.
|
||||||
|
//!
|
||||||
|
//! v1.1.0 ships only the trait shape and a `NoopEventEmitter` that
|
||||||
|
//! drops every event. The real outbox-backed implementation lands with
|
||||||
|
//! the triggers PR in v1.1.1; locking the trait now means services
|
||||||
|
//! written in subsequent v1.1.x PRs (KV, docs, files, …) don't have to
|
||||||
|
//! re-thread their plumbing when the dispatcher arrives.
|
||||||
|
//!
|
||||||
|
//! Design rationale (full discussion: `docs/sdk-shape.md`):
|
||||||
|
//! * Async — outbox writes hit Postgres.
|
||||||
|
//! * Cx is passed in so the emitter can attribute the event to the
|
||||||
|
//! `app_id` / `principal` / `execution_id` that produced it.
|
||||||
|
//! * Events carry their semantic identity (`source` + `op`) plus
|
||||||
|
//! optional locator (`collection` + `key`) and optional payloads
|
||||||
|
//! (`payload` for the new value, `old_payload` for the previous on
|
||||||
|
//! updates). The dispatcher matches on (source, op, collection)
|
||||||
|
//! filters to decide which scripts to fan out to.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::SdkCallCx;
|
||||||
|
|
||||||
|
/// Trait every stateful service depends on to emit events. The host
|
||||||
|
/// binary constructs one instance and clones the Arc into each service.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ServiceEventEmitter: Send + Sync {
|
||||||
|
/// Publish a single event. Implementations are expected to be
|
||||||
|
/// fire-and-forget from the caller's perspective: the outbox impl
|
||||||
|
/// will return `Ok(())` once the event is durably persisted, the
|
||||||
|
/// dispatcher reads it out-of-band.
|
||||||
|
async fn emit(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One service event. `source` and `op` are `&'static str` because they
|
||||||
|
/// come from a fixed enumeration baked into each service (`"kv"` +
|
||||||
|
/// `"insert"`/`"update"`/`"delete"`, etc.) — never from user data.
|
||||||
|
/// `collection`/`key`/payloads come from user data and are owned.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ServiceEvent {
|
||||||
|
/// Service namespace. Matches the Rhai module name: `"kv"`,
|
||||||
|
/// `"docs"`, `"files"`, etc.
|
||||||
|
pub source: &'static str,
|
||||||
|
|
||||||
|
/// Operation verb. Each service defines its own vocabulary;
|
||||||
|
/// dispatcher filters match on the literal string.
|
||||||
|
pub op: &'static str,
|
||||||
|
|
||||||
|
/// Affected collection, when the service is collection-scoped
|
||||||
|
/// (`kv`, `docs`, `files`). `None` for collection-less events.
|
||||||
|
pub collection: Option<String>,
|
||||||
|
|
||||||
|
/// Affected key/id within the collection, when applicable.
|
||||||
|
pub key: Option<String>,
|
||||||
|
|
||||||
|
/// New value after the operation, when carrying it is cheap and
|
||||||
|
/// useful. `None` for deletes.
|
||||||
|
pub payload: Option<serde_json::Value>,
|
||||||
|
|
||||||
|
/// Previous value before the operation, populated on `update` /
|
||||||
|
/// `delete` so triggers can diff. `None` on `insert`.
|
||||||
|
pub old_payload: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors an emitter can surface upward. The noop impl never returns
|
||||||
|
/// these; the v1.1.1 outbox impl uses `Unavailable` for pool/connection
|
||||||
|
/// failures and `Rejected` for malformed payloads (e.g. event JSON too
|
||||||
|
/// large for the outbox row).
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum EmitError {
|
||||||
|
#[error("event sink unavailable: {0}")]
|
||||||
|
Unavailable(String),
|
||||||
|
#[error("event sink rejected event: {0}")]
|
||||||
|
Rejected(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default emitter for v1.1.0. Accepts every event, persists nothing,
|
||||||
|
/// always returns `Ok(())`. Wired in the picloud binary; the v1.1.1
|
||||||
|
/// triggers PR swaps this for a Postgres outbox writer.
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct NoopEventEmitter;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ServiceEventEmitter for NoopEventEmitter {
|
||||||
|
async fn emit(&self, _cx: &SdkCallCx, _event: ServiceEvent) -> Result<(), EmitError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// Compile-time check that ServiceEventEmitter is dyn-safe — every
|
||||||
|
// service holds it as `Arc<dyn ServiceEventEmitter>` and would
|
||||||
|
// silently break the workspace if a non-object-safe method snuck
|
||||||
|
// in. Behavioural tests for the noop impl come for free once a
|
||||||
|
// service exercises it (v1.1.1+); avoid pulling tokio into
|
||||||
|
// `picloud-shared` just for a one-line `emit().await` check.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn assert_dyn_compatible(_e: &dyn ServiceEventEmitter) {}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_event_construction_is_explicit() {
|
||||||
|
// Pin the field layout so a re-ordering in a future PR causes a
|
||||||
|
// compile failure here rather than silently misattributing
|
||||||
|
// events. Hash-derive isn't appropriate (serde_json::Value isn't
|
||||||
|
// Hash), so structural construction is the assertion.
|
||||||
|
let _ = ServiceEvent {
|
||||||
|
source: "kv",
|
||||||
|
op: "insert",
|
||||||
|
collection: Some("widgets".into()),
|
||||||
|
key: Some("k1".into()),
|
||||||
|
payload: Some(serde_json::json!({"v": 1})),
|
||||||
|
old_payload: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,23 +7,29 @@
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod events;
|
||||||
pub mod execution_log;
|
pub mod execution_log;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
pub mod log_sink;
|
pub mod log_sink;
|
||||||
pub mod route;
|
pub mod route;
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
pub mod script;
|
pub mod script;
|
||||||
|
pub mod sdk_cx;
|
||||||
|
pub mod services;
|
||||||
pub mod validator;
|
pub mod validator;
|
||||||
pub mod version;
|
pub mod version;
|
||||||
|
|
||||||
pub use app::{App, AppDomain, DomainShape};
|
pub use app::{App, AppDomain, DomainShape};
|
||||||
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
|
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
|
pub use events::{EmitError, NoopEventEmitter, ServiceEvent, ServiceEventEmitter};
|
||||||
pub use execution_log::{ExecutionLog, ExecutionStatus};
|
pub use execution_log::{ExecutionLog, ExecutionStatus};
|
||||||
pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId};
|
pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId};
|
||||||
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
||||||
pub use route::{HostKind, PathKind, Route};
|
pub use route::{HostKind, PathKind, Route};
|
||||||
pub use sandbox::ScriptSandbox;
|
pub use sandbox::ScriptSandbox;
|
||||||
pub use script::Script;
|
pub use script::Script;
|
||||||
|
pub use sdk_cx::SdkCallCx;
|
||||||
|
pub use services::Services;
|
||||||
pub use validator::{ScriptValidator, ValidationError};
|
pub use validator::{ScriptValidator, ValidationError};
|
||||||
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};
|
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};
|
||||||
|
|||||||
54
crates/shared/src/sdk_cx.rs
Normal file
54
crates/shared/src/sdk_cx.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
//! `SdkCallCx` — per-call context every stateful SDK service receives.
|
||||||
|
//!
|
||||||
|
//! Service trait methods (added by subsequent v1.1.x PRs starting with
|
||||||
|
//! KV) all take `&SdkCallCx` so they can:
|
||||||
|
//! * scope by `app_id` for cross-app isolation,
|
||||||
|
//! * audit `principal` when authenticated,
|
||||||
|
//! * carry `execution_id` / `request_id` into emitted events,
|
||||||
|
//! * bound trigger chains via `trigger_depth` / `root_execution_id`.
|
||||||
|
//!
|
||||||
|
//! The struct lives in `picloud-shared` (not `executor-core`) because
|
||||||
|
//! future service impls live in `manager-core` and the trait that hands
|
||||||
|
//! the cx in is shared by both sides. Pure value type — no handles, no
|
||||||
|
//! DB pool references, no allocations beyond what's in `Principal`.
|
||||||
|
|
||||||
|
use crate::{AppId, ExecutionId, Principal, RequestId};
|
||||||
|
|
||||||
|
/// Per-invocation context for every stateful SDK service call.
|
||||||
|
///
|
||||||
|
/// Constructed once at the start of an invocation by `executor-core`
|
||||||
|
/// from the incoming `ExecRequest`, then handed (by reference) to every
|
||||||
|
/// service trait method the script triggers during execution. Services
|
||||||
|
/// MUST derive `app_id` from this struct — never from script-passed
|
||||||
|
/// arguments — to preserve cross-app isolation.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SdkCallCx {
|
||||||
|
/// Owning application for this invocation. Source of truth for
|
||||||
|
/// every `(app_id, …)` storage lookup the script makes.
|
||||||
|
pub app_id: AppId,
|
||||||
|
|
||||||
|
/// Caller identity, when authenticated. `None` for unauthenticated
|
||||||
|
/// data-plane HTTP requests (the common case for public endpoints);
|
||||||
|
/// `Some` when the call came in via the dashboard, an API key, or a
|
||||||
|
/// future authed surface.
|
||||||
|
pub principal: Option<Principal>,
|
||||||
|
|
||||||
|
/// Unique id for THIS execution. Matches `ExecRequest.execution_id`.
|
||||||
|
pub execution_id: ExecutionId,
|
||||||
|
|
||||||
|
/// Unique id for the ingress request that started the chain. The
|
||||||
|
/// same `request_id` is shared across every execution triggered by
|
||||||
|
/// the same request (direct + trigger fan-out).
|
||||||
|
pub request_id: RequestId,
|
||||||
|
|
||||||
|
/// `0` for direct invocations (HTTP request, manual run). Each
|
||||||
|
/// indirect invocation through the triggers framework (v1.1.1)
|
||||||
|
/// increments this; the dispatcher rejects beyond a configured
|
||||||
|
/// ceiling to prevent runaway feedback loops.
|
||||||
|
pub trigger_depth: u32,
|
||||||
|
|
||||||
|
/// `== execution_id` when `trigger_depth == 0`; otherwise the
|
||||||
|
/// `execution_id` of the original ingress execution. Lets the audit
|
||||||
|
/// log group every fan-out execution under the originating event.
|
||||||
|
pub root_execution_id: ExecutionId,
|
||||||
|
}
|
||||||
38
crates/shared/src/services.rs
Normal file
38
crates/shared/src/services.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//! `Services` — bundle of stateful SDK service handles plumbed from the
|
||||||
|
//! host binary into every Rhai execution.
|
||||||
|
//!
|
||||||
|
//! v1.1.0 ships this struct empty. Subsequent PRs in the v1.1.x series
|
||||||
|
//! add one field per service:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! pub kv: Arc<dyn KvService>, // v1.1.1
|
||||||
|
//! pub docs: Arc<dyn DocsService>, // v1.1.2
|
||||||
|
//! pub http: Arc<dyn HttpService>, // v1.1.4
|
||||||
|
//! // …
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The bundle is cheap to clone (`Arc` per service) and is constructed
|
||||||
|
//! once at startup in the picloud binary. The executor takes it by
|
||||||
|
//! reference per invocation, hands it (alongside an `SdkCallCx`) to
|
||||||
|
//! `executor-core::sdk::register_all`, which wires the corresponding
|
||||||
|
//! Rhai `::` namespace per service.
|
||||||
|
//!
|
||||||
|
//! `#[non_exhaustive]` so adding fields is a non-breaking change for
|
||||||
|
//! consumers that only *pattern-match* a `&Services`; only crates that
|
||||||
|
//! *construct* a `Services` (in practice, just the picloud binary) need
|
||||||
|
//! to update their constructor when new services land.
|
||||||
|
|
||||||
|
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
|
||||||
|
/// expansion plan.
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Services {}
|
||||||
|
|
||||||
|
impl Services {
|
||||||
|
/// Construct an empty bundle. Replaced by a fielded `::new(...)`
|
||||||
|
/// once the first service (KV, v1.1.1) lands.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,3 +2,9 @@
|
|||||||
build
|
build
|
||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
|
# Playwright generated artifacts
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
tests/e2e/.auth
|
||||||
|
tests/e2e/.results
|
||||||
|
|||||||
64
dashboard/package-lock.json
generated
64
dashboard/package-lock.json
generated
@@ -20,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/kit": "^2.17.0",
|
"@sveltejs/kit": "^2.17.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
@@ -885,6 +886,22 @@
|
|||||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@polka/url": {
|
"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",
|
||||||
@@ -3010,6 +3027,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
|
|||||||
@@ -11,10 +11,14 @@
|
|||||||
"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"
|
"test": "vitest run",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:e2e:install": "playwright install --with-deps chromium"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/kit": "^2.17.0",
|
"@sveltejs/kit": "^2.17.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
|
|||||||
51
dashboard/playwright.config.ts
Normal file
51
dashboard/playwright.config.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const DASHBOARD_PORT = Number(process.env.PICLOUD_DASHBOARD_PORT ?? 5173);
|
||||||
|
// baseURL is the origin only — the SvelteKit dashboard is mounted at
|
||||||
|
// `/admin` (svelte.config.js paths.base), so tests use full paths like
|
||||||
|
// `/admin/login` rather than relying on baseURL path resolution.
|
||||||
|
const DASHBOARD_BASE = process.env.E2E_BASE_URL ?? `http://localhost:${DASHBOARD_PORT}`;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
outputDir: './tests/e2e/.results',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
// Local: 1 retry to absorb dev-server warmup flakiness. CI: 2.
|
||||||
|
retries: process.env.CI ? 2 : 1,
|
||||||
|
// Cap at 4 workers locally to keep the shared Vite dev server
|
||||||
|
// from getting stampeded during cold-start compiles.
|
||||||
|
workers: process.env.CI ? 2 : 4,
|
||||||
|
reporter: process.env.CI ? [['html'], ['github']] : 'html',
|
||||||
|
globalSetup: './tests/e2e/global-setup.ts',
|
||||||
|
expect: { timeout: 5_000 },
|
||||||
|
use: {
|
||||||
|
baseURL: DASHBOARD_BASE,
|
||||||
|
actionTimeout: 10_000,
|
||||||
|
navigationTimeout: 30_000,
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'retain-on-failure'
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
storageState: path.join(__dirname, 'tests/e2e/.auth/admin.json')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
url: `http://localhost:${DASHBOARD_PORT}/admin/`,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
timeout: 60_000
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -25,12 +25,18 @@
|
|||||||
value = $bindable(''),
|
value = $bindable(''),
|
||||||
language = 'rhai' as Language,
|
language = 'rhai' as Language,
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
minHeight = '12rem'
|
minHeight = '12rem',
|
||||||
|
readOnly = false
|
||||||
}: {
|
}: {
|
||||||
value?: string;
|
value?: string;
|
||||||
language?: Language;
|
language?: Language;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
minHeight?: string;
|
minHeight?: string;
|
||||||
|
/** When true the editor renders without a cursor and rejects
|
||||||
|
* keystrokes. Parent-driven `value` changes still apply via
|
||||||
|
* the dispatch path below — this only blocks user edits.
|
||||||
|
* Not reactive after mount; re-mount via `{#key}` if needed. */
|
||||||
|
readOnly?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let host: HTMLDivElement | null = null;
|
let host: HTMLDivElement | null = null;
|
||||||
@@ -48,6 +54,12 @@
|
|||||||
keymap.of([indentWithTab]),
|
keymap.of([indentWithTab]),
|
||||||
dashboardSyntaxHighlighting,
|
dashboardSyntaxHighlighting,
|
||||||
dashboardTheme,
|
dashboardTheme,
|
||||||
|
// readOnly + editable together: readOnly blocks the
|
||||||
|
// underlying transactions, editable suppresses the caret
|
||||||
|
// + selection visuals so the user can see it's not
|
||||||
|
// editable.
|
||||||
|
EditorState.readOnly.of(readOnly),
|
||||||
|
EditorView.editable.of(!readOnly),
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged && !pushingFromOutside) {
|
if (update.docChanged && !pushingFromOutside) {
|
||||||
value = update.state.doc.toString();
|
value = update.state.doc.toString();
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { InstanceRole } from '$lib/auth';
|
import type { InstanceRole } from '$lib/auth';
|
||||||
|
import type { AppRole } from '$lib/api';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
role: InstanceRole;
|
role?: InstanceRole;
|
||||||
|
appRole?: AppRole;
|
||||||
size?: 'sm' | 'md';
|
size?: 'sm' | 'md';
|
||||||
}
|
}
|
||||||
|
|
||||||
let { role, size = 'md' }: Props = $props();
|
let { role, appRole, size = 'md' }: Props = $props();
|
||||||
|
|
||||||
|
// Display label: app roles read better with a space ("app admin")
|
||||||
|
// than their wire form ("app_admin").
|
||||||
|
const label = $derived(
|
||||||
|
appRole ? appRole.replace('_', ' ') : (role ?? '')
|
||||||
|
);
|
||||||
|
const cls = $derived(appRole ? `chip-${appRole}` : `chip-${role}`);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span class="chip chip-{role}" class:sm={size === 'sm'}>{role}</span>
|
<span class="chip {cls}" class:sm={size === 'sm'}>{label}</span>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.chip {
|
.chip {
|
||||||
@@ -42,4 +51,19 @@
|
|||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
border-color: #334155;
|
border-color: #334155;
|
||||||
}
|
}
|
||||||
|
.chip-app_admin {
|
||||||
|
background: #4c1d95;
|
||||||
|
color: #c4b5fd;
|
||||||
|
border-color: #6d28d9;
|
||||||
|
}
|
||||||
|
.chip-editor {
|
||||||
|
background: #1e3a8a;
|
||||||
|
color: #93c5fd;
|
||||||
|
border-color: #1d4ed8;
|
||||||
|
}
|
||||||
|
.chip-viewer {
|
||||||
|
background: #1f2937;
|
||||||
|
color: #9ca3af;
|
||||||
|
border-color: #374151;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export interface App {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AppRole = 'app_admin' | 'editor' | 'viewer';
|
||||||
|
|
||||||
export type DomainShape = 'exact' | 'wildcard' | 'parameterized';
|
export type DomainShape = 'exact' | 'wildcard' | 'parameterized';
|
||||||
|
|
||||||
export interface AppDomain {
|
export interface AppDomain {
|
||||||
@@ -64,6 +66,11 @@ export interface AppLookupResponse {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
/// Present only when the requested slug was a retired redirect.
|
/// Present only when the requested slug was a retired redirect.
|
||||||
redirect_to?: string;
|
redirect_to?: string;
|
||||||
|
/// The caller's role on this app — owners are implicit `app_admin`,
|
||||||
|
/// admins implicit `editor`, members carry their `app_members.role`.
|
||||||
|
/// `null` only when a member somehow reaches the endpoint without
|
||||||
|
/// a membership (the server normally 403s first).
|
||||||
|
my_role: AppRole | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SlugCheckResponse {
|
export interface SlugCheckResponse {
|
||||||
@@ -289,6 +296,21 @@ export interface PatchAdminInput {
|
|||||||
email?: string | null;
|
email?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AppMemberDto {
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
instance_role: InstanceRole;
|
||||||
|
is_active: boolean;
|
||||||
|
role: AppRole;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrantAppMemberInput {
|
||||||
|
user_id: string;
|
||||||
|
role: AppRole;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiKeyDto {
|
export interface ApiKeyDto {
|
||||||
id: string;
|
id: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
@@ -472,6 +494,28 @@ export const api = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
appMembers: {
|
||||||
|
list: (idOrSlug: string) =>
|
||||||
|
adminRequest<AppMemberDto[]>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members`
|
||||||
|
),
|
||||||
|
add: (idOrSlug: string, input: GrantAppMemberInput) =>
|
||||||
|
adminRequest<AppMemberDto>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members`,
|
||||||
|
{ method: 'POST', body: JSON.stringify(input) }
|
||||||
|
),
|
||||||
|
setRole: (idOrSlug: string, userId: string, role: AppRole) =>
|
||||||
|
adminRequest<AppMemberDto>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members/${userId}`,
|
||||||
|
{ method: 'PATCH', body: JSON.stringify({ role }) }
|
||||||
|
),
|
||||||
|
remove: (idOrSlug: string, userId: string) =>
|
||||||
|
adminRequest<null>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members/${userId}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
execute: async (
|
execute: async (
|
||||||
id: string,
|
id: string,
|
||||||
body: unknown,
|
body: unknown,
|
||||||
|
|||||||
60
dashboard/src/lib/capabilities.test.ts
Normal file
60
dashboard/src/lib/capabilities.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { AppRole, MeDto } from './api';
|
||||||
|
import { canAdminApp, canCreateApp, canManageUsers, canWriteApp } from './capabilities';
|
||||||
|
|
||||||
|
function me(role: MeDto['instance_role']): MeDto {
|
||||||
|
return { id: 'u', username: 'u', instance_role: role, email: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLES: MeDto['instance_role'][] = ['owner', 'admin', 'member'];
|
||||||
|
const APP_ROLES: (AppRole | null)[] = ['app_admin', 'editor', 'viewer', null];
|
||||||
|
|
||||||
|
describe('capabilities', () => {
|
||||||
|
it('null caller is denied everything', () => {
|
||||||
|
expect(canCreateApp(null)).toBe(false);
|
||||||
|
expect(canManageUsers(null)).toBe(false);
|
||||||
|
expect(canWriteApp(null, 'app_admin')).toBe(false);
|
||||||
|
expect(canAdminApp(null, 'app_admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canCreateApp + canManageUsers: owner/admin yes, member no', () => {
|
||||||
|
expect(canCreateApp(me('owner'))).toBe(true);
|
||||||
|
expect(canCreateApp(me('admin'))).toBe(true);
|
||||||
|
expect(canCreateApp(me('member'))).toBe(false);
|
||||||
|
expect(canManageUsers(me('owner'))).toBe(true);
|
||||||
|
expect(canManageUsers(me('admin'))).toBe(true);
|
||||||
|
expect(canManageUsers(me('member'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('owner + admin can write and admin every app regardless of my_role', () => {
|
||||||
|
for (const role of ['owner', 'admin'] as const) {
|
||||||
|
for (const appRole of APP_ROLES) {
|
||||||
|
expect(canWriteApp(me(role), appRole)).toBe(true);
|
||||||
|
expect(canAdminApp(me(role), appRole)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('member: write requires app_admin or editor; admin requires app_admin', () => {
|
||||||
|
const m = me('member');
|
||||||
|
expect(canWriteApp(m, 'app_admin')).toBe(true);
|
||||||
|
expect(canWriteApp(m, 'editor')).toBe(true);
|
||||||
|
expect(canWriteApp(m, 'viewer')).toBe(false);
|
||||||
|
expect(canWriteApp(m, null)).toBe(false);
|
||||||
|
|
||||||
|
expect(canAdminApp(m, 'app_admin')).toBe(true);
|
||||||
|
expect(canAdminApp(m, 'editor')).toBe(false);
|
||||||
|
expect(canAdminApp(m, 'viewer')).toBe(false);
|
||||||
|
expect(canAdminApp(m, null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canAdminApp implies canWriteApp for every combination', () => {
|
||||||
|
for (const role of ROLES) {
|
||||||
|
for (const appRole of APP_ROLES) {
|
||||||
|
if (canAdminApp(me(role), appRole)) {
|
||||||
|
expect(canWriteApp(me(role), appRole)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
43
dashboard/src/lib/capabilities.ts
Normal file
43
dashboard/src/lib/capabilities.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Permission predicates the dashboard uses to shadow create / edit /
|
||||||
|
// delete affordances. Mirrors the canonical role → capability rules in
|
||||||
|
// crates/manager-core/src/authz.rs:
|
||||||
|
//
|
||||||
|
// owner / admin instance role → implicit app_admin on every app
|
||||||
|
// app_admin → settings, domain claims, delete app, delete scripts
|
||||||
|
// editor → CRUD on scripts, routes, sandbox config (no script delete)
|
||||||
|
// viewer → read scripts + execution logs
|
||||||
|
// member with no membership → no access
|
||||||
|
//
|
||||||
|
// These helpers are read-only and have no Svelte runes — callers pass
|
||||||
|
// the current `MeDto` and (when relevant) the per-app `my_role` they
|
||||||
|
// already hold. Hiding here never authorizes anything; the backend's
|
||||||
|
// `require(Capability::…)` is always the ground truth.
|
||||||
|
|
||||||
|
import type { AppRole, MeDto } from './api';
|
||||||
|
|
||||||
|
/** Owner + admin only. Members never see "New app". */
|
||||||
|
export function canCreateApp(me: MeDto | null): boolean {
|
||||||
|
if (!me) return false;
|
||||||
|
return me.instance_role === 'owner' || me.instance_role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owner + admin only — the "Users" admin page is also gated this way. */
|
||||||
|
export function canManageUsers(me: MeDto | null): boolean {
|
||||||
|
if (!me) return false;
|
||||||
|
return me.instance_role === 'owner' || me.instance_role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Can mutate scripts and routes (Save, +Add route, remove route). */
|
||||||
|
export function canWriteApp(me: MeDto | null, appMyRole: AppRole | null): boolean {
|
||||||
|
if (!me) return false;
|
||||||
|
if (me.instance_role === 'owner' || me.instance_role === 'admin') return true;
|
||||||
|
return appMyRole === 'app_admin' || appMyRole === 'editor';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Can take app-admin actions: app settings, domain claims, delete
|
||||||
|
* app, delete scripts, manage members. */
|
||||||
|
export function canAdminApp(me: MeDto | null, appMyRole: AppRole | null): boolean {
|
||||||
|
if (!me) return false;
|
||||||
|
if (me.instance_role === 'owner' || me.instance_role === 'admin') return true;
|
||||||
|
return appMyRole === 'app_admin';
|
||||||
|
}
|
||||||
54
dashboard/src/lib/password-gen.test.ts
Normal file
54
dashboard/src/lib/password-gen.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { generatePassword } from './password-gen';
|
||||||
|
|
||||||
|
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
|
||||||
|
|
||||||
|
describe('generatePassword', () => {
|
||||||
|
it('rejects lengths under 8', () => {
|
||||||
|
expect(() => generatePassword(7)).toThrowError(/at least 8/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects the requested length', () => {
|
||||||
|
for (const len of [8, 16, 32, 64]) {
|
||||||
|
expect(generatePassword(len)).toHaveLength(len);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses only characters from the documented charset', () => {
|
||||||
|
const set = new Set(CHARSET);
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
for (const c of generatePassword(32)) {
|
||||||
|
expect(set.has(c)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rejection-sampling sanity. With N = 71 the expected count per
|
||||||
|
// char over 100k samples is ~1408 (σ ≈ 37). A 6σ band catches
|
||||||
|
// any byte-level bias (biased modulo would push the first 38
|
||||||
|
// chars by ~16 ppm — too small for this band to flag on its
|
||||||
|
// own, but a regression to `% N` over Uint16/Uint32 with a
|
||||||
|
// non-power-of-two charset would still produce visible drift in
|
||||||
|
// pathological codepaths). Mostly this guards against
|
||||||
|
// fundamental mistakes (off-by-one in the loop, returning the
|
||||||
|
// same byte stream every time, etc.).
|
||||||
|
it('distribution stays within a wide tolerance band', () => {
|
||||||
|
const samples = 100_000;
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (let i = 0; i < samples; i++) {
|
||||||
|
const c = generatePassword(8)[0];
|
||||||
|
counts.set(c, (counts.get(c) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const expected = samples / CHARSET.length;
|
||||||
|
const sigma = Math.sqrt(expected);
|
||||||
|
const band = 6 * sigma;
|
||||||
|
for (const c of CHARSET) {
|
||||||
|
const observed = counts.get(c) ?? 0;
|
||||||
|
const drift = Math.abs(observed - expected);
|
||||||
|
expect(
|
||||||
|
drift,
|
||||||
|
`char "${c}": observed ${observed}, expected ~${Math.round(expected)} (drift ${drift.toFixed(0)} > ${band.toFixed(0)})`
|
||||||
|
).toBeLessThan(band);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,11 @@
|
|||||||
// entropy at 16 chars (~95 bits) to be uncopyable by hand mistakes,
|
// entropy at 16 chars (~95 bits) to be uncopyable by hand mistakes,
|
||||||
// avoidant of characters that ship awkwardly through chat clients
|
// avoidant of characters that ship awkwardly through chat clients
|
||||||
// (no quotes, slashes, or backticks).
|
// (no quotes, slashes, or backticks).
|
||||||
|
//
|
||||||
|
// Sampling: rejection sampling against a Uint8 stream. The naive
|
||||||
|
// `byte % CHARSET.length` would slightly overweight the first
|
||||||
|
// (256 mod N) chars; with N = 71 that's ~16 ppm of bias which is
|
||||||
|
// safe at 16 chars but easy to remove.
|
||||||
|
|
||||||
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
|
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
|
||||||
|
|
||||||
@@ -15,11 +20,18 @@ export function generatePassword(length = 16): string {
|
|||||||
if (length < 8) {
|
if (length < 8) {
|
||||||
throw new Error('password length must be at least 8');
|
throw new Error('password length must be at least 8');
|
||||||
}
|
}
|
||||||
const buf = new Uint32Array(length);
|
const n = CHARSET.length;
|
||||||
crypto.getRandomValues(buf);
|
// Largest multiple of `n` that fits in a Uint8 — bytes ≥ MAX get
|
||||||
|
// rejected to remove modulo bias.
|
||||||
|
const max = 256 - (256 % n);
|
||||||
|
const buf = new Uint8Array(length);
|
||||||
let out = '';
|
let out = '';
|
||||||
for (let i = 0; i < length; i++) {
|
while (out.length < length) {
|
||||||
out += CHARSET[buf[i] % CHARSET.length];
|
crypto.getRandomValues(buf);
|
||||||
|
for (let i = 0; i < buf.length && out.length < length; i++) {
|
||||||
|
const byte = buf[i];
|
||||||
|
if (byte < max) out += CHARSET[byte % n];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { api, ApiError, type App } from '$lib/api';
|
import { api, ApiError, type App } from '$lib/api';
|
||||||
import { slugify, SLUG_MAX } from '$lib/slugify';
|
import { slugify, SLUG_MAX } from '$lib/slugify';
|
||||||
|
import { canCreateApp } from '$lib/capabilities';
|
||||||
|
import { currentUser } from '$lib/auth';
|
||||||
|
|
||||||
|
const me = $derived($currentUser);
|
||||||
|
const canCreate = $derived(canCreateApp(me));
|
||||||
|
|
||||||
let apps = $state<App[] | null>(null);
|
let apps = $state<App[] | null>(null);
|
||||||
let listError = $state<string | null>(null);
|
let listError = $state<string | null>(null);
|
||||||
@@ -99,6 +104,7 @@
|
|||||||
<section>
|
<section>
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1>Apps</h1>
|
<h1>Apps</h1>
|
||||||
|
{#if canCreate}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
@@ -108,9 +114,10 @@
|
|||||||
>
|
>
|
||||||
{showCreate ? 'Cancel' : 'New app'}
|
{showCreate ? 'Cancel' : 'New app'}
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if showCreate}
|
{#if showCreate && canCreate}
|
||||||
<form class="create-form" onsubmit={(e) => submitCreate(e)}>
|
<form class="create-form" onsubmit={(e) => submitCreate(e)}>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
@@ -5,26 +5,44 @@
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
ApiError,
|
ApiError,
|
||||||
|
type AdminDto,
|
||||||
type App,
|
type App,
|
||||||
type AppDomain,
|
type AppDomain,
|
||||||
|
type AppMemberDto,
|
||||||
|
type AppRole,
|
||||||
type Script
|
type Script
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
import CodeEditor from '$lib/CodeEditor.svelte';
|
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||||
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||||
|
import ActionMenu from '$lib/ActionMenu.svelte';
|
||||||
|
import RoleChip from '$lib/RoleChip.svelte';
|
||||||
|
import { currentUser } from '$lib/auth';
|
||||||
|
import { canAdminApp, canWriteApp } from '$lib/capabilities';
|
||||||
|
|
||||||
|
const me = $derived($currentUser);
|
||||||
|
|
||||||
const SAMPLE_SOURCE =
|
const SAMPLE_SOURCE =
|
||||||
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
||||||
|
|
||||||
type Tab = 'scripts' | 'domains' | 'settings';
|
type Tab = 'scripts' | 'domains' | 'members' | 'settings';
|
||||||
|
|
||||||
let slug = $derived(page.params.slug ?? '');
|
let slug = $derived(page.params.slug ?? '');
|
||||||
let app = $state<App | null>(null);
|
let app = $state<App | null>(null);
|
||||||
|
let myRole = $state<AppRole | null>(null);
|
||||||
let loadError = $state<string | null>(null);
|
let loadError = $state<string | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let activeTab = $state<Tab>('scripts');
|
let activeTab = $state<Tab>('scripts');
|
||||||
|
|
||||||
let scripts = $state<Script[]>([]);
|
let scripts = $state<Script[]>([]);
|
||||||
let domains = $state<AppDomain[]>([]);
|
let domains = $state<AppDomain[]>([]);
|
||||||
|
let members = $state<AppMemberDto[]>([]);
|
||||||
|
|
||||||
|
// Derive UI gates from the capabilities helper so the rules stay
|
||||||
|
// in lockstep with the backend's `can()`. canAdminApp also covers
|
||||||
|
// the Members + Settings + Domains-mutation tabs; canWriteApp
|
||||||
|
// covers New script.
|
||||||
|
const canWrite = $derived(canWriteApp(me, myRole));
|
||||||
|
const canAdmin = $derived(canAdminApp(me, myRole));
|
||||||
|
|
||||||
// Script create
|
// Script create
|
||||||
let showCreateScript = $state(false);
|
let showCreateScript = $state(false);
|
||||||
@@ -55,6 +73,19 @@
|
|||||||
let removingDomain = $state(false);
|
let removingDomain = $state(false);
|
||||||
let removeDomainError = $state<string | null>(null);
|
let removeDomainError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Members tab
|
||||||
|
let eligibleUsers = $state<AdminDto[]>([]);
|
||||||
|
let eligibleLoadError = $state<string | null>(null);
|
||||||
|
let addMemberUserId = $state('');
|
||||||
|
let addMemberRole = $state<AppRole>('viewer');
|
||||||
|
let addingMember = $state(false);
|
||||||
|
let addMemberError = $state<string | null>(null);
|
||||||
|
let memberToRemove = $state<AppMemberDto | null>(null);
|
||||||
|
let removingMember = $state(false);
|
||||||
|
let removeMemberError = $state<string | null>(null);
|
||||||
|
let roleChangeBusy = $state<string | null>(null);
|
||||||
|
let memberActionError = $state<string | null>(null);
|
||||||
|
|
||||||
async function loadApp() {
|
async function loadApp() {
|
||||||
loading = true;
|
loading = true;
|
||||||
loadError = null;
|
loadError = null;
|
||||||
@@ -72,10 +103,15 @@
|
|||||||
created_at: fetched.created_at,
|
created_at: fetched.created_at,
|
||||||
updated_at: fetched.updated_at
|
updated_at: fetched.updated_at
|
||||||
};
|
};
|
||||||
|
myRole = fetched.my_role;
|
||||||
editName = app.name;
|
editName = app.name;
|
||||||
editDescription = app.description ?? '';
|
editDescription = app.description ?? '';
|
||||||
editSlug = app.slug;
|
editSlug = app.slug;
|
||||||
await Promise.all([loadScripts(app.id), loadDomains(app.id)]);
|
const loaders: Promise<unknown>[] = [loadScripts(app.id), loadDomains(app.id)];
|
||||||
|
if (canAdmin) {
|
||||||
|
loaders.push(loadMembers(app.id), loadEligibleUsers());
|
||||||
|
}
|
||||||
|
await Promise.all(loaders);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError = e instanceof Error ? e.message : String(e);
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -101,6 +137,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMembers(appId: string) {
|
||||||
|
try {
|
||||||
|
members = await api.appMembers.list(appId);
|
||||||
|
} catch (e) {
|
||||||
|
members = [];
|
||||||
|
memberActionError = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEligibleUsers() {
|
||||||
|
eligibleLoadError = null;
|
||||||
|
try {
|
||||||
|
const all = await api.admins.list();
|
||||||
|
// Only inactive=false members are valid invite targets — the
|
||||||
|
// API rejects everyone else anyway, so filter upfront.
|
||||||
|
eligibleUsers = all.filter(
|
||||||
|
(u) => u.is_active && u.instance_role === 'member'
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
eligibleUsers = [];
|
||||||
|
// member-with-app_admin can hit /apps/.../members but cannot
|
||||||
|
// browse /admins (gated on InstanceManageUsers). The add form
|
||||||
|
// will render disabled with the explanatory message below.
|
||||||
|
eligibleLoadError =
|
||||||
|
e instanceof ApiError && e.status === 403
|
||||||
|
? 'Only instance owners/admins can browse the user directory to invite new members.'
|
||||||
|
: e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligibleAfterFilter = $derived(
|
||||||
|
eligibleUsers.filter((u) => !members.some((m) => m.user_id === u.id))
|
||||||
|
);
|
||||||
|
|
||||||
async function submitCreateScript(event: Event) {
|
async function submitCreateScript(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
@@ -201,6 +273,76 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitAddMember(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!app || !addMemberUserId) return;
|
||||||
|
addingMember = true;
|
||||||
|
addMemberError = null;
|
||||||
|
try {
|
||||||
|
await api.appMembers.add(app.id, {
|
||||||
|
user_id: addMemberUserId,
|
||||||
|
role: addMemberRole
|
||||||
|
});
|
||||||
|
addMemberUserId = '';
|
||||||
|
addMemberRole = 'viewer';
|
||||||
|
await loadMembers(app.id);
|
||||||
|
} catch (e) {
|
||||||
|
addMemberError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
addingMember = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeMemberRole(member: AppMemberDto, role: AppRole) {
|
||||||
|
if (!app || member.role === role) return;
|
||||||
|
roleChangeBusy = member.user_id;
|
||||||
|
memberActionError = null;
|
||||||
|
try {
|
||||||
|
await api.appMembers.setRole(app.id, member.user_id, role);
|
||||||
|
await loadMembers(app.id);
|
||||||
|
} catch (e) {
|
||||||
|
memberActionError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
roleChangeBusy = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveMember(member: AppMemberDto) {
|
||||||
|
removeMemberError = null;
|
||||||
|
memberToRemove = member;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRemoveMember() {
|
||||||
|
if (!app || !memberToRemove) return;
|
||||||
|
removingMember = true;
|
||||||
|
removeMemberError = null;
|
||||||
|
try {
|
||||||
|
const removedSelf = !!me && memberToRemove.user_id === me.id;
|
||||||
|
await api.appMembers.remove(app.id, memberToRemove.user_id);
|
||||||
|
memberToRemove = null;
|
||||||
|
if (removedSelf) {
|
||||||
|
// We just revoked our own access to this app; the next
|
||||||
|
// fetch of /apps/{slug} would 403. Bounce back to the
|
||||||
|
// apps list rather than render a broken tab.
|
||||||
|
await goto(`${base}/apps`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadMembers(app.id);
|
||||||
|
} catch (e) {
|
||||||
|
removeMemberError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
removingMember = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function askDeleteApp() {
|
function askDeleteApp() {
|
||||||
deleteAppError = null;
|
deleteAppError = null;
|
||||||
confirmingDeleteApp = true;
|
confirmingDeleteApp = true;
|
||||||
@@ -226,6 +368,16 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
void loadApp();
|
void loadApp();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Defense-in-depth: a viewer / editor following a stale link to
|
||||||
|
// the Settings or Members tab gets bounced back to Scripts. The
|
||||||
|
// backend still 403s the underlying calls, but no point showing an
|
||||||
|
// empty tab.
|
||||||
|
$effect(() => {
|
||||||
|
if (!canAdmin && (activeTab === 'settings' || activeTab === 'members')) {
|
||||||
|
activeTab = 'scripts';
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if loading && !app}
|
{#if loading && !app}
|
||||||
@@ -258,26 +410,35 @@
|
|||||||
class:active={activeTab === 'domains'}
|
class:active={activeTab === 'domains'}
|
||||||
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
|
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
|
||||||
>
|
>
|
||||||
|
{#if canAdmin}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={activeTab === 'members'}
|
||||||
|
onclick={() => (activeTab = 'members')}>Members ({members.length})</button
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:active={activeTab === 'settings'}
|
class:active={activeTab === 'settings'}
|
||||||
onclick={() => (activeTab = 'settings')}>Settings</button
|
onclick={() => (activeTab = 'settings')}>Settings</button
|
||||||
>
|
>
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{#if activeTab === 'scripts'}
|
{#if activeTab === 'scripts'}
|
||||||
<section>
|
<section>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<h2>Scripts</h2>
|
<h2>Scripts</h2>
|
||||||
|
{#if canWrite}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (showCreateScript = !showCreateScript)}
|
onclick={() => (showCreateScript = !showCreateScript)}
|
||||||
>
|
>
|
||||||
{showCreateScript ? 'Cancel' : 'New script'}
|
{showCreateScript ? 'Cancel' : 'New script'}
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showCreateScript}
|
{#if showCreateScript && canWrite}
|
||||||
<form class="create-form" onsubmit={submitCreateScript}>
|
<form class="create-form" onsubmit={submitCreateScript}>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>
|
<label>
|
||||||
@@ -330,6 +491,7 @@
|
|||||||
these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for
|
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.
|
wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture.
|
||||||
</p>
|
</p>
|
||||||
|
{#if canAdmin}
|
||||||
<form class="create-form inline" onsubmit={submitCreateDomain}>
|
<form class="create-form inline" onsubmit={submitCreateDomain}>
|
||||||
<input
|
<input
|
||||||
bind:value={createDomainPattern}
|
bind:value={createDomainPattern}
|
||||||
@@ -343,6 +505,7 @@
|
|||||||
{#if createDomainError}
|
{#if createDomainError}
|
||||||
<div class="error">{createDomainError}</div>
|
<div class="error">{createDomainError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
{#if domains.length === 0}
|
{#if domains.length === 0}
|
||||||
<p class="muted">No domain claims yet.</p>
|
<p class="muted">No domain claims yet.</p>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -353,6 +516,7 @@
|
|||||||
<code>{d.pattern}</code>
|
<code>{d.pattern}</code>
|
||||||
<span class="muted">— {d.shape}</span>
|
<span class="muted">— {d.shape}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{#if canAdmin}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="secondary danger"
|
class="secondary danger"
|
||||||
@@ -360,12 +524,128 @@
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{:else if activeTab === 'settings'}
|
{:else if activeTab === 'members' && canAdmin}
|
||||||
|
<section>
|
||||||
|
<h2>Members</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Users with explicit access to this app. Instance owners and admins
|
||||||
|
already have implicit access — they are not listed here. Use the Users
|
||||||
|
page to invite a <code>member</code> first, then grant them app access
|
||||||
|
below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form class="create-form" onsubmit={submitAddMember}>
|
||||||
|
<div class="row">
|
||||||
|
<label class="grow">
|
||||||
|
<span>User</span>
|
||||||
|
<select
|
||||||
|
bind:value={addMemberUserId}
|
||||||
|
disabled={!!eligibleLoadError || eligibleAfterFilter.length === 0}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="" disabled>Pick a member to invite…</option>
|
||||||
|
{#each eligibleAfterFilter as u (u.id)}
|
||||||
|
<option value={u.id}>{u.username}{u.email ? ` (${u.email})` : ''}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Role</span>
|
||||||
|
<select bind:value={addMemberRole} disabled={!!eligibleLoadError}>
|
||||||
|
<option value="viewer">viewer</option>
|
||||||
|
<option value="editor">editor</option>
|
||||||
|
<option value="app_admin">app admin</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{#if eligibleLoadError}
|
||||||
|
<p class="muted">{eligibleLoadError}</p>
|
||||||
|
{:else if eligibleAfterFilter.length === 0}
|
||||||
|
<p class="muted">
|
||||||
|
No eligible users to invite. Create a <code>member</code> on the Users
|
||||||
|
page first.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if addMemberError}
|
||||||
|
<div class="error">{addMemberError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={addingMember || !addMemberUserId || !!eligibleLoadError}
|
||||||
|
>
|
||||||
|
{addingMember ? 'Adding…' : 'Add member'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if memberActionError}
|
||||||
|
<div class="error">{memberActionError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if members.length === 0}
|
||||||
|
<p class="muted">No explicit members yet.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="table">
|
||||||
|
<div class="row head-row">
|
||||||
|
<div>User</div>
|
||||||
|
<div>Instance</div>
|
||||||
|
<div>App role</div>
|
||||||
|
<div>Joined</div>
|
||||||
|
<div class="actions-col"></div>
|
||||||
|
</div>
|
||||||
|
{#each members as m (m.user_id)}
|
||||||
|
<div class="row member-row" class:inactive={!m.is_active}>
|
||||||
|
<div>
|
||||||
|
<strong>{m.username}</strong>
|
||||||
|
{#if m.email}<span class="muted">{m.email}</span>{/if}
|
||||||
|
{#if !m.is_active}<span class="muted">(inactive)</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div><RoleChip role={m.instance_role} size="sm" /></div>
|
||||||
|
<div><RoleChip appRole={m.role} size="sm" /></div>
|
||||||
|
<div>{shortDate(m.created_at)}</div>
|
||||||
|
<div class="actions-col">
|
||||||
|
<ActionMenu
|
||||||
|
label="Member actions for {m.username}"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: 'Make app admin',
|
||||||
|
disabled:
|
||||||
|
m.role === 'app_admin' || roleChangeBusy === m.user_id,
|
||||||
|
onClick: () => changeMemberRole(m, 'app_admin')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Make editor',
|
||||||
|
disabled:
|
||||||
|
m.role === 'editor' || roleChangeBusy === m.user_id,
|
||||||
|
onClick: () => changeMemberRole(m, 'editor')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Make viewer',
|
||||||
|
disabled:
|
||||||
|
m.role === 'viewer' || roleChangeBusy === m.user_id,
|
||||||
|
onClick: () => changeMemberRole(m, 'viewer')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Remove from app',
|
||||||
|
danger: true,
|
||||||
|
onClick: () => askRemoveMember(m)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{:else if activeTab === 'settings' && canAdmin}
|
||||||
<section>
|
<section>
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
<form class="create-form" onsubmit={(e) => saveSettings(e)}>
|
<form class="create-form" onsubmit={(e) => saveSettings(e)}>
|
||||||
@@ -502,6 +782,26 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</ConfirmModal>
|
</ConfirmModal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if memberToRemove}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Remove {memberToRemove.username} from {app.name}"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Remove member"
|
||||||
|
busyLabel="Removing…"
|
||||||
|
busy={removingMember}
|
||||||
|
onConfirm={confirmRemoveMember}
|
||||||
|
onCancel={() => (memberToRemove = null)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<strong>{memberToRemove.username}</strong> will lose access to this
|
||||||
|
app. Their other app memberships and account are untouched.
|
||||||
|
</p>
|
||||||
|
{#if removeMemberError}
|
||||||
|
<p class="modal-error">{removeMemberError}</p>
|
||||||
|
{/if}
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -744,4 +1044,60 @@
|
|||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
background: #1e0a0a;
|
background: #1e0a0a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.create-form select {
|
||||||
|
background: #0b1220;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form .row > label.grow {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr 1fr 3rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .head-row {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .member-row.inactive {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .member-row strong {
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .member-row .muted {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .actions-col {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,12 +6,15 @@
|
|||||||
api,
|
api,
|
||||||
ApiError,
|
ApiError,
|
||||||
type AppDomain,
|
type AppDomain,
|
||||||
|
type AppRole,
|
||||||
type ExecutionLog,
|
type ExecutionLog,
|
||||||
type Route,
|
type Route,
|
||||||
type RouteInput,
|
type RouteInput,
|
||||||
type Script,
|
type Script,
|
||||||
type VersionInfo
|
type VersionInfo
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
|
import { currentUser } from '$lib/auth';
|
||||||
|
import { canAdminApp, canWriteApp } from '$lib/capabilities';
|
||||||
import { logLevelColor, statusColor } from '$lib/styles';
|
import { logLevelColor, statusColor } from '$lib/styles';
|
||||||
import {
|
import {
|
||||||
checkHostAgainstClaims,
|
checkHostAgainstClaims,
|
||||||
@@ -21,6 +24,7 @@
|
|||||||
pathKindMismatchWarning
|
pathKindMismatchWarning
|
||||||
} from '$lib/route-utils';
|
} from '$lib/route-utils';
|
||||||
import CodeEditor from '$lib/CodeEditor.svelte';
|
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||||
|
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||||
import { format as formatRhai } from '$lib/rhai';
|
import { format as formatRhai } from '$lib/rhai';
|
||||||
|
|
||||||
/// Pretty-print a JSON string in place, leaving it untouched if the
|
/// Pretty-print a JSON string in place, leaving it untouched if the
|
||||||
@@ -47,6 +51,11 @@
|
|||||||
|
|
||||||
let appSlug = $state<string | null>(null);
|
let appSlug = $state<string | null>(null);
|
||||||
let appDomains = $state<AppDomain[]>([]);
|
let appDomains = $state<AppDomain[]>([]);
|
||||||
|
let appMyRole = $state<AppRole | null>(null);
|
||||||
|
|
||||||
|
const me = $derived($currentUser);
|
||||||
|
const canWrite = $derived(canWriteApp(me, appMyRole));
|
||||||
|
const canAdmin = $derived(canAdminApp(me, appMyRole));
|
||||||
|
|
||||||
async function loadScript() {
|
async function loadScript() {
|
||||||
scriptLoading = true;
|
scriptLoading = true;
|
||||||
@@ -58,15 +67,16 @@
|
|||||||
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
|
// Resolve the owning app for the breadcrumb (slug),
|
||||||
// domain claims for the route form's suggestions + live
|
// route-form host suggestions (domain claims), and UI
|
||||||
// validation. Both are non-fatal — the page works without
|
// shadowing (my_role on this app). All non-fatal — the
|
||||||
// them.
|
// page renders without them, just with reduced fidelity.
|
||||||
const appId = script.app_id;
|
const appId = script.app_id;
|
||||||
void api.apps
|
void api.apps
|
||||||
.get(appId)
|
.get(appId)
|
||||||
.then((a) => {
|
.then((a) => {
|
||||||
appSlug = a.slug;
|
appSlug = a.slug;
|
||||||
|
appMyRole = a.my_role ?? null;
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
void api.domains
|
void api.domains
|
||||||
@@ -366,16 +376,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- deletion ----------------
|
// ---------------- deletion ----------------
|
||||||
|
let confirmingDelete = $state(false);
|
||||||
let deleting = $state(false);
|
let deleting = $state(false);
|
||||||
async function remove() {
|
let deleteError = $state<string | null>(null);
|
||||||
|
|
||||||
|
function askDelete() {
|
||||||
|
deleteError = null;
|
||||||
|
confirmingDelete = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
if (!script) return;
|
if (!script) return;
|
||||||
if (!confirm(`Delete script "${script.name}"? This cannot be undone.`)) return;
|
|
||||||
deleting = true;
|
deleting = true;
|
||||||
|
deleteError = null;
|
||||||
try {
|
try {
|
||||||
await api.scripts.remove(id);
|
await api.scripts.remove(id);
|
||||||
await goto(base + '/');
|
await goto(base + '/');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : String(e));
|
deleteError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
deleting = false;
|
deleting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,6 +405,15 @@
|
|||||||
void loadRoutes();
|
void loadRoutes();
|
||||||
void loadLogs();
|
void loadLogs();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Defense-in-depth: anyone non-admin who lands on the Settings
|
||||||
|
// tab via a stale link gets bounced back to Edit. The tab button
|
||||||
|
// itself is also hidden.
|
||||||
|
$effect(() => {
|
||||||
|
if (!canAdmin && tab === 'settings') {
|
||||||
|
tab = 'edit';
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -410,9 +438,11 @@
|
|||||||
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
|
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="danger" onclick={remove} disabled={deleting}>
|
{#if canAdmin}
|
||||||
|
<button type="button" class="danger" onclick={askDelete} disabled={deleting}>
|
||||||
{deleting ? 'Deleting…' : 'Delete'}
|
{deleting ? 'Deleting…' : 'Delete'}
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
@@ -423,7 +453,9 @@
|
|||||||
<span class="badge-count">{routes.length}</span>
|
<span class="badge-count">{routes.length}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
{#if canAdmin}
|
||||||
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings</button>
|
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings</button>
|
||||||
|
{/if}
|
||||||
<button class:active={tab === 'executions'} onclick={() => (tab = 'executions')}>
|
<button class:active={tab === 'executions'} onclick={() => (tab = 'executions')}>
|
||||||
Executions
|
Executions
|
||||||
</button>
|
</button>
|
||||||
@@ -435,17 +467,25 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<header class="editor-header">
|
<header class="editor-header">
|
||||||
<h2>Source</h2>
|
<h2>Source</h2>
|
||||||
|
{#if canWrite}
|
||||||
<button type="button" class="ghost small" onclick={formatRhaiSource}>
|
<button type="button" class="ghost small" onclick={formatRhaiSource}>
|
||||||
Format
|
Format
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
<CodeEditor bind:value={editableSource} language="rhai" minHeight="22rem" />
|
<CodeEditor
|
||||||
|
bind:value={editableSource}
|
||||||
|
language="rhai"
|
||||||
|
minHeight="22rem"
|
||||||
|
readOnly={!canWrite}
|
||||||
|
/>
|
||||||
{#if rhaiFormatError}
|
{#if rhaiFormatError}
|
||||||
<div class="error inline">{rhaiFormatError}</div>
|
<div class="error inline">{rhaiFormatError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if saveSourceError}
|
{#if saveSourceError}
|
||||||
<div class="error inline">{saveSourceError}</div>
|
<div class="error inline">{saveSourceError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if canWrite}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -455,6 +495,7 @@
|
|||||||
{savingSource ? 'Saving…' : 'Save'}
|
{savingSource ? 'Saving…' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -510,12 +551,14 @@
|
|||||||
<section class="card wide">
|
<section class="card wide">
|
||||||
<header class="card-header">
|
<header class="card-header">
|
||||||
<h2>Routes</h2>
|
<h2>Routes</h2>
|
||||||
|
{#if canWrite}
|
||||||
<button type="button" onclick={() => (showAddRoute = !showAddRoute)}>
|
<button type="button" onclick={() => (showAddRoute = !showAddRoute)}>
|
||||||
{showAddRoute ? 'Cancel' : '+ Add route'}
|
{showAddRoute ? 'Cancel' : '+ Add route'}
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if showAddRoute}
|
{#if showAddRoute && canWrite}
|
||||||
<form class="route-form" onsubmit={submitRoute}>
|
<form class="route-form" onsubmit={submitRoute}>
|
||||||
<label class="full">
|
<label class="full">
|
||||||
<span>Path</span>
|
<span>Path</span>
|
||||||
@@ -626,9 +669,11 @@
|
|||||||
: r.host}
|
: r.host}
|
||||||
</span>
|
</span>
|
||||||
<span class="path">{r.path}</span>
|
<span class="path">{r.path}</span>
|
||||||
|
{#if canWrite}
|
||||||
<button type="button" class="link danger" onclick={() => removeRoute(r.id)}>
|
<button type="button" class="link danger" onclick={() => removeRoute(r.id)}>
|
||||||
remove
|
remove
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if info}
|
{#if info}
|
||||||
<div class="route-url muted">→ {fullUrlForRoute(r)}</div>
|
<div class="route-url muted">→ {fullUrlForRoute(r)}</div>
|
||||||
@@ -670,7 +715,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ===================================================== SETTINGS ===== -->
|
<!-- ===================================================== SETTINGS ===== -->
|
||||||
{:else if tab === 'settings'}
|
{:else if tab === 'settings' && canAdmin}
|
||||||
<section class="card wide">
|
<section class="card wide">
|
||||||
<h2>General</h2>
|
<h2>General</h2>
|
||||||
<label>
|
<label>
|
||||||
@@ -786,6 +831,35 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if confirmingDelete && script}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete script “{script.name}”"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Delete script"
|
||||||
|
busyLabel="Deleting…"
|
||||||
|
confirmPhrase={script.name}
|
||||||
|
confirmPhrasePrompt="Type the script name to confirm:"
|
||||||
|
busy={deleting}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => (confirmingDelete = false)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
This will <strong>permanently delete</strong>
|
||||||
|
<strong>{script.name}</strong>, all its routes, and all its
|
||||||
|
execution logs. There is no undo.
|
||||||
|
</p>
|
||||||
|
{#if routes.length > 0}
|
||||||
|
<p class="muted">
|
||||||
|
{routes.length} route{routes.length === 1 ? '' : 's'} bound to
|
||||||
|
this script will be removed.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if deleteError}
|
||||||
|
<p class="modal-error">{deleteError}</p>
|
||||||
|
{/if}
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,13 @@
|
|||||||
let deleteTarget = $state<AdminDto | null>(null);
|
let deleteTarget = $state<AdminDto | null>(null);
|
||||||
let deletePending = $state(false);
|
let deletePending = $state(false);
|
||||||
|
|
||||||
|
// Deactivate modal -------------------------------------------------------
|
||||||
|
// Reactivate is one-click (non-destructive); deactivate routes
|
||||||
|
// through the modal because it signs the user out and expires
|
||||||
|
// every API key they hold.
|
||||||
|
let deactivateTarget = $state<AdminDto | null>(null);
|
||||||
|
let deactivatePending = $state(false);
|
||||||
|
|
||||||
// Validation rules (mirror backend: 2-32, [a-z0-9._-]) -------------------
|
// Validation rules (mirror backend: 2-32, [a-z0-9._-]) -------------------
|
||||||
const USERNAME_RE = /^[a-z0-9._-]{2,32}$/;
|
const USERNAME_RE = /^[a-z0-9._-]{2,32}$/;
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
@@ -219,19 +226,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleActive(row: AdminDto) {
|
async function reactivate(row: AdminDto) {
|
||||||
try {
|
try {
|
||||||
const updated = await api.admins.update(row.id, { is_active: !row.is_active });
|
const updated = await api.admins.update(row.id, { is_active: true });
|
||||||
admins = admins.map((a) => (a.id === updated.id ? updated : a));
|
admins = admins.map((a) => (a.id === updated.id ? updated : a));
|
||||||
flash(
|
flash('info', `${updated.username} reactivated.`);
|
||||||
'info',
|
|
||||||
`${updated.username} ${updated.is_active ? 'reactivated' : 'deactivated'}.`
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
flash('error', e instanceof ApiError ? e.message : 'failed to update user');
|
flash('error', e instanceof ApiError ? e.message : 'failed to update user');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function askDeactivate(row: AdminDto) {
|
||||||
|
deactivateTarget = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeactivate() {
|
||||||
|
if (!deactivateTarget) return;
|
||||||
|
deactivatePending = true;
|
||||||
|
const target = deactivateTarget;
|
||||||
|
try {
|
||||||
|
const updated = await api.admins.update(target.id, { is_active: false });
|
||||||
|
admins = admins.map((a) => (a.id === updated.id ? updated : a));
|
||||||
|
deactivateTarget = null;
|
||||||
|
flash('info', `${updated.username} deactivated.`);
|
||||||
|
} catch (e) {
|
||||||
|
flash('error', e instanceof ApiError ? e.message : 'failed to update user');
|
||||||
|
} finally {
|
||||||
|
deactivatePending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openDelete(row: AdminDto) {
|
function openDelete(row: AdminDto) {
|
||||||
deleteTarget = row;
|
deleteTarget = row;
|
||||||
}
|
}
|
||||||
@@ -353,7 +377,8 @@
|
|||||||
{ label: 'Edit', onClick: () => openEdit(row) },
|
{ label: 'Edit', onClick: () => openEdit(row) },
|
||||||
{
|
{
|
||||||
label: row.is_active ? 'Deactivate' : 'Reactivate',
|
label: row.is_active ? 'Deactivate' : 'Reactivate',
|
||||||
onClick: () => toggleActive(row)
|
onClick: () =>
|
||||||
|
row.is_active ? askDeactivate(row) : reactivate(row)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
@@ -571,6 +596,30 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Deactivate confirmation -->
|
||||||
|
{#if deactivateTarget}
|
||||||
|
{@const dt = deactivateTarget}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Deactivate {dt.username}?"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Deactivate"
|
||||||
|
busyLabel="Deactivating…"
|
||||||
|
busy={deactivatePending}
|
||||||
|
onConfirm={confirmDeactivate}
|
||||||
|
onCancel={() => (deactivateTarget = null)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Deactivating signs <strong>{dt.username}</strong> out immediately and
|
||||||
|
expires <strong>every API key</strong> they hold. Their sessions and keys
|
||||||
|
won't come back if you reactivate — they'll need to log in again and
|
||||||
|
mint new keys.
|
||||||
|
</p>
|
||||||
|
<p class="muted">
|
||||||
|
Reactivation is one click — this isn't permanent.
|
||||||
|
</p>
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Delete confirmation -->
|
<!-- Delete confirmation -->
|
||||||
{#if deleteTarget}
|
{#if deleteTarget}
|
||||||
{@const dt = deleteTarget}
|
{@const dt = deleteTarget}
|
||||||
|
|||||||
75
dashboard/tests/e2e/README.md
Normal file
75
dashboard/tests/e2e/README.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Dashboard E2E tests
|
||||||
|
|
||||||
|
Browser-driven tests for the PiCloud dashboard, powered by [Playwright].
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
The tests drive a real dashboard against a real backend. Bring up both
|
||||||
|
before running:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. Postgres
|
||||||
|
docker compose up -d postgres
|
||||||
|
|
||||||
|
# 2. Backend (port 18080 matches dashboard/vite.config.ts dev proxy)
|
||||||
|
PICLOUD_BIND=127.0.0.1:18080 \
|
||||||
|
PICLOUD_ADMIN_USERNAME=admin \
|
||||||
|
PICLOUD_ADMIN_PASSWORD=admin \
|
||||||
|
DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \
|
||||||
|
cargo run -p picloud
|
||||||
|
|
||||||
|
# 3. Browser binaries (one-time, ~200 MB)
|
||||||
|
cd dashboard && npm run test:e2e:install
|
||||||
|
```
|
||||||
|
|
||||||
|
The Vite dev server is started automatically by Playwright's `webServer`
|
||||||
|
config — you do not need to run `npm run dev` yourself.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd dashboard
|
||||||
|
npm run test:e2e # headless, full suite
|
||||||
|
npm run test:e2e:ui # interactive UI runner
|
||||||
|
npx playwright test smoke # run a single spec
|
||||||
|
npx playwright show-report
|
||||||
|
```
|
||||||
|
|
||||||
|
## Env vars
|
||||||
|
|
||||||
|
| Var | Default | Notes |
|
||||||
|
| ------------------------ | ------------------------ | ----------------------------------------------------------------- |
|
||||||
|
| `E2E_BASE_URL` | `http://localhost:5173` | Origin tests navigate against (dashboard is mounted at `/admin`). |
|
||||||
|
| `E2E_API_BASE` | `http://127.0.0.1:18080` | Backend used by globalSetup health probe + admin login. |
|
||||||
|
| `E2E_DASHBOARD_ORIGIN` | `http://localhost:5173` | Used to seed `localStorage` during globalSetup. |
|
||||||
|
| `E2E_ADMIN_USERNAME` | `admin` | Bootstrap admin to log in as. |
|
||||||
|
| `E2E_ADMIN_PASSWORD` | `admin` | Match `PICLOUD_ADMIN_PASSWORD` above. |
|
||||||
|
| `PICLOUD_DASHBOARD_PORT` | `5173` | Dev server port — picked up by both Vite and Playwright. |
|
||||||
|
|
||||||
|
## How isolation works
|
||||||
|
|
||||||
|
Tests share one backend + one Postgres. To avoid cross-test interference:
|
||||||
|
|
||||||
|
- A shared bootstrap admin session is captured once in
|
||||||
|
`tests/e2e/.auth/admin.json` (gitignored) and reused by every test via
|
||||||
|
`storageState`.
|
||||||
|
- Each test creates resources with a unique slug / username produced by
|
||||||
|
`fixtures/ids.ts` (`e2e-<prefix>-w<worker>-<random>`).
|
||||||
|
- Each test registers cleanup via `fixtures/cleanup.ts` and tears down
|
||||||
|
in `afterEach`. Cleanup is best-effort: a missing resource doesn't
|
||||||
|
fail the suite, so a test can pre-delete and still register the entry.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/e2e/
|
||||||
|
global-setup.ts # health probe + admin login + storageState seed
|
||||||
|
smoke.spec.ts # A.5 smoke
|
||||||
|
fixtures/
|
||||||
|
auth.ts # UI login/logout helpers (for login-flow specs)
|
||||||
|
api.ts # bearer-token-backed APIRequestContext
|
||||||
|
ids.ts # unique slug/username generators (test-fixture)
|
||||||
|
cleanup.ts # afterEach resource teardown
|
||||||
|
```
|
||||||
|
|
||||||
|
[Playwright]: https://playwright.dev
|
||||||
335
dashboard/tests/e2e/apps/apps.spec.ts
Normal file
335
dashboard/tests/e2e/apps/apps.spec.ts
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import { test } from '../fixtures/ids';
|
||||||
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
|
import { adminApi } from '../fixtures/api';
|
||||||
|
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
|
||||||
|
|
||||||
|
const MEMBER_PW = 'e2e-member-pw';
|
||||||
|
|
||||||
|
async function seedAppAndMember(opts: {
|
||||||
|
slug: string;
|
||||||
|
username: string;
|
||||||
|
role: 'viewer' | 'editor' | 'app_admin';
|
||||||
|
}): Promise<{ appId: string; userId: string }> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const appRes = await api.post('/api/v1/admin/apps', {
|
||||||
|
data: { slug: opts.slug, name: opts.slug }
|
||||||
|
});
|
||||||
|
expect(appRes.ok()).toBe(true);
|
||||||
|
const appId = ((await appRes.json()) as { id: string }).id;
|
||||||
|
const userRes = await api.post('/api/v1/admin/admins', {
|
||||||
|
data: { username: opts.username, password: MEMBER_PW, instance_role: 'member' }
|
||||||
|
});
|
||||||
|
expect(userRes.ok()).toBe(true);
|
||||||
|
const userId = ((await userRes.json()) as { id: string }).id;
|
||||||
|
const memberRes = await api.post(`/api/v1/admin/apps/${opts.slug}/members`, {
|
||||||
|
data: { user_id: userId, role: opts.role }
|
||||||
|
});
|
||||||
|
expect(memberRes.ok()).toBe(true);
|
||||||
|
return { appId, userId };
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase B2 — Apps Lifecycle. Create, view, edit, delete, plus the
|
||||||
|
// historical-slug takeover flow and adversarial inputs.
|
||||||
|
|
||||||
|
const cleanup = new CleanupRegistry();
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await cleanup.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
function failOnDialog(page: Page): void {
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
await dialog.dismiss();
|
||||||
|
throw new Error(`Unexpected browser dialog fired: ${dialog.type()} — "${dialog.message()}"`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCreateForm(page: Page): Promise<void> {
|
||||||
|
await page.goto('/admin/apps');
|
||||||
|
await page.getByRole('button', { name: 'New app' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createApp(
|
||||||
|
page: Page,
|
||||||
|
opts: { name: string; slug: string; description?: string }
|
||||||
|
): Promise<void> {
|
||||||
|
await openCreateForm(page);
|
||||||
|
await page.getByLabel('Name').fill(opts.name);
|
||||||
|
// Clear the auto-derived slug and type the test-controlled one so
|
||||||
|
// we know exactly which slug we'll register for cleanup.
|
||||||
|
const slugInput = page.getByLabel('Slug');
|
||||||
|
await slugInput.fill('');
|
||||||
|
await slugInput.fill(opts.slug);
|
||||||
|
if (opts.description !== undefined) {
|
||||||
|
await page.getByLabel('Description').fill(opts.description);
|
||||||
|
}
|
||||||
|
await page.getByRole('button', { name: 'Create app' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('B2 apps lifecycle', () => {
|
||||||
|
test('create app: slug auto-derives from name, app appears in list', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('lifecycle');
|
||||||
|
const displayName = slug.replace(/-/g, ' ');
|
||||||
|
|
||||||
|
await openCreateForm(page);
|
||||||
|
await page.getByLabel('Name').fill(displayName);
|
||||||
|
// Slug auto-derives — the input value is set, no extra typing.
|
||||||
|
const slugInput = page.getByLabel('Slug');
|
||||||
|
await expect(slugInput).toHaveValue(slug);
|
||||||
|
await page.getByRole('button', { name: 'Create app' }).click();
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(displayName) })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edit name + description in settings persists across reload', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('edit');
|
||||||
|
await createApp(page, { name: slug, slug });
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: new RegExp(slug) }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/admin/apps/${slug}$`));
|
||||||
|
await page.getByRole('button', { name: 'Settings' }).click();
|
||||||
|
|
||||||
|
const newName = `${slug} renamed`;
|
||||||
|
const newDesc = 'updated description';
|
||||||
|
await page.getByLabel('Name').fill(newName);
|
||||||
|
await page.getByLabel('Description').fill(newDesc);
|
||||||
|
await page.getByRole('button', { name: 'Save changes' }).click();
|
||||||
|
// Wait for the network round-trip to settle — the busy label
|
||||||
|
// flips back to "Save changes" when done.
|
||||||
|
await expect(page.getByRole('button', { name: 'Save changes' })).toBeEnabled();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: 'Settings' }).click();
|
||||||
|
await expect(page.getByLabel('Name')).toHaveValue(newName);
|
||||||
|
await expect(page.getByLabel('Description')).toHaveValue(newDesc);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete: wrong phrase keeps button disabled, right phrase removes app', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('delete');
|
||||||
|
await createApp(page, { name: slug, slug });
|
||||||
|
cleanup.app(slug); // belt-and-braces; cleanup is best-effort
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: new RegExp(slug) }).click();
|
||||||
|
await page.getByRole('button', { name: 'Settings' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Delete app' }).click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
const phraseInput = dialog.getByRole('textbox');
|
||||||
|
const confirmBtn = dialog.getByRole('button', { name: 'Delete app' });
|
||||||
|
await expect(confirmBtn).toBeDisabled();
|
||||||
|
|
||||||
|
await phraseInput.fill('wrong-phrase');
|
||||||
|
await expect(confirmBtn).toBeDisabled();
|
||||||
|
|
||||||
|
await phraseInput.fill(slug);
|
||||||
|
await expect(confirmBtn).toBeEnabled();
|
||||||
|
await confirmBtn.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/admin\/apps$/);
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(slug) })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('historical slug warning surfaces; force-takeover succeeds', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const origSlug = uniqueSlug('hist');
|
||||||
|
const renamedSlug = `${origSlug}-r`;
|
||||||
|
|
||||||
|
// Historical-redirect rows are created on RENAME, not on
|
||||||
|
// delete. So: create app, rename it, original slug now lives
|
||||||
|
// in app_slug_history.
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const created = await api.post('/api/v1/admin/apps', {
|
||||||
|
data: { slug: origSlug, name: origSlug }
|
||||||
|
});
|
||||||
|
expect(created.ok()).toBe(true);
|
||||||
|
const renamed = await api.patch(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(origSlug)}`,
|
||||||
|
{ data: { slug: renamedSlug } }
|
||||||
|
);
|
||||||
|
expect(renamed.ok()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
cleanup.app(renamedSlug); // the renamed app still exists
|
||||||
|
|
||||||
|
await openCreateForm(page);
|
||||||
|
await page.getByLabel('Name').fill(origSlug);
|
||||||
|
await page.getByLabel('Slug').fill('');
|
||||||
|
await page.getByLabel('Slug').fill(origSlug);
|
||||||
|
await page.getByRole('button', { name: 'Create app' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('.warning')).toBeVisible();
|
||||||
|
await expect(page.locator('.warning')).toContainText(/previously redirected/i);
|
||||||
|
await page.getByRole('button', { name: /claim slug anyway/i }).click();
|
||||||
|
cleanup.app(origSlug); // the takeover created a new app
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(origSlug) })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B2 apps adversarial', () => {
|
||||||
|
test('slug with uppercase + spaces is normalized in-place', async ({ page, uniqueSlug }) => {
|
||||||
|
const base = uniqueSlug('norm');
|
||||||
|
await openCreateForm(page);
|
||||||
|
await page.getByLabel('Name').fill(base);
|
||||||
|
const slugInput = page.getByLabel('Slug');
|
||||||
|
await slugInput.fill('');
|
||||||
|
// Simulate the user typing/pasting an invalid slug. The
|
||||||
|
// oninput handler runs slugify() and rewrites the input value.
|
||||||
|
await slugInput.fill(` Hello WORLD ${base}!`);
|
||||||
|
await expect(slugInput).toHaveValue(`hello-world-${base}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('xss in name and description renders as text everywhere', async ({ page, uniqueSlug }) => {
|
||||||
|
failOnDialog(page);
|
||||||
|
const slug = uniqueSlug('xss');
|
||||||
|
const payload = '<img src=x onerror=alert(1)><script>window.__xss=true;</script>';
|
||||||
|
|
||||||
|
await createApp(page, { name: payload, slug, description: payload });
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
// List page — the link's accessible name contains the literal
|
||||||
|
// payload text, not the parsed HTML.
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp('img src=x') })).toBeVisible();
|
||||||
|
|
||||||
|
// Detail page — open it; payload renders in the breadcrumb /
|
||||||
|
// header as text only.
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
const xssRan = await page.evaluate(
|
||||||
|
() => (window as unknown as { __xss?: boolean }).__xss === true
|
||||||
|
);
|
||||||
|
expect(xssRan).toBe(false);
|
||||||
|
expect(await page.locator('script:has-text("__xss")').count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('very long name does not crash the dashboard', async ({ page, uniqueSlug }) => {
|
||||||
|
// The backend currently has no name length cap; the dashboard
|
||||||
|
// just needs to keep rendering when handed an unusually long
|
||||||
|
// value. Guards against layout / locator regressions when a
|
||||||
|
// future test or user creates an oversized app.
|
||||||
|
const slug = uniqueSlug('long');
|
||||||
|
const longName = 'A'.repeat(10_000);
|
||||||
|
|
||||||
|
await openCreateForm(page);
|
||||||
|
await page.getByLabel('Name').fill(longName);
|
||||||
|
await page.getByLabel('Slug').fill('');
|
||||||
|
await page.getByLabel('Slug').fill(slug);
|
||||||
|
await page.getByRole('button', { name: 'Create app' }).click();
|
||||||
|
|
||||||
|
const errorVisible = await page
|
||||||
|
.locator('.create-form .error')
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (errorVisible) {
|
||||||
|
// Server rejected — fine, no cleanup needed.
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(slug) })).toHaveCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server accepted — confirm the dashboard still renders and is
|
||||||
|
// navigable. Detail page must load too.
|
||||||
|
cleanup.app(slug);
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B2 apps role shadowing', () => {
|
||||||
|
test('viewer member sees no "New app" on the apps list', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('vlist');
|
||||||
|
const username = uniqueUsername('viewer');
|
||||||
|
const { userId } = await seedAppAndMember({ slug, username, role: 'viewer' });
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto('/admin/apps');
|
||||||
|
// Member can see the apps list (just the one they belong to)
|
||||||
|
// but the create-app affordance is hidden.
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /^New app$/ })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewer sees no Add domain form and no Settings tab on app detail', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('vdom');
|
||||||
|
const username = uniqueUsername('viewer');
|
||||||
|
const { userId } = await seedAppAndMember({ slug, username, role: 'viewer' });
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: /^Scripts \(\d+\)$/ })
|
||||||
|
).toBeVisible();
|
||||||
|
// Settings tab is absent.
|
||||||
|
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
|
||||||
|
// Domains tab still listable, but no Add-domain submit.
|
||||||
|
await page.getByRole('button', { name: /^Domains \(\d+\)$/ }).click();
|
||||||
|
await expect(page.getByRole('button', { name: /^Add domain$/ })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editor sees New script but no Settings tab', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('edit');
|
||||||
|
const username = uniqueUsername('editor');
|
||||||
|
const { userId } = await seedAppAndMember({ slug, username, role: 'editor' });
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
await expect(page.getByRole('button', { name: /^New script$/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: /^Members \(\d+\)$/ })
|
||||||
|
).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
118
dashboard/tests/e2e/auth/auth.spec.ts
Normal file
118
dashboard/tests/e2e/auth/auth.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
import { loginAsAdmin, logout } from '../fixtures/auth';
|
||||||
|
|
||||||
|
// Phase B1 — Auth & Navigation. Every interaction with the login form
|
||||||
|
// and the layout-level redirects, plus the obvious adversarial inputs.
|
||||||
|
|
||||||
|
const VALID_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||||
|
const VALID_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
|
||||||
|
|
||||||
|
function failOnDialog(page: Page): void {
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
await dialog.dismiss();
|
||||||
|
throw new Error(`Unexpected browser dialog fired: ${dialog.type()} — "${dialog.message()}"`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('B1 auth — unauthenticated', () => {
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
test('valid credentials land on the apps list', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Apps', level: 1 })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrong password shows an inline error and stays on /login', async ({ page }) => {
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.getByLabel('Username').fill(VALID_USERNAME);
|
||||||
|
await page.getByLabel('Password').fill('definitely-not-the-password');
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
|
||||||
|
const error = page.locator('.error');
|
||||||
|
await expect(error).toBeVisible();
|
||||||
|
await expect(error).not.toHaveText('');
|
||||||
|
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||||
|
// localStorage must remain empty — a failed login should not
|
||||||
|
// leak a session token.
|
||||||
|
const token = await page.evaluate(() => localStorage.getItem('picloud.admin.token'));
|
||||||
|
expect(token).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty submit is blocked by the browser and does not navigate', async ({ page }) => {
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
// HTML5 validation prevents submission; URL is unchanged and the
|
||||||
|
// username input is reported invalid.
|
||||||
|
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||||
|
const usernameInvalid = await page
|
||||||
|
.getByLabel('Username')
|
||||||
|
.evaluate((el: HTMLInputElement) => !el.validity.valid);
|
||||||
|
expect(usernameInvalid).toBe(true);
|
||||||
|
await expect(page.locator('.error')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('visiting an authed route redirects to /login', async ({ page }) => {
|
||||||
|
await page.goto('/admin/apps');
|
||||||
|
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||||
|
await expect(page.getByLabel('Username')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password field is type=password (no plaintext echo)', async ({ page }) => {
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await expect(page.getByLabel('Password')).toHaveAttribute('type', 'password');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('xss payload in username is escaped and does not execute', async ({ page }) => {
|
||||||
|
failOnDialog(page);
|
||||||
|
const payload = '<script>window.__xss = true;</script><img src=x onerror=alert(1)>';
|
||||||
|
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.getByLabel('Username').fill(payload);
|
||||||
|
await page.getByLabel('Password').fill('whatever');
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
|
||||||
|
// Whatever the API does with that input, the page must remain
|
||||||
|
// safe: no script tag injected into the DOM, no global side
|
||||||
|
// effect, and a visible error (since the credentials don't
|
||||||
|
// match any user).
|
||||||
|
await expect(page.locator('.error')).toBeVisible();
|
||||||
|
const xssRan = await page.evaluate(
|
||||||
|
() => (window as unknown as { __xss?: boolean }).__xss === true
|
||||||
|
);
|
||||||
|
expect(xssRan).toBe(false);
|
||||||
|
const injectedScript = await page.locator('script:has-text("__xss")').count();
|
||||||
|
expect(injectedScript).toBe(0);
|
||||||
|
// The form must still be functional after the rejected attempt.
|
||||||
|
await page.getByLabel('Username').fill('');
|
||||||
|
await page.getByLabel('Username').fill(VALID_USERNAME);
|
||||||
|
await page.getByLabel('Password').fill('');
|
||||||
|
await page.getByLabel('Password').fill(VALID_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/admin\/apps$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B1 auth — authenticated', () => {
|
||||||
|
test('visiting /login while signed in bounces to /apps', async ({ page }) => {
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await expect(page).toHaveURL(/\/admin\/apps$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B1 auth — logout', () => {
|
||||||
|
// Logout must NOT use the shared storageState token, or it would
|
||||||
|
// invalidate the session every other test relies on. Each run
|
||||||
|
// here logs in fresh so its session is disposable.
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
test('logout clears the session and lands on /login', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Apps', level: 1 })).toBeVisible();
|
||||||
|
await logout(page);
|
||||||
|
const token = await page.evaluate(() => localStorage.getItem('picloud.admin.token'));
|
||||||
|
expect(token).toBeNull();
|
||||||
|
// And the authed area is now gated again.
|
||||||
|
await page.goto('/admin/apps');
|
||||||
|
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
47
dashboard/tests/e2e/fixtures/api.ts
Normal file
47
dashboard/tests/e2e/fixtures/api.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { request, type APIRequestContext } from '@playwright/test';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
||||||
|
const STATE_PATH = path.join(__dirname, '..', '.auth', 'admin.json');
|
||||||
|
|
||||||
|
interface StoredState {
|
||||||
|
origins: Array<{
|
||||||
|
origin: string;
|
||||||
|
localStorage: Array<{ name: string; value: string }>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedToken: string | null = null;
|
||||||
|
|
||||||
|
async function readAdminToken(): Promise<string> {
|
||||||
|
if (cachedToken) return cachedToken;
|
||||||
|
const raw = await fs.readFile(STATE_PATH, 'utf8');
|
||||||
|
const state = JSON.parse(raw) as StoredState;
|
||||||
|
for (const origin of state.origins) {
|
||||||
|
const entry = origin.localStorage.find((e) => e.name === 'picloud.admin.token');
|
||||||
|
if (entry) {
|
||||||
|
cachedToken = entry.value;
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`No picloud.admin.token in ${STATE_PATH} — did globalSetup run?`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thin wrapper around Playwright's request context that injects the
|
||||||
|
// admin bearer token from the shared storageState. Use this for
|
||||||
|
// setup/teardown shortcuts when the *test itself* is about something
|
||||||
|
// else (e.g., a script-editor test that just needs an app to exist).
|
||||||
|
export async function adminApi(): Promise<APIRequestContext> {
|
||||||
|
const token = await readAdminToken();
|
||||||
|
return request.newContext({
|
||||||
|
baseURL: API_BASE,
|
||||||
|
extraHTTPHeaders: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
'content-type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
21
dashboard/tests/e2e/fixtures/auth.ts
Normal file
21
dashboard/tests/e2e/fixtures/auth.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||||
|
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
|
||||||
|
|
||||||
|
// Drive the login form like a real user. globalSetup already saves a
|
||||||
|
// storageState for the shared admin, so most tests don't need this —
|
||||||
|
// it's reserved for specs that explicitly cover the login UI.
|
||||||
|
export async function loginAsAdmin(page: Page): Promise<void> {
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.getByLabel('Username').fill(ADMIN_USERNAME);
|
||||||
|
await page.getByLabel('Password').fill(ADMIN_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/admin\/apps$/);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /logout/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||||
|
}
|
||||||
77
dashboard/tests/e2e/fixtures/cleanup.ts
Normal file
77
dashboard/tests/e2e/fixtures/cleanup.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { APIRequestContext } from '@playwright/test';
|
||||||
|
import { adminApi } from './api';
|
||||||
|
|
||||||
|
// Resources to delete after a test, in LIFO order. Tests register
|
||||||
|
// their creations and the registry tears everything down in
|
||||||
|
// `run()` — typically called from `test.afterEach`.
|
||||||
|
//
|
||||||
|
// A non-2xx status (other than 404) is treated as a real failure and
|
||||||
|
// logged to stderr. The previous shape silently swallowed every
|
||||||
|
// error, so a backend that started returning 500 on cleanup would
|
||||||
|
// have leaked orphans invisibly across runs. 404 stays tolerated —
|
||||||
|
// the test may have already deleted the resource itself.
|
||||||
|
|
||||||
|
interface CleanupItem {
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CleanupRegistry {
|
||||||
|
private items: CleanupItem[] = [];
|
||||||
|
|
||||||
|
app(slugOrId: string): void {
|
||||||
|
this.items.push({
|
||||||
|
label: `app=${slugOrId}`,
|
||||||
|
path: `/api/v1/admin/apps/${encodeURIComponent(slugOrId)}?force=true`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
adminUser(userId: string): void {
|
||||||
|
this.items.push({
|
||||||
|
label: `admin=${userId}`,
|
||||||
|
path: `/api/v1/admin/admins/${userId}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey(keyId: string): void {
|
||||||
|
this.items.push({
|
||||||
|
label: `key=${keyId}`,
|
||||||
|
path: `/api/v1/admin/api-keys/${keyId}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(): Promise<void> {
|
||||||
|
if (this.items.length === 0) return;
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
// Copy-then-reverse so a defensive double-`run()` (or a
|
||||||
|
// caller that inspects the registry after a partial
|
||||||
|
// teardown) doesn't see the items in a re-reversed order.
|
||||||
|
for (const item of [...this.items].reverse()) {
|
||||||
|
await deleteAndReport(api, item);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
this.items = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAndReport(
|
||||||
|
api: APIRequestContext,
|
||||||
|
item: CleanupItem
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await api.delete(item.path);
|
||||||
|
// 2xx and 404 are both "this resource is no longer here" — fine.
|
||||||
|
if (!res.ok() && res.status() !== 404) {
|
||||||
|
console.warn(
|
||||||
|
`[cleanup] ${item.label} failed: HTTP ${res.status()} ${await res.text()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Network-level failure (request never reached the server,
|
||||||
|
// timeout, etc.). Log so a leak doesn't accumulate silently.
|
||||||
|
console.warn(`[cleanup] ${item.label} failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
dashboard/tests/e2e/fixtures/ids.ts
Normal file
42
dashboard/tests/e2e/fixtures/ids.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/* eslint-disable no-empty-pattern -- Playwright fixtures require an
|
||||||
|
object-pattern first arg; these fixtures don't depend on any other
|
||||||
|
fixture so the pattern is intentionally empty. */
|
||||||
|
import { test as base } from '@playwright/test';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
// Tests share a single backend/Postgres. To avoid collisions we tag
|
||||||
|
// every resource the test creates with a short random suffix plus the
|
||||||
|
// Playwright worker index. This way two workers running the same spec
|
||||||
|
// in parallel never fight over the same slug or username.
|
||||||
|
|
||||||
|
export function shortId(): string {
|
||||||
|
return randomBytes(3).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uniqueSlug(prefix: string, workerIndex: number): string {
|
||||||
|
const cleaned = prefix
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
return `e2e-${cleaned}-w${workerIndex}-${shortId()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uniqueUsername(prefix: string, workerIndex: number): string {
|
||||||
|
// Username regex is [a-z0-9._-]{2,32}. Mirror the slug format.
|
||||||
|
const cleaned = prefix.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||||
|
return `e2e${cleaned}w${workerIndex}${shortId()}`.slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const test = base.extend<{
|
||||||
|
uniqueSlug: (prefix: string) => string;
|
||||||
|
uniqueUsername: (prefix: string) => string;
|
||||||
|
}>({
|
||||||
|
uniqueSlug: async ({}, use, testInfo) => {
|
||||||
|
await use((prefix) => uniqueSlug(prefix, testInfo.workerIndex));
|
||||||
|
},
|
||||||
|
uniqueUsername: async ({}, use, testInfo) => {
|
||||||
|
await use((prefix) => uniqueUsername(prefix, testInfo.workerIndex));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect } from '@playwright/test';
|
||||||
46
dashboard/tests/e2e/fixtures/role-page.ts
Normal file
46
dashboard/tests/e2e/fixtures/role-page.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Helpers for tests that drive the dashboard as a non-bootstrap admin
|
||||||
|
// (member with an app-membership row, custom InstanceRole, etc.).
|
||||||
|
//
|
||||||
|
// `loginAsUserToken` exchanges username/password for a bearer token
|
||||||
|
// via the admin API. `pageWithUserToken` opens a fresh browser
|
||||||
|
// context, seeds the dashboard's localStorage entry, and returns the
|
||||||
|
// page ready to navigate. Callers are responsible for closing the
|
||||||
|
// returned page's context.
|
||||||
|
|
||||||
|
import { expect, request, type Browser, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
||||||
|
|
||||||
|
export async function loginAsUserToken(
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<string> {
|
||||||
|
const probe = await request.newContext({ baseURL: API_BASE });
|
||||||
|
try {
|
||||||
|
const res = await probe.post('/api/v1/admin/auth/login', {
|
||||||
|
data: { username, password },
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
return ((await res.json()) as { token: string }).token;
|
||||||
|
} finally {
|
||||||
|
await probe.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pageWithUserToken(
|
||||||
|
browser: Browser,
|
||||||
|
token: string
|
||||||
|
): Promise<Page> {
|
||||||
|
const ctx = await browser.newContext({ storageState: undefined });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
// Seed localStorage on the right origin, then navigate normally.
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.evaluate(
|
||||||
|
([key, value]) => {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
},
|
||||||
|
['picloud.admin.token', token]
|
||||||
|
);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
146
dashboard/tests/e2e/global-setup.ts
Normal file
146
dashboard/tests/e2e/global-setup.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { chromium, request } from '@playwright/test';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
||||||
|
const DASHBOARD_PORT = Number(process.env.PICLOUD_DASHBOARD_PORT ?? 5173);
|
||||||
|
const DASHBOARD_ORIGIN = process.env.E2E_DASHBOARD_ORIGIN ?? `http://localhost:${DASHBOARD_PORT}`;
|
||||||
|
const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||||
|
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
|
||||||
|
|
||||||
|
const AUTH_DIR = path.join(__dirname, '.auth');
|
||||||
|
const ADMIN_STATE_PATH = path.join(AUTH_DIR, 'admin.json');
|
||||||
|
|
||||||
|
export default async function globalSetup(): Promise<void> {
|
||||||
|
await assertBackendUp();
|
||||||
|
await fs.mkdir(AUTH_DIR, { recursive: true });
|
||||||
|
const token = await loginAsAdmin();
|
||||||
|
await sweepOrphans(token);
|
||||||
|
await persistAdminStorageState(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertBackendUp(): Promise<void> {
|
||||||
|
const probe = await request.newContext();
|
||||||
|
try {
|
||||||
|
const res = await probe.get(`${API_BASE}/healthz`, { timeout: 5_000 });
|
||||||
|
if (!res.ok()) {
|
||||||
|
throw new Error(
|
||||||
|
`backend /healthz returned ${res.status()} — is \`cargo run -p picloud\` listening on ${API_BASE}?`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not reach backend at ${API_BASE}/healthz. ` +
|
||||||
|
`Bring it up before running E2E tests:\n\n` +
|
||||||
|
` docker compose up -d postgres\n` +
|
||||||
|
` PICLOUD_BIND=127.0.0.1:18080 \\\n` +
|
||||||
|
` PICLOUD_ADMIN_USERNAME=${ADMIN_USERNAME} \\\n` +
|
||||||
|
` PICLOUD_ADMIN_PASSWORD=${ADMIN_PASSWORD} \\\n` +
|
||||||
|
` DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \\\n` +
|
||||||
|
` cargo run -p picloud\n\n` +
|
||||||
|
`Underlying error: ${(err as Error).message}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await probe.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginAsAdmin(): Promise<string> {
|
||||||
|
const ctx = await request.newContext();
|
||||||
|
try {
|
||||||
|
const res = await ctx.post(`${API_BASE}/api/v1/admin/auth/login`, {
|
||||||
|
data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD },
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
if (!res.ok()) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(
|
||||||
|
`Admin login failed (${res.status()}): ${body}. ` +
|
||||||
|
`Verify PICLOUD_ADMIN_USERNAME / PICLOUD_ADMIN_PASSWORD match the seeded bootstrap admin.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const payload = (await res.json()) as { token?: string };
|
||||||
|
if (!payload.token) {
|
||||||
|
throw new Error('Admin login response missing token field');
|
||||||
|
}
|
||||||
|
return payload.token;
|
||||||
|
} finally {
|
||||||
|
await ctx.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up apps + admin users left over from a previous crashed run.
|
||||||
|
// The convention is that every e2e-created resource has a slug
|
||||||
|
// starting with `e2e-` (apps) or a username starting with `e2e`
|
||||||
|
// (admins) — see fixtures/ids.ts. Best-effort: a sweep failure must
|
||||||
|
// not stop the suite from running.
|
||||||
|
async function sweepOrphans(token: string): Promise<void> {
|
||||||
|
const ctx = await request.newContext({
|
||||||
|
baseURL: API_BASE,
|
||||||
|
extraHTTPHeaders: { authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
const res = await ctx.get('/api/v1/admin/apps');
|
||||||
|
if (res.ok()) {
|
||||||
|
const apps = (await res.json()) as Array<{ slug: string }>;
|
||||||
|
for (const app of apps) {
|
||||||
|
if (!app.slug.startsWith('e2e-')) continue;
|
||||||
|
try {
|
||||||
|
await ctx.delete(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(app.slug)}?force=true`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Individual delete failure is non-fatal — the per-test
|
||||||
|
// cleanup will catch it on the next run.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Listing failed; nothing to do but proceed.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await ctx.get('/api/v1/admin/admins');
|
||||||
|
if (res.ok()) {
|
||||||
|
const admins = (await res.json()) as Array<{ id: string; username: string }>;
|
||||||
|
for (const a of admins) {
|
||||||
|
if (!/^e2e/.test(a.username)) continue;
|
||||||
|
try {
|
||||||
|
await ctx.delete(`/api/v1/admin/admins/${a.id}`);
|
||||||
|
} catch {
|
||||||
|
// Same per-row tolerance as above.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Listing failed; same as above.
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await ctx.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dashboard reads its session from localStorage under the key
|
||||||
|
// `picloud.admin.token` (see src/lib/auth.ts). We can't write to
|
||||||
|
// localStorage without a browser context, so launch a throwaway one,
|
||||||
|
// seed the value, then save storageState for every test to reuse.
|
||||||
|
async function persistAdminStorageState(token: string): Promise<void> {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(`${DASHBOARD_ORIGIN}/admin/login`);
|
||||||
|
await page.evaluate(
|
||||||
|
([key, value]) => {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
},
|
||||||
|
['picloud.admin.token', token]
|
||||||
|
);
|
||||||
|
await context.storageState({ path: ADMIN_STATE_PATH });
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
158
dashboard/tests/e2e/integration/integration.spec.ts
Normal file
158
dashboard/tests/e2e/integration/integration.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { expect, request, type Page } from '@playwright/test';
|
||||||
|
import { test } from '../fixtures/ids';
|
||||||
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
|
import { adminApi } from '../fixtures/api';
|
||||||
|
|
||||||
|
// Full-stack integration scenarios. Unlike the per-page B1–B8 specs,
|
||||||
|
// these drive a complete user journey across multiple pages and then
|
||||||
|
// verify the data plane / API surface behaves the way the dashboard
|
||||||
|
// promised it would.
|
||||||
|
|
||||||
|
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
||||||
|
|
||||||
|
const cleanup = new CleanupRegistry();
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await cleanup.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fillCodeMirror(page: Page, locator: string, text: string): Promise<void> {
|
||||||
|
const cm = page.locator(locator).first();
|
||||||
|
await cm.click();
|
||||||
|
await page.keyboard.press('ControlOrMeta+A');
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.keyboard.type(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('end-to-end: app + domain + script + route via dashboard → invoke via public URL', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('public');
|
||||||
|
const domain = `${slug}.local`;
|
||||||
|
const routePath = `/${slug}/hello`;
|
||||||
|
const scriptName = `${slug}-hello`;
|
||||||
|
const scriptSource = `return #{ statusCode: 200, body: #{ source: "public", slug: "${slug}" } };`;
|
||||||
|
|
||||||
|
// 1. Create the app from the apps list.
|
||||||
|
await page.goto('/admin/apps');
|
||||||
|
await page.getByRole('button', { name: 'New app' }).click();
|
||||||
|
await page.getByLabel('Name').fill(slug);
|
||||||
|
const slugInput = page.getByLabel('Slug');
|
||||||
|
await slugInput.fill('');
|
||||||
|
await slugInput.fill(slug);
|
||||||
|
await page.getByRole('button', { name: 'Create app' }).click();
|
||||||
|
cleanup.app(slug);
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
|
||||||
|
|
||||||
|
// 2. Open the app and claim the domain on the Domains tab.
|
||||||
|
await page.getByRole('link', { name: new RegExp(slug) }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/admin/apps/${slug}$`));
|
||||||
|
await page.getByRole('button', { name: /^Domains \(\d+\)$/ }).click();
|
||||||
|
const domainForm = page.locator('form.create-form.inline');
|
||||||
|
await domainForm.getByPlaceholder(/app\.example\.com/).fill(domain);
|
||||||
|
await domainForm.getByRole('button', { name: /^Add domain$/ }).click();
|
||||||
|
await expect(page.locator('.domain-row')).toContainText(domain);
|
||||||
|
|
||||||
|
// 3. Create the script on the Scripts tab.
|
||||||
|
await page.getByRole('button', { name: /^Scripts \(\d+\)$/ }).click();
|
||||||
|
await page.getByRole('button', { name: /^New script$/ }).click();
|
||||||
|
await page.getByLabel('Name').fill(scriptName);
|
||||||
|
await fillCodeMirror(page, '.cm-content', scriptSource);
|
||||||
|
await page.getByRole('button', { name: /^Create script$/ }).click();
|
||||||
|
|
||||||
|
// 4. Open the script and bind a route on the Routing tab.
|
||||||
|
await page.getByRole('link', { name: new RegExp(scriptName) }).click();
|
||||||
|
await page.getByRole('button', { name: 'Routing' }).click();
|
||||||
|
await page.getByRole('button', { name: '+ Add route' }).click();
|
||||||
|
const routeForm = page.locator('form.route-form');
|
||||||
|
await routeForm.getByLabel('Path', { exact: true }).fill(routePath);
|
||||||
|
await routeForm.getByLabel('Method').selectOption('GET');
|
||||||
|
await routeForm.getByLabel(/^Host/).fill(domain);
|
||||||
|
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||||
|
await expect(page.locator('.route-list')).toContainText(routePath);
|
||||||
|
|
||||||
|
// 5. Invoke via the public URL, with the Host header pointing at
|
||||||
|
// the claimed domain. The dev backend listens on 127.0.0.1; the
|
||||||
|
// orchestrator resolves the app from Host, then the route.
|
||||||
|
const publicCtx = await request.newContext({ baseURL: API_BASE });
|
||||||
|
try {
|
||||||
|
const res = await publicCtx.get(routePath, { headers: { host: domain } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = (await res.json()) as { source: string; slug: string };
|
||||||
|
expect(body.source).toBe('public');
|
||||||
|
expect(body.slug).toBe(slug);
|
||||||
|
} finally {
|
||||||
|
await publicCtx.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('api key minted via dashboard works as a CLI bearer, then revoke disables it', async ({
|
||||||
|
page,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
// Worker-aware unique helper instead of Date.now() — keeps two
|
||||||
|
// workers from minting the same name on the same millisecond.
|
||||||
|
const name = uniqueUsername('cli');
|
||||||
|
|
||||||
|
// 1. Mint the key from /profile and capture the revealed token.
|
||||||
|
await page.goto('/admin/profile');
|
||||||
|
await page.getByRole('button', { name: /\+ Mint API key/ }).click();
|
||||||
|
const mintForm = page.locator('form.mint');
|
||||||
|
await mintForm.getByPlaceholder('e.g. ci-deploy').fill(name);
|
||||||
|
// script:read is enough to read the scripts list — that's our
|
||||||
|
// "CLI verb" below.
|
||||||
|
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
|
||||||
|
await page.getByRole('button', { name: /^Mint key$/ }).click();
|
||||||
|
|
||||||
|
const reveal = page.locator('.reveal');
|
||||||
|
await expect(reveal).toBeVisible();
|
||||||
|
const rawToken = (await reveal.locator('code.token').textContent())?.trim();
|
||||||
|
expect(rawToken).toBeTruthy();
|
||||||
|
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
|
||||||
|
await reveal.getByRole('button', { name: /^Done$/ }).click();
|
||||||
|
|
||||||
|
// 2. Act like a CLI: call the API directly with Bearer <token>.
|
||||||
|
const cli = await request.newContext({
|
||||||
|
baseURL: API_BASE,
|
||||||
|
extraHTTPHeaders: { authorization: `Bearer ${rawToken}` }
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const ok = await cli.get('/api/v1/admin/scripts');
|
||||||
|
expect(ok.status()).toBe(200);
|
||||||
|
const body = (await ok.json()) as unknown;
|
||||||
|
expect(Array.isArray(body)).toBe(true);
|
||||||
|
|
||||||
|
// Sanity: a route the scope doesn't cover must reject.
|
||||||
|
// `script:read` cannot list instance admins (that's
|
||||||
|
// instance:admin territory).
|
||||||
|
const denied = await cli.get('/api/v1/admin/admins');
|
||||||
|
expect(denied.status()).toBe(403);
|
||||||
|
|
||||||
|
// 3. Revoke via the dashboard.
|
||||||
|
await page.reload();
|
||||||
|
const revokeBtn = page.getByRole('button', { name: `Revoke ${name}` });
|
||||||
|
await expect(revokeBtn).toBeVisible();
|
||||||
|
await revokeBtn.click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: /^Revoke$/ }).click();
|
||||||
|
await expect(revokeBtn).toHaveCount(0);
|
||||||
|
|
||||||
|
// 4. Same CLI call must now fail auth.
|
||||||
|
const afterRevoke = await cli.get('/api/v1/admin/scripts');
|
||||||
|
expect(afterRevoke.status()).toBe(401);
|
||||||
|
} finally {
|
||||||
|
await cli.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Belt-and-braces cleanup: if the UI revoke missed, drop via API.
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const list = await api.get('/api/v1/admin/api-keys');
|
||||||
|
if (list.ok()) {
|
||||||
|
const all = (await list.json()) as Array<{ id: string; name: string }>;
|
||||||
|
const k = all.find((x) => x.name === name);
|
||||||
|
if (k) cleanup.apiKey(k.id);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
168
dashboard/tests/e2e/members/members.spec.ts
Normal file
168
dashboard/tests/e2e/members/members.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { expect } from '@playwright/test';
|
||||||
|
import { test } from '../fixtures/ids';
|
||||||
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
|
import { adminApi } from '../fixtures/api';
|
||||||
|
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
|
||||||
|
|
||||||
|
// Phase B5 — App Members. Setup creates one or two extra admin
|
||||||
|
// users via the API; tests drive the Members tab through the
|
||||||
|
// dashboard like a real app admin would.
|
||||||
|
|
||||||
|
const cleanup = new CleanupRegistry();
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await cleanup.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createApp(slug: string): Promise<string> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/v1/admin/apps', { data: { slug, name: slug } });
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
return ((await res.json()) as { id: string }).id;
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMemberUser(username: string): Promise<string> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/v1/admin/admins', {
|
||||||
|
data: { username, password: 'e2e-member-pw', instance_role: 'member' }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
return ((await res.json()) as { id: string }).id;
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('B5 app members', () => {
|
||||||
|
test('invite a member-role user, then remove them', async ({ page, uniqueSlug, uniqueUsername }) => {
|
||||||
|
const slug = uniqueSlug('mem');
|
||||||
|
const username = uniqueUsername('inv');
|
||||||
|
await createApp(slug);
|
||||||
|
const userId = await createMemberUser(username);
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
|
||||||
|
|
||||||
|
// Invite. Both selects sit in `form.create-form`; locate them
|
||||||
|
// by position to avoid getByLabel ambiguity (the Svelte
|
||||||
|
// markup nests both labels in a flex row, which makes their
|
||||||
|
// accessible names overlap).
|
||||||
|
const form = page.locator('form.create-form');
|
||||||
|
await form.locator('select').nth(0).selectOption({ label: username });
|
||||||
|
await form.locator('select').nth(1).selectOption('editor');
|
||||||
|
await page.getByRole('button', { name: /^Add member$/ }).click();
|
||||||
|
await expect(page.locator('.member-row')).toContainText(username);
|
||||||
|
|
||||||
|
// Remove via action menu + confirm modal.
|
||||||
|
await page.getByRole('button', { name: new RegExp(`Member actions for ${username}`) }).click();
|
||||||
|
await page.getByRole('menuitem', { name: /^Remove from app$/ }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Remove member$/ }).click();
|
||||||
|
await expect(page.locator('.member-row')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('role change via action menu updates the role chip', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('mem');
|
||||||
|
const username = uniqueUsername('role');
|
||||||
|
await createApp(slug);
|
||||||
|
const userId = await createMemberUser(username);
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
// Seed the membership via API to skip the invite UI.
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/api/v1/admin/apps/${slug}/members`, {
|
||||||
|
data: { user_id: userId, role: 'viewer' }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
|
||||||
|
await page.getByRole('button', { name: new RegExp(`Member actions for ${username}`) }).click();
|
||||||
|
await page.getByRole('menuitem', { name: /^Make editor$/ }).click();
|
||||||
|
|
||||||
|
const row = page.locator('.member-row', { hasText: username });
|
||||||
|
await expect(row).toContainText(/editor/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-app-admin viewers do not see the Members tab', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('mem');
|
||||||
|
const username = uniqueUsername('viewer');
|
||||||
|
const password = 'e2e-member-pw';
|
||||||
|
await createApp(slug);
|
||||||
|
const userId = await createMemberUser(username);
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
// Grant viewer membership (not app_admin) so the user can see
|
||||||
|
// the app at all.
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/api/v1/admin/apps/${slug}/members`, {
|
||||||
|
data: { user_id: userId, role: 'viewer' }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, password);
|
||||||
|
const viewerPage = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await viewerPage.goto(`/admin/apps/${slug}`);
|
||||||
|
// Scripts tab loads — that's what a viewer sees.
|
||||||
|
await expect(
|
||||||
|
viewerPage.getByRole('button', { name: /^Scripts \(\d+\)$/ })
|
||||||
|
).toBeVisible();
|
||||||
|
// Members tab button is absent for non-app-admins.
|
||||||
|
await expect(
|
||||||
|
viewerPage.getByRole('button', { name: /^Members \(\d+\)$/ })
|
||||||
|
).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await viewerPage.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B5 app members adversarial', () => {
|
||||||
|
test('role dropdown exposes only the documented values', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('mem');
|
||||||
|
const username = uniqueUsername('rolelist');
|
||||||
|
await createApp(slug);
|
||||||
|
const userId = await createMemberUser(username);
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
|
||||||
|
const form = page.locator('form.create-form');
|
||||||
|
const roleSelect = form.locator('select').nth(1);
|
||||||
|
const optionValues = await roleSelect.evaluate((el: HTMLSelectElement) =>
|
||||||
|
Array.from(el.options).map((o) => o.value)
|
||||||
|
);
|
||||||
|
expect(optionValues.sort()).toEqual(['app_admin', 'editor', 'viewer']);
|
||||||
|
});
|
||||||
|
});
|
||||||
150
dashboard/tests/e2e/profile/profile.spec.ts
Normal file
150
dashboard/tests/e2e/profile/profile.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import { test } from '../fixtures/ids';
|
||||||
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
|
import { adminApi } from '../fixtures/api';
|
||||||
|
|
||||||
|
// Phase B7 — Profile + API Keys (/admin/profile). Covers the
|
||||||
|
// mint/reveal/revoke flow, the app-binding mutual-exclusion guard,
|
||||||
|
// and adversarial inputs.
|
||||||
|
|
||||||
|
const cleanup = new CleanupRegistry();
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await cleanup.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createApp(slug: string): Promise<string> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/v1/admin/apps', { data: { slug, name: slug } });
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
return ((await res.json()) as { id: string }).id;
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMintForm(page: Page): Promise<void> {
|
||||||
|
await page.goto('/admin/profile');
|
||||||
|
await page.getByRole('button', { name: /\+ Mint API key/ }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerKeyCleanupByName(name: string): Promise<void> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.get('/api/v1/admin/api-keys');
|
||||||
|
const all = (await res.json()) as Array<{ id: string; name: string }>;
|
||||||
|
const k = all.find((x) => x.name === name);
|
||||||
|
if (k) cleanup.apiKey(k.id);
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('B7 profile + API keys', () => {
|
||||||
|
test('mint instance-wide key: reveal → ack → key appears in list', async ({ page }) => {
|
||||||
|
const name = `e2e-mint-${Date.now()}`;
|
||||||
|
await openMintForm(page);
|
||||||
|
await page.locator('form.mint').getByPlaceholder('e.g. ci-deploy').fill(name);
|
||||||
|
// Pick a non-instance scope so we don't need to worry about
|
||||||
|
// mutual exclusion here. The scope-chip is a <label> wrapping
|
||||||
|
// the checkbox — clicking the label toggles it.
|
||||||
|
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
|
||||||
|
await page.getByRole('button', { name: /^Mint key$/ }).click();
|
||||||
|
|
||||||
|
const reveal = page.locator('.reveal');
|
||||||
|
await expect(reveal).toBeVisible();
|
||||||
|
await expect(reveal.locator('code.token')).toContainText(/\S{16,}/);
|
||||||
|
await expect(reveal.getByRole('button', { name: /^Done$/ })).toBeDisabled();
|
||||||
|
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
|
||||||
|
await reveal.getByRole('button', { name: /^Done$/ }).click();
|
||||||
|
|
||||||
|
await registerKeyCleanupByName(name);
|
||||||
|
await expect(page.getByText(name)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('binding to an app disables instance scopes', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('keyapp');
|
||||||
|
const appId = await createApp(slug);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await openMintForm(page);
|
||||||
|
|
||||||
|
// Default binding is Instance-wide — instance scopes are
|
||||||
|
// enabled.
|
||||||
|
const instChip = page.locator('label.scope-chip', { hasText: 'instance:admin' });
|
||||||
|
await expect(instChip).not.toHaveClass(/disabled/);
|
||||||
|
|
||||||
|
// Switch binding to the app. The chip becomes disabled.
|
||||||
|
await page.getByLabel(/Binding/i).selectOption(appId);
|
||||||
|
await expect(instChip).toHaveClass(/disabled/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revoke key removes it from the list', async ({ page }) => {
|
||||||
|
const name = `e2e-revoke-${Date.now()}`;
|
||||||
|
// Seed a key via API so the test focuses on the revoke UI.
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/v1/admin/api-keys', {
|
||||||
|
data: { name, scopes: ['script:read'] }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
const body = (await res.json()) as { id: string };
|
||||||
|
cleanup.apiKey(body.id);
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto('/admin/profile');
|
||||||
|
const revokeBtn = page.getByRole('button', { name: `Revoke ${name}` });
|
||||||
|
await expect(revokeBtn).toBeVisible();
|
||||||
|
await revokeBtn.click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await dialog.getByRole('button', { name: /^Revoke$/ }).click();
|
||||||
|
// Assert the row's revoke button is gone (the flash banner
|
||||||
|
// also mentions the name, so a plain getByText would still
|
||||||
|
// match — anchor on the row-scoped button instead).
|
||||||
|
await expect(revokeBtn).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('denied=users banner shows when arriving from the users redirect', async ({ page }) => {
|
||||||
|
await page.goto('/admin/profile?denied=users');
|
||||||
|
await expect(page.getByText(/don.?t have access to the Users page/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B7 profile adversarial', () => {
|
||||||
|
test('empty name keeps the mint button disabled', async ({ page }) => {
|
||||||
|
await openMintForm(page);
|
||||||
|
// Trying to click would HTML5-validate; instead verify the
|
||||||
|
// button is disabled while name is empty.
|
||||||
|
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: /^Mint key$/ })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copy-token button copies the full token, not a truncated form', async ({
|
||||||
|
page,
|
||||||
|
context
|
||||||
|
}) => {
|
||||||
|
// Permission must be granted explicitly; chromium will throw
|
||||||
|
// otherwise when calling navigator.clipboard.readText().
|
||||||
|
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||||
|
|
||||||
|
const name = `e2e-copy-${Date.now()}`;
|
||||||
|
await openMintForm(page);
|
||||||
|
await page.locator('form.mint').getByPlaceholder('e.g. ci-deploy').fill(name);
|
||||||
|
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
|
||||||
|
await page.getByRole('button', { name: /^Mint key$/ }).click();
|
||||||
|
|
||||||
|
const reveal = page.locator('.reveal');
|
||||||
|
const tokenInDom = await reveal.locator('code.token').textContent();
|
||||||
|
expect(tokenInDom).toBeTruthy();
|
||||||
|
await reveal.getByRole('button', { name: /^Copy$/ }).click();
|
||||||
|
const copied = await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
expect(copied).toBe(tokenInDom);
|
||||||
|
|
||||||
|
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
|
||||||
|
await reveal.getByRole('button', { name: /^Done$/ }).click();
|
||||||
|
await registerKeyCleanupByName(name);
|
||||||
|
});
|
||||||
|
});
|
||||||
189
dashboard/tests/e2e/routing/routing.spec.ts
Normal file
189
dashboard/tests/e2e/routing/routing.spec.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import { test } from '../fixtures/ids';
|
||||||
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
|
import { adminApi } from '../fixtures/api';
|
||||||
|
|
||||||
|
// Phase B4 — Routing tab in the script editor. Add / remove / match
|
||||||
|
// preview + validation paths (host check, path-kind mismatch, reserved
|
||||||
|
// prefix, duplicate conflict, adversarial paths).
|
||||||
|
|
||||||
|
const HELLO_RHAI = `return #{ statusCode: 200, body: #{ ok: true } };`;
|
||||||
|
|
||||||
|
const cleanup = new CleanupRegistry();
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await cleanup.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function makeAppWithScript(slug: string): Promise<{ appId: string; scriptId: string }> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const appRes = await api.post('/api/v1/admin/apps', {
|
||||||
|
data: { slug, name: slug }
|
||||||
|
});
|
||||||
|
expect(appRes.ok()).toBe(true);
|
||||||
|
const appBody = (await appRes.json()) as { id: string };
|
||||||
|
|
||||||
|
const scriptRes = await api.post('/api/v1/admin/scripts', {
|
||||||
|
data: { app_id: appBody.id, name: 'route-target', source: HELLO_RHAI }
|
||||||
|
});
|
||||||
|
expect(scriptRes.ok()).toBe(true);
|
||||||
|
const scriptBody = (await scriptRes.json()) as { id: string };
|
||||||
|
|
||||||
|
return { appId: appBody.id, scriptId: scriptBody.id };
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gotoRoutingTab(page: Page, scriptId: string): Promise<void> {
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
await page.getByRole('button', { name: 'Routing' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRoute(
|
||||||
|
page: Page,
|
||||||
|
opts: { path: string; pathKind?: 'exact' | 'param' | 'prefix'; method?: string; host?: string }
|
||||||
|
): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: '+ Add route' }).click();
|
||||||
|
const form = page.locator('form.route-form');
|
||||||
|
await form.getByLabel('Path', { exact: true }).fill(opts.path);
|
||||||
|
if (opts.pathKind) {
|
||||||
|
await form.getByLabel('Path kind').selectOption(opts.pathKind);
|
||||||
|
}
|
||||||
|
if (opts.method !== undefined) {
|
||||||
|
await form.getByLabel('Method').selectOption(opts.method);
|
||||||
|
}
|
||||||
|
if (opts.host !== undefined) {
|
||||||
|
await form.getByLabel(/^Host/).fill(opts.host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('B4 routing', () => {
|
||||||
|
test('add route appears in list and matches in the preview', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('addr');
|
||||||
|
const { scriptId } = await makeAppWithScript(slug);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await gotoRoutingTab(page, scriptId);
|
||||||
|
await addRoute(page, { path: '/greet', method: 'GET' });
|
||||||
|
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('.route-list')).toContainText('/greet');
|
||||||
|
|
||||||
|
// Match preview confirms the route resolves.
|
||||||
|
await page.getByLabel('URL').fill('http://localhost/greet');
|
||||||
|
await page.locator('.actions').getByRole('button', { name: 'Match' }).click();
|
||||||
|
await expect(page.locator('pre.preview')).toContainText('script_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remove route updates the list', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('remr');
|
||||||
|
const { scriptId } = await makeAppWithScript(slug);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await gotoRoutingTab(page, scriptId);
|
||||||
|
await addRoute(page, { path: '/transient', method: 'GET' });
|
||||||
|
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||||
|
await expect(page.locator('.route-list')).toContainText('/transient');
|
||||||
|
|
||||||
|
// removeRoute() uses window.confirm — accept it.
|
||||||
|
page.once('dialog', (d) => void d.accept());
|
||||||
|
await page.locator('.route-list').getByRole('button', { name: 'remove' }).click();
|
||||||
|
await expect(page.locator('.route-list')).toHaveCount(0);
|
||||||
|
await expect(page.getByText(/no routes yet/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicate route surfaces a 409 conflict error inline', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('dupr');
|
||||||
|
const { scriptId } = await makeAppWithScript(slug);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await gotoRoutingTab(page, scriptId);
|
||||||
|
await addRoute(page, { path: '/twice', method: 'GET' });
|
||||||
|
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||||
|
await expect(page.locator('.route-list')).toContainText('/twice');
|
||||||
|
|
||||||
|
// Same path + method again — must conflict.
|
||||||
|
await addRoute(page, { path: '/twice', method: 'GET' });
|
||||||
|
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||||
|
await expect(page.locator('.route-form .error.inline')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('path-kind mismatch warns inline when /:name is set to exact', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('mism');
|
||||||
|
const { scriptId } = await makeAppWithScript(slug);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await gotoRoutingTab(page, scriptId);
|
||||||
|
await page.getByRole('button', { name: '+ Add route' }).click();
|
||||||
|
await page.getByLabel('Path', { exact: true }).fill('/users/:id');
|
||||||
|
// Override to a wrong kind — auto-detect would have picked
|
||||||
|
// `param`; selecting `exact` should fire the warning.
|
||||||
|
await page.getByLabel('Path kind').selectOption('exact');
|
||||||
|
await expect(page.locator('.route-form .warning.inline')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('host validation warns when the host is not a claimed domain', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('unclaim');
|
||||||
|
const { scriptId } = await makeAppWithScript(slug);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await gotoRoutingTab(page, scriptId);
|
||||||
|
await page.getByRole('button', { name: '+ Add route' }).click();
|
||||||
|
await page.getByLabel('Path', { exact: true }).fill('/x');
|
||||||
|
await page.getByLabel(/^Host/).fill('example.test-not-claimed.local');
|
||||||
|
// One of the inline warnings is the unclaimed-host explainer.
|
||||||
|
await expect(page.locator('.route-form .warning.inline').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B4 routing adversarial', () => {
|
||||||
|
test('reserved prefix /api/ is rejected with a visible error', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('reserv');
|
||||||
|
const { scriptId } = await makeAppWithScript(slug);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await gotoRoutingTab(page, scriptId);
|
||||||
|
await addRoute(page, { path: '/api/v9/oops', method: 'GET' });
|
||||||
|
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||||
|
await expect(page.locator('.route-form .error.inline')).toBeVisible();
|
||||||
|
await expect(page.locator('.route-form .error.inline')).toContainText(
|
||||||
|
/reserved|api|prefix/i
|
||||||
|
);
|
||||||
|
// Empty-state copy renders when no routes exist; the path
|
||||||
|
// itself must not appear anywhere on the routing tab.
|
||||||
|
await expect(page.getByText(/no routes yet/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('xss payload in path stored or rejected — never executes on render', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
page.on('dialog', async (d) => {
|
||||||
|
await d.dismiss();
|
||||||
|
throw new Error(`Unexpected dialog: ${d.message()}`);
|
||||||
|
});
|
||||||
|
const slug = uniqueSlug('pxss');
|
||||||
|
const { scriptId } = await makeAppWithScript(slug);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await gotoRoutingTab(page, scriptId);
|
||||||
|
await addRoute(page, {
|
||||||
|
path: '/<script>alert(1)</script>',
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||||
|
|
||||||
|
// Either accepted (rendered as text in the list) or rejected
|
||||||
|
// (error inline). Both fine — what's NOT fine is an alert
|
||||||
|
// dialog or an injected <script> tag in the list.
|
||||||
|
const xssScripts = await page.locator('.route-list script:has-text("alert")').count();
|
||||||
|
expect(xssScripts).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
337
dashboard/tests/e2e/scripts/scripts.spec.ts
Normal file
337
dashboard/tests/e2e/scripts/scripts.spec.ts
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import { test } from '../fixtures/ids';
|
||||||
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
|
import { adminApi } from '../fixtures/api';
|
||||||
|
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
|
||||||
|
|
||||||
|
const MEMBER_PW = 'e2e-member-pw';
|
||||||
|
|
||||||
|
async function seedAppScriptAndMember(opts: {
|
||||||
|
slug: string;
|
||||||
|
username: string;
|
||||||
|
role: 'viewer' | 'editor';
|
||||||
|
}): Promise<{ scriptId: string; userId: string }> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const appRes = await api.post('/api/v1/admin/apps', {
|
||||||
|
data: { slug: opts.slug, name: opts.slug }
|
||||||
|
});
|
||||||
|
expect(appRes.ok()).toBe(true);
|
||||||
|
const appId = ((await appRes.json()) as { id: string }).id;
|
||||||
|
const scriptRes = await api.post('/api/v1/admin/scripts', {
|
||||||
|
data: { app_id: appId, name: `${opts.slug}-sc`, source: HELLO_RHAI }
|
||||||
|
});
|
||||||
|
expect(scriptRes.ok()).toBe(true);
|
||||||
|
const scriptId = ((await scriptRes.json()) as { id: string }).id;
|
||||||
|
const userRes = await api.post('/api/v1/admin/admins', {
|
||||||
|
data: { username: opts.username, password: MEMBER_PW, instance_role: 'member' }
|
||||||
|
});
|
||||||
|
expect(userRes.ok()).toBe(true);
|
||||||
|
const userId = ((await userRes.json()) as { id: string }).id;
|
||||||
|
const memberRes = await api.post(`/api/v1/admin/apps/${opts.slug}/members`, {
|
||||||
|
data: { user_id: userId, role: opts.role }
|
||||||
|
});
|
||||||
|
expect(memberRes.ok()).toBe(true);
|
||||||
|
return { scriptId, userId };
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase B3 — Scripts CRUD + Editor. The script editor lives at
|
||||||
|
// /admin/scripts/{id}. Setup uses the API to create the app (and
|
||||||
|
// sometimes a baseline script) so each test can focus on the editor
|
||||||
|
// flow it actually covers.
|
||||||
|
|
||||||
|
const HELLO_RHAI = `return #{ statusCode: 200, body: #{ ok: true } };`;
|
||||||
|
|
||||||
|
const cleanup = new CleanupRegistry();
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await cleanup.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createAppViaApi(slug: string): Promise<string> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/v1/admin/apps', {
|
||||||
|
data: { slug, name: slug }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
const body = (await res.json()) as { id: string };
|
||||||
|
return body.id;
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createScriptViaApi(
|
||||||
|
appId: string,
|
||||||
|
name: string,
|
||||||
|
source = HELLO_RHAI
|
||||||
|
): Promise<string> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/v1/admin/scripts', {
|
||||||
|
data: { app_id: appId, name, source }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
const body = (await res.json()) as { id: string };
|
||||||
|
return body.id;
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillCodeMirror(page: Page, locator: string, text: string): Promise<void> {
|
||||||
|
const cm = page.locator(locator).first();
|
||||||
|
await cm.click();
|
||||||
|
await page.keyboard.press('ControlOrMeta+A');
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.keyboard.type(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('B3 scripts CRUD', () => {
|
||||||
|
test('create script via UI navigates to scripts list with the new entry', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('cscr');
|
||||||
|
await createAppViaApi(slug);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
await page.getByRole('button', { name: /^New script$/ }).click();
|
||||||
|
await page.getByLabel('Name').fill('echo');
|
||||||
|
// The CodeMirror editor starts empty in create mode; type a
|
||||||
|
// minimal valid script.
|
||||||
|
await fillCodeMirror(page, '.cm-content', HELLO_RHAI);
|
||||||
|
await page.getByRole('button', { name: 'Create script' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: /echo/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edit + save Rhai source persists across reload', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('edit');
|
||||||
|
const appId = await createAppViaApi(slug);
|
||||||
|
const scriptId = await createScriptViaApi(appId, 'edit-target');
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
await expect(page.locator('.cm-content').first()).toContainText('statusCode');
|
||||||
|
|
||||||
|
const updated = `// edited by e2e\nreturn #{ statusCode: 201, body: #{ edited: true } };`;
|
||||||
|
await fillCodeMirror(page, '.cm-content', updated);
|
||||||
|
await page.getByRole('button', { name: /^Save$/ }).click();
|
||||||
|
// Save button becomes disabled once the buffer matches the
|
||||||
|
// just-saved source — that's our settle signal.
|
||||||
|
await expect(page.getByRole('button', { name: /^Save$/ })).toBeDisabled();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('.cm-content').first()).toContainText('edited by e2e');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid Rhai source: Format shows a parse error', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('invrhai');
|
||||||
|
const appId = await createAppViaApi(slug);
|
||||||
|
const scriptId = await createScriptViaApi(appId, 'bad-syntax');
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
await fillCodeMirror(page, '.cm-content', 'this is not rhai @@@ {{{');
|
||||||
|
await page
|
||||||
|
.locator('.editor-header')
|
||||||
|
.getByRole('button', { name: 'Format' })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(page.locator('.error.inline').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B3 test-invoke', () => {
|
||||||
|
test('valid JSON body returns status + body in the result panel', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('inv-ok');
|
||||||
|
const appId = await createAppViaApi(slug);
|
||||||
|
const scriptId = await createScriptViaApi(appId, 'invoke-ok');
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
// Body editor is the second .cm-content (source is first).
|
||||||
|
const bodyEditor = page.locator('.cm-content').nth(1);
|
||||||
|
await bodyEditor.click();
|
||||||
|
await page.keyboard.press('ControlOrMeta+A');
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.keyboard.type('{"hello":"world"}');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Send$/ }).click();
|
||||||
|
await expect(page.locator('.status')).toContainText('HTTP 200');
|
||||||
|
await expect(page.locator('.result pre')).toContainText('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed JSON body: Format surfaces the parse error', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('inv-bad');
|
||||||
|
const appId = await createAppViaApi(slug);
|
||||||
|
const scriptId = await createScriptViaApi(appId, 'invoke-bad');
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
const bodyEditor = page.locator('.cm-content').nth(1);
|
||||||
|
await bodyEditor.click();
|
||||||
|
await page.keyboard.press('ControlOrMeta+A');
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.keyboard.type('{not valid json,');
|
||||||
|
|
||||||
|
// The Format button for the request body sits inside the
|
||||||
|
// Test-invoke card next to the body editor.
|
||||||
|
await page
|
||||||
|
.locator('.json-block')
|
||||||
|
.first()
|
||||||
|
.getByRole('button', { name: 'Format' })
|
||||||
|
.click();
|
||||||
|
await expect(page.locator('.error.inline').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B3 settings', () => {
|
||||||
|
test('timeout input rejects zero and non-positive values', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('settz');
|
||||||
|
const appId = await createAppViaApi(slug);
|
||||||
|
const scriptId = await createScriptViaApi(appId, 'settings-target');
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
await page.getByRole('button', { name: 'Settings' }).click();
|
||||||
|
const timeout = page.getByLabel(/Timeout/);
|
||||||
|
await timeout.fill('0');
|
||||||
|
const invalid = await timeout.evaluate((el: HTMLInputElement) => !el.validity.valid);
|
||||||
|
expect(invalid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B3 scripts role shadowing', () => {
|
||||||
|
test('viewer: no Delete header, no Save/Format on Edit, no Add route on Routing', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('vscr');
|
||||||
|
const username = uniqueUsername('viewer');
|
||||||
|
const { scriptId, userId } = await seedAppScriptAndMember({
|
||||||
|
slug,
|
||||||
|
username,
|
||||||
|
role: 'viewer'
|
||||||
|
});
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
// Header Delete is hidden for non-admins.
|
||||||
|
await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0);
|
||||||
|
// Save/Format on the Edit tab are hidden for viewers.
|
||||||
|
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.locator('.editor-header').getByRole('button', { name: 'Format' })
|
||||||
|
).toHaveCount(0);
|
||||||
|
// Test invoke is still visible (everyone with read access).
|
||||||
|
await expect(page.getByRole('button', { name: /^Send$/ })).toBeVisible();
|
||||||
|
// Routing tab loads, no +Add route.
|
||||||
|
await page.getByRole('button', { name: /Routing/ }).click();
|
||||||
|
await expect(page.getByRole('button', { name: /\+ Add route/ })).toHaveCount(0);
|
||||||
|
// Settings tab is absent for non-admins.
|
||||||
|
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewer: CodeMirror is read-only', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('vro');
|
||||||
|
const username = uniqueUsername('viewer');
|
||||||
|
const { scriptId, userId } = await seedAppScriptAndMember({
|
||||||
|
slug,
|
||||||
|
username,
|
||||||
|
role: 'viewer'
|
||||||
|
});
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
const cm = page.locator('.cm-content').first();
|
||||||
|
await expect(cm).toBeVisible();
|
||||||
|
// CodeMirror sets contenteditable=false when EditorView.editable.of(false)
|
||||||
|
// is in effect; that's the canonical signal for read-only mode.
|
||||||
|
await expect(cm).toHaveAttribute('contenteditable', 'false');
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editor: Save visible, Delete header hidden', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('escr');
|
||||||
|
const username = uniqueUsername('editor');
|
||||||
|
const { scriptId, userId } = await seedAppScriptAndMember({
|
||||||
|
slug,
|
||||||
|
username,
|
||||||
|
role: 'editor'
|
||||||
|
});
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
// Editor sees Save (disabled until the buffer changes — that's fine).
|
||||||
|
await expect(page.getByRole('button', { name: /^Save$/ })).toBeVisible();
|
||||||
|
// Delete stays admin-only.
|
||||||
|
await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0);
|
||||||
|
// Settings stays admin-only.
|
||||||
|
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B3 adversarial', () => {
|
||||||
|
test('infinite loop script hits the sandbox timeout', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('loop');
|
||||||
|
const appId = await createAppViaApi(slug);
|
||||||
|
const scriptId = await createScriptViaApi(
|
||||||
|
appId,
|
||||||
|
'inf-loop',
|
||||||
|
'loop { let x = 1; }'
|
||||||
|
);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
await page.getByRole('button', { name: /^Send$/ }).click();
|
||||||
|
|
||||||
|
// Either the status renders with a 5xx code, or an error
|
||||||
|
// banner shows up. Either way, the page recovers.
|
||||||
|
await Promise.race([
|
||||||
|
expect(page.locator('.status')).toBeVisible({ timeout: 30_000 }),
|
||||||
|
expect(page.locator('.error.inline').last()).toBeVisible({ timeout: 30_000 })
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The dashboard must remain interactive after the timeout.
|
||||||
|
await page.getByRole('button', { name: 'Settings' }).click();
|
||||||
|
await expect(page.getByLabel(/Timeout/)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
81
dashboard/tests/e2e/security/security.spec.ts
Normal file
81
dashboard/tests/e2e/security/security.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
// Phase B8 — Cross-cutting security. Things that aren't tied to a
|
||||||
|
// single page: session handling, secret leakage, error states for
|
||||||
|
// missing resources, and a sanity check that no XSS sink fires
|
||||||
|
// anywhere in the dashboard's main authed routes.
|
||||||
|
|
||||||
|
const VALID_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||||
|
const VALID_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
|
||||||
|
|
||||||
|
test.describe('B8 cross-cutting security', () => {
|
||||||
|
test('expired/stale token: any authed call redirects to /login', async ({ page }) => {
|
||||||
|
// Replace the storageState token with an obvious garbage
|
||||||
|
// value; the fetch wrapper treats 401 as "go to /login".
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.setItem('picloud.admin.token', 'expired-or-bogus-token');
|
||||||
|
});
|
||||||
|
await page.goto('/admin/apps');
|
||||||
|
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login response cookie is HttpOnly', async ({ request }) => {
|
||||||
|
const res = await request.post('/api/v1/admin/auth/login', {
|
||||||
|
data: { username: VALID_USERNAME, password: VALID_PASSWORD },
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
const headers = res.headers();
|
||||||
|
const setCookie = headers['set-cookie'];
|
||||||
|
// Backend may or may not set a cookie (the dashboard primarily
|
||||||
|
// uses bearer-in-localStorage). If it does, it must be
|
||||||
|
// HttpOnly so XSS can't exfiltrate it.
|
||||||
|
if (setCookie) {
|
||||||
|
expect(setCookie.toLowerCase()).toContain('httponly');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bootstrap password is not present in the DOM after login', async ({ page }) => {
|
||||||
|
await page.goto('/admin/apps');
|
||||||
|
const body = await page.locator('body').innerText();
|
||||||
|
expect(body).not.toContain(VALID_PASSWORD);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-existent app slug shows a recoverable error, not a crash', async ({ page }) => {
|
||||||
|
await page.goto('/admin/apps/does-not-exist-e2e-9999');
|
||||||
|
// Page must render *something* and the layout must remain
|
||||||
|
// intact (header link to Apps still works).
|
||||||
|
await expect(page.getByRole('link', { name: 'Apps' })).toBeVisible();
|
||||||
|
// And surface the failure to the user — either a "couldn't
|
||||||
|
// load" message or a "back to apps" link.
|
||||||
|
const errorOrBack = page.locator('.error, a[href$="/admin/apps"]');
|
||||||
|
await expect(errorOrBack.first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('xss probe across major surfaces never fires a dialog', async ({ page }) => {
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
await dialog.dismiss();
|
||||||
|
throw new Error(
|
||||||
|
`XSS sink fired — got a ${dialog.type()} dialog: "${dialog.message()}"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cover each main authed route. None should evaluate any
|
||||||
|
// payload that earlier tests may have stored, and none should
|
||||||
|
// inject inline <script> tags from server responses.
|
||||||
|
for (const path of ['/admin/apps', '/admin/profile', '/admin/users']) {
|
||||||
|
await page.goto(path);
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
const inlineScripts = await page.locator('script[src=""], script:not([src])').count();
|
||||||
|
// Svelte itself injects no inline <script> in the
|
||||||
|
// production bundle; vite dev does, but never with
|
||||||
|
// onerror/alert payload text in them.
|
||||||
|
const evilInline = await page
|
||||||
|
.locator('script:has-text("alert"), script:has-text("__xss")')
|
||||||
|
.count();
|
||||||
|
expect(evilInline, `evil inline script tag on ${path}`).toBe(0);
|
||||||
|
expect(inlineScripts).toBeGreaterThanOrEqual(0); // sanity assertion, no crash
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
28
dashboard/tests/e2e/smoke.spec.ts
Normal file
28
dashboard/tests/e2e/smoke.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { loginAsAdmin } from './fixtures/auth';
|
||||||
|
|
||||||
|
// A1 smoke: prove globalSetup + webServer + fixtures + proxy all work.
|
||||||
|
|
||||||
|
test.describe('smoke', () => {
|
||||||
|
test.describe('unauthenticated', () => {
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
test('root redirects to login and shows the form', async ({ page }) => {
|
||||||
|
await page.goto('/admin/');
|
||||||
|
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||||
|
await expect(page.getByLabel('Username')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Password')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('valid credentials land on the apps page', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await expect(page.getByRole('link', { name: 'Apps' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin storageState already lands on apps', async ({ page }) => {
|
||||||
|
await page.goto('/admin/');
|
||||||
|
await expect(page).toHaveURL(/\/admin\/apps$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
224
dashboard/tests/e2e/users/users.spec.ts
Normal file
224
dashboard/tests/e2e/users/users.spec.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { expect, type Browser, type Page, request } from '@playwright/test';
|
||||||
|
import { test } from '../fixtures/ids';
|
||||||
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
|
import { adminApi } from '../fixtures/api';
|
||||||
|
|
||||||
|
// Phase B6 — Instance Users (/admin/users). Covers the bootstrap
|
||||||
|
// admin's view of the user directory: invite, edit, deactivate,
|
||||||
|
// search, delete, plus the member-role redirect and adversarial
|
||||||
|
// inputs to the invite form.
|
||||||
|
|
||||||
|
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
||||||
|
|
||||||
|
const cleanup = new CleanupRegistry();
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await cleanup.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createMember(username: string, password = 'e2e-member-pw'): Promise<string> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/v1/admin/admins', {
|
||||||
|
data: { username, password, instance_role: 'member' }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
return ((await res.json()) as { id: string }).id;
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginToken(username: string, password: string): Promise<string> {
|
||||||
|
const ctx = await request.newContext({ baseURL: API_BASE });
|
||||||
|
try {
|
||||||
|
const res = await ctx.post('/api/v1/admin/auth/login', {
|
||||||
|
data: { username, password },
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
return ((await res.json()) as { token: string }).token;
|
||||||
|
} finally {
|
||||||
|
await ctx.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pageWithToken(browser: Browser, token: string): Promise<Page> {
|
||||||
|
const ctx = await browser.newContext({ storageState: undefined });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.evaluate(
|
||||||
|
([key, value]) => {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
},
|
||||||
|
['picloud.admin.token', token]
|
||||||
|
);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('B6 instance users', () => {
|
||||||
|
test('invite happy path: form → reveal modal → user in list', async ({
|
||||||
|
page,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const username = uniqueUsername('inv');
|
||||||
|
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await page.getByRole('button', { name: '+ Invite user' }).click();
|
||||||
|
const modal = page.locator('form.modal');
|
||||||
|
await modal.getByLabel('Username').fill(username);
|
||||||
|
await modal.getByRole('radio', { name: /^Member/ }).check();
|
||||||
|
await modal.getByRole('button', { name: /^Create user$/ }).click();
|
||||||
|
|
||||||
|
// Reveal modal shows the one-time password.
|
||||||
|
const reveal = page.locator('.reveal-modal');
|
||||||
|
await expect(reveal).toBeVisible();
|
||||||
|
await expect(reveal).toContainText(/User created — /);
|
||||||
|
await expect(reveal.getByRole('button', { name: /^Done$/ })).toBeDisabled();
|
||||||
|
await reveal.getByRole('checkbox', { name: /shared this/i }).check();
|
||||||
|
await reveal.getByRole('button', { name: /^Done$/ }).click();
|
||||||
|
|
||||||
|
// Now in the table.
|
||||||
|
await expect(page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username })).toBeVisible();
|
||||||
|
|
||||||
|
// API cleanup — we don't have the user id from the UI alone.
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const list = await api.get('/api/v1/admin/admins');
|
||||||
|
const all = (await list.json()) as Array<{ id: string; username: string }>;
|
||||||
|
const u = all.find((x) => x.username === username);
|
||||||
|
if (u) cleanup.adminUser(u.id);
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('username live validation: bad chars → submit disabled', async ({ page }) => {
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await page.getByRole('button', { name: '+ Invite user' }).click();
|
||||||
|
const modal = page.locator('form.modal');
|
||||||
|
await modal.getByLabel('Username').fill('UPPER_CASE_invalid');
|
||||||
|
await expect(modal.locator('small.invalid')).toContainText(/allowed pattern/i);
|
||||||
|
await modal.getByRole('radio', { name: /^Member/ }).check();
|
||||||
|
await expect(modal.getByRole('button', { name: /^Create user$/ })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search filters the table by username', async ({ page, uniqueUsername }) => {
|
||||||
|
const target = uniqueUsername('hit');
|
||||||
|
const decoy = uniqueUsername('miss');
|
||||||
|
const ids = await Promise.all([createMember(target), createMember(decoy)]);
|
||||||
|
ids.forEach((id) => cleanup.adminUser(id));
|
||||||
|
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await page.getByPlaceholder(/Search by username/).fill(target);
|
||||||
|
await expect(page.locator('.row', { hasText: target })).toBeVisible();
|
||||||
|
await expect(page.locator('.row', { hasText: decoy })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deactivate confirm modal: Cancel keeps active, Deactivate flips, reactivate is one click', async ({
|
||||||
|
page,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const username = uniqueUsername('toggle');
|
||||||
|
const userId = await createMember(username);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await page.getByPlaceholder(/Search by username/).fill(username);
|
||||||
|
const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
|
// Deactivate opens the confirm modal.
|
||||||
|
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
||||||
|
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog).toContainText(username);
|
||||||
|
|
||||||
|
// Cancel leaves the user active.
|
||||||
|
await dialog.getByRole('button', { name: /^Cancel$/ }).click();
|
||||||
|
await expect(dialog).toHaveCount(0);
|
||||||
|
await expect(row).not.toContainText(/inactive/i);
|
||||||
|
|
||||||
|
// Open again and confirm — user becomes inactive.
|
||||||
|
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
||||||
|
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: /^Deactivate$/ }).click();
|
||||||
|
await expect(row).toContainText(/inactive/i);
|
||||||
|
|
||||||
|
// Reactivate is still one-click (non-destructive — no modal).
|
||||||
|
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
||||||
|
await page.getByRole('menuitem', { name: /^Reactivate$/ }).click();
|
||||||
|
await expect(row).not.toContainText(/inactive/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete: wrong phrase keeps disabled, right phrase removes the user', async ({
|
||||||
|
page,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const username = uniqueUsername('del');
|
||||||
|
const userId = await createMember(username);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await page.getByPlaceholder(/Search by username/).fill(username);
|
||||||
|
const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username });
|
||||||
|
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
||||||
|
await page.getByRole('menuitem', { name: /^Delete$/ }).click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
const confirm = dialog.getByRole('button', { name: /^Delete user$/ });
|
||||||
|
await expect(confirm).toBeDisabled();
|
||||||
|
await dialog.getByRole('textbox').fill('not-the-username');
|
||||||
|
await expect(confirm).toBeDisabled();
|
||||||
|
await dialog.getByRole('textbox').fill(username);
|
||||||
|
await expect(confirm).toBeEnabled();
|
||||||
|
await confirm.click();
|
||||||
|
|
||||||
|
await expect(page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('member-role user visiting /admin/users is bounced to profile with denied banner', async ({
|
||||||
|
browser,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const username = uniqueUsername('memvw');
|
||||||
|
const password = 'e2e-member-pw';
|
||||||
|
const userId = await createMember(username, password);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginToken(username, password);
|
||||||
|
const memberPage = await pageWithToken(browser, token);
|
||||||
|
try {
|
||||||
|
await memberPage.goto('/admin/users');
|
||||||
|
await expect(memberPage).toHaveURL(/\/admin\/profile\?denied=users$/);
|
||||||
|
await expect(memberPage.getByText(/don.?t have access to the Users page/i)).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await memberPage.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B6 instance users adversarial', () => {
|
||||||
|
test('username too short: live invalid + submit disabled', async ({ page }) => {
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await page.getByRole('button', { name: '+ Invite user' }).click();
|
||||||
|
const modal = page.locator('form.modal');
|
||||||
|
await modal.getByLabel('Username').fill('a'); // 1 char — minimum is 2
|
||||||
|
await expect(modal.locator('small.invalid')).toBeVisible();
|
||||||
|
await modal.getByRole('radio', { name: /^Member/ }).check();
|
||||||
|
await expect(modal.getByRole('button', { name: /^Create user$/ })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('email with script tag fails validation, never executes', async ({ page }) => {
|
||||||
|
page.on('dialog', async (d) => {
|
||||||
|
await d.dismiss();
|
||||||
|
throw new Error(`Unexpected dialog: ${d.message()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await page.getByRole('button', { name: '+ Invite user' }).click();
|
||||||
|
const modal = page.locator('form.modal');
|
||||||
|
await modal.getByLabel(/Email/).fill('<script>alert(1)</script>@x');
|
||||||
|
await expect(modal.locator('small.invalid')).toContainText(/email/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@ import { defineConfig } from 'vitest/config';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
include: ['src/lib/rhai/**/*.test.ts'],
|
include: ['src/lib/**/*.test.ts'],
|
||||||
environment: 'node'
|
environment: 'node'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ services:
|
|||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${PICLOUD_HOST_PORT:-8000}:80"
|
- "${PICLOUD_HOST_PORT:-8000}:80"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
227
docs/sdk-shape.md
Normal file
227
docs/sdk-shape.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# SDK shape (v1.1.x stateful services)
|
||||||
|
|
||||||
|
This document describes the architectural shape every v1.1.x SDK
|
||||||
|
service follows. It is **not** a feature reference for any particular
|
||||||
|
service — those live in their own docs as each PR lands (KV in v1.1.1,
|
||||||
|
docs in v1.1.2, …). What follows is the contract those PRs implement
|
||||||
|
against, so the surface stays consistent and the build doesn't drift.
|
||||||
|
|
||||||
|
The shape was laid down in v1.1.0 (the SDK foundation PR). If you find
|
||||||
|
yourself re-litigating any of it inside a service PR, push back and
|
||||||
|
update this doc explicitly first.
|
||||||
|
|
||||||
|
## Two kinds of Rhai modules
|
||||||
|
|
||||||
|
**Stateless utility modules** (regex, time, json, base64, hex, url —
|
||||||
|
landing as v1.1.0's stdlib PR) are registered once at engine build.
|
||||||
|
They have no per-call state and no cross-app sensitivity. Implementation
|
||||||
|
goes in `executor-core::engine::build_engine` next to the existing
|
||||||
|
`log::` registration. They use Rhai's `register_static_module`.
|
||||||
|
|
||||||
|
**Stateful service modules** (kv, docs, http, cron, files, pubsub,
|
||||||
|
secrets, email, users, queue, invoke) are registered **per call** by
|
||||||
|
`executor-core::sdk::register_all`. They need:
|
||||||
|
|
||||||
|
- A service handle bundled in `picloud_shared::Services` (constructed
|
||||||
|
once at startup, cloned cheaply per call).
|
||||||
|
- A per-call `SdkCallCx` carrying the calling app, principal,
|
||||||
|
execution ids, and trigger depth.
|
||||||
|
- Closures that capture both, registered as Rhai native functions
|
||||||
|
inside a per-call `rhai::Module`.
|
||||||
|
|
||||||
|
Mixing the two categories in one module is wrong — services that
|
||||||
|
internally consult per-call context are stateful, period.
|
||||||
|
|
||||||
|
## `::` namespace style
|
||||||
|
|
||||||
|
Every SDK module exposes itself under a `::` namespace, mirroring the
|
||||||
|
existing `log::`:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
log::info("hello"); // v1.0 — present
|
||||||
|
let value = kv::collection("widgets").get("k"); // v1.1.1
|
||||||
|
let resp = http::get("https://example.com"); // v1.1.4
|
||||||
|
```
|
||||||
|
|
||||||
|
Dotted-object syntax (`kv.get("widgets", "k")`) is **not** used.
|
||||||
|
Rationale: `::` is consistent with Rust import syntax, doesn't
|
||||||
|
require a wrapper "module object" in Rhai's scope, and keeps the
|
||||||
|
module boundary obvious in scripts.
|
||||||
|
|
||||||
|
## Handle pattern for collection-scoped services
|
||||||
|
|
||||||
|
Services that operate on collections expose a **collection handle**
|
||||||
|
returned by an `::collection(name)` constructor:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let widgets = kv::collection("widgets");
|
||||||
|
widgets.set("k", "v");
|
||||||
|
let v = widgets.get("k");
|
||||||
|
```
|
||||||
|
|
||||||
|
Not `kv::set("widgets", "k", "v")`. The handle is a Rhai custom type
|
||||||
|
the service registers; method calls bind to that type. This:
|
||||||
|
|
||||||
|
- Removes the "did I get the collection-name argument right?" foot-gun.
|
||||||
|
- Lets the implementation cache per-collection state on the handle
|
||||||
|
(prepared statements, connection affinity) without leaking that
|
||||||
|
into the call signature.
|
||||||
|
- Pre-empts the "collection is implicit" failure mode where two
|
||||||
|
services in the same script accidentally share a default collection.
|
||||||
|
|
||||||
|
`(app_id, collection, key)` is the identity tuple for KV; `(app_id,
|
||||||
|
collection, id)` for docs. Collections are **mandatory**, not optional
|
||||||
|
— even single-collection apps name their collection. The service layer
|
||||||
|
rejects requests with empty collection names.
|
||||||
|
|
||||||
|
## Error convention
|
||||||
|
|
||||||
|
- **Throw on failure.** `widgets.set("k", "v")` throws a Rhai runtime
|
||||||
|
error on any operational problem (DB unavailable, payload too large,
|
||||||
|
authz denied). Scripts opting into error handling use Rhai's
|
||||||
|
`try/catch`.
|
||||||
|
- **`()` for absent.** `widgets.get("missing")` returns `()` (Rhai
|
||||||
|
unit). Scripts test absence with `if v == () { ... }` or use the
|
||||||
|
matching `has(k)` predicate.
|
||||||
|
- **`bool` for predicates.** `widgets.has(k)` is the cheap existence
|
||||||
|
check that doesn't deserialize the value.
|
||||||
|
|
||||||
|
This convention is uniform across every v1.1.x service. Adding
|
||||||
|
`Result`-flavoured variants is a design departure that requires a doc
|
||||||
|
update before implementation.
|
||||||
|
|
||||||
|
## `SdkCallCx` and cross-app isolation
|
||||||
|
|
||||||
|
Every stateful service trait method takes `&SdkCallCx` as its first
|
||||||
|
non-self argument. The cx carries:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct SdkCallCx {
|
||||||
|
pub app_id: AppId,
|
||||||
|
pub principal: Option<Principal>,
|
||||||
|
pub execution_id: ExecutionId,
|
||||||
|
pub request_id: RequestId,
|
||||||
|
pub trigger_depth: u32,
|
||||||
|
pub root_execution_id: ExecutionId,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**The service implementation MUST derive `app_id` from `cx.app_id` —
|
||||||
|
never from a script-passed argument.** Scripts cannot name another
|
||||||
|
app's data, period. The closure registered into Rhai captures the
|
||||||
|
`Arc<SdkCallCx>` for the call; the script never sees or passes
|
||||||
|
`app_id`.
|
||||||
|
|
||||||
|
Why this matters: a `kv::set("widgets", "k", v)` call with a
|
||||||
|
script-supplied `app_id` would be a tenant-isolation vulnerability if
|
||||||
|
that arg ever leaked into the storage query. By deriving from the
|
||||||
|
host-attached cx, the service can't be tricked.
|
||||||
|
|
||||||
|
`principal` is `Option<Principal>` because the data plane is
|
||||||
|
unauthenticated by default — public HTTP scripts run with `None`.
|
||||||
|
Services that need an authenticated identity (e.g., `users::*`) check
|
||||||
|
`cx.principal.is_some()` and throw if missing.
|
||||||
|
|
||||||
|
## Sync ↔ async bridge
|
||||||
|
|
||||||
|
Rhai is synchronous; service trait methods (KV writes, HTTP calls) are
|
||||||
|
async. The bridge runs *inside the `spawn_blocking` thread* that
|
||||||
|
already wraps `Engine::execute` (orchestrator-core's
|
||||||
|
`LocalExecutorClient`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Inside a Rhai-registered closure.
|
||||||
|
let runtime = tokio::runtime::Handle::current();
|
||||||
|
let result = runtime.block_on(service.do_thing(&cx, args));
|
||||||
|
```
|
||||||
|
|
||||||
|
`Handle::current()` finds the same Tokio runtime that scheduled the
|
||||||
|
`spawn_blocking`, so the `block_on` doesn't construct a fresh runtime.
|
||||||
|
The thread is already off the async worker pool (that's what
|
||||||
|
`spawn_blocking` does), so blocking inside it is safe.
|
||||||
|
|
||||||
|
This pattern goes in every stateful service's registered Rhai closure.
|
||||||
|
The first service PR (KV, v1.1.1) lands a helper so subsequent services
|
||||||
|
don't reinvent the boilerplate.
|
||||||
|
|
||||||
|
## `ServiceEventEmitter`
|
||||||
|
|
||||||
|
Every stateful service that mutates data also emits events for the
|
||||||
|
(future) triggers framework:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
emitter.emit(&cx, ServiceEvent {
|
||||||
|
source: "kv",
|
||||||
|
op: "insert",
|
||||||
|
collection: Some("widgets".into()),
|
||||||
|
key: Some("k".into()),
|
||||||
|
payload: Some(new_value_json),
|
||||||
|
old_payload: None,
|
||||||
|
}).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
v1.1.0 ships only `NoopEventEmitter`. The v1.1.1 triggers PR replaces
|
||||||
|
that with an outbox-backed implementation: events land in a Postgres
|
||||||
|
outbox table; a dispatcher worker reads them out-of-band, matches
|
||||||
|
against registered triggers, and fans out script executions. The
|
||||||
|
dispatcher enforces a depth limit via `cx.trigger_depth` so a
|
||||||
|
trigger-fires-its-own-trigger chain can't run away.
|
||||||
|
|
||||||
|
Services hold `Arc<dyn ServiceEventEmitter>` and emit unconditionally;
|
||||||
|
the noop drops events, the real impl persists them. From the service's
|
||||||
|
perspective the emission is fire-and-forget.
|
||||||
|
|
||||||
|
## `ExecutionGate` and `PICLOUD_MAX_CONCURRENT_EXECUTIONS`
|
||||||
|
|
||||||
|
A single global semaphore caps concurrent script executions. Default
|
||||||
|
is 32; override via the `PICLOUD_MAX_CONCURRENT_EXECUTIONS` env var.
|
||||||
|
Acquisition is **non-blocking, no queue** — if a permit isn't free,
|
||||||
|
the request is refused immediately with HTTP 503 and a `Retry-After:
|
||||||
|
1` header.
|
||||||
|
|
||||||
|
Rationale: Rhai execution runs under `spawn_blocking`, which uses a
|
||||||
|
finite pool of blocking threads (defaults to 512 in current Tokio).
|
||||||
|
Without a cap, a script storm parks every blocking thread and starves
|
||||||
|
every other workload (DB writes, log sinks, audit emission). Hard
|
||||||
|
pushback is preferable to silent degradation.
|
||||||
|
|
||||||
|
Per-app or per-script caps are deferred until a real workload demands
|
||||||
|
them. The gate lives in `orchestrator-core::gate::ExecutionGate` and
|
||||||
|
is constructed once in the picloud binary's `build_app`.
|
||||||
|
|
||||||
|
## Registration: where future services hook in
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// orchestrator-core / executor-core internal call path —
|
||||||
|
// you do not implement this; you implement registration helpers
|
||||||
|
// that future PRs call from here.
|
||||||
|
pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||||
|
// v1.1.1: register_kv(engine, services, cx.clone());
|
||||||
|
// v1.1.2: register_docs(engine, services, cx.clone());
|
||||||
|
// …
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each service PR adds:
|
||||||
|
|
||||||
|
1. A `Service` trait + impl in `manager-core` (since that's where the
|
||||||
|
DB-backed implementations live).
|
||||||
|
2. A field on `picloud_shared::Services` (`pub kv: Arc<dyn KvService>`).
|
||||||
|
3. A `register_kv` helper inside `executor-core::sdk::kv` that takes
|
||||||
|
the engine, the service, and the cx, then registers the Rhai
|
||||||
|
`::collection(...)` constructor and method bindings.
|
||||||
|
4. A new `Capability` variant in `manager-core::authz` (e.g.
|
||||||
|
`AppKvRead(AppId)`) and a check inside the service impl.
|
||||||
|
|
||||||
|
That sequence is the entire mechanical pattern; nothing here should
|
||||||
|
require architecture-level discussion past v1.1.0.
|
||||||
|
|
||||||
|
## What this doc does NOT cover
|
||||||
|
|
||||||
|
- Service-specific schemas (KV table layout, docs query DSL, etc.) —
|
||||||
|
in each service PR.
|
||||||
|
- Authentication and the admin auth model — see blueprint §11.5,
|
||||||
|
§11.6 and Phase 3.5.
|
||||||
|
- The trigger dispatch design (outbox row layout, fan-out semantics,
|
||||||
|
trigger CRUD endpoints) — comes with v1.1.1.
|
||||||
|
- Cluster mode considerations — deferred to v1.3+.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Project Blueprint: Lightweight Event-Based Serverless Cloud
|
# Project Blueprint: Lightweight Event-Based Serverless Cloud
|
||||||
|
|
||||||
**Status**: Phase 4 — Blueprint Complete
|
**Status**: Phase 4 — Blueprint Complete
|
||||||
**Last Updated**: 2026-04-10
|
**Last Updated**: 2026-05-27
|
||||||
**Audience**: Solo developer (DIY self-hosted)
|
**Audience**: Solo developer (DIY self-hosted)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -661,7 +661,7 @@ users.set_permissions(user_id, {
|
|||||||
|-------|-----------|-----------|
|
|-------|-----------|-----------|
|
||||||
| **Orchestrator** | Rust + Axum | Performance, safety, async-first; minimal overhead |
|
| **Orchestrator** | Rust + Axum | Performance, safety, async-first; minimal overhead |
|
||||||
| **Dashboard** | Alpine.js + vanilla HTML/CSS | Zero dependencies, simple to deploy, fast enough for MVP |
|
| **Dashboard** | Alpine.js + vanilla HTML/CSS | Zero dependencies, simple to deploy, fast enough for MVP |
|
||||||
| **Database** | PostgreSQL + hstore | Robust ACID database; hstore extension for lightweight KV (v1.1) |
|
| **Database** | PostgreSQL 15+ (`pgcrypto`) | Robust ACID database; JSONB carries data-plane values (v1.1+). See §8.1. |
|
||||||
| **Container Runtime** | Docker (Docker daemon) | Industry standard, simple CLI |
|
| **Container Runtime** | Docker (Docker daemon) | Industry standard, simple CLI |
|
||||||
| **Executor Image** | Alpine Linux + Rhai | Minimal image size (~50-100MB), fast startup |
|
| **Executor Image** | Alpine Linux + Rhai | Minimal image size (~50-100MB), fast startup |
|
||||||
| **Scripting** | Rhai | Lightweight, embedded-friendly, safe by default |
|
| **Scripting** | Rhai | Lightweight, embedded-friendly, safe by default |
|
||||||
@@ -1022,9 +1022,9 @@ The scripts and routes endpoints keep their existing shape — this avoids forci
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11.6 Users, roles, and bearer-token auth (Phase 3.5) — Pending
|
## 11.6 Users, roles, and bearer-token auth (Phase 3.5) — ✓ Shipped
|
||||||
|
|
||||||
**Status**: pending. Targets `crates/manager-core/src/{authz,api_keys_api,api_key_repo}.rs`, an extended `auth_middleware.rs`, new shared types under `crates/shared/src/auth.rs`, migration `0006_users_authz.sql`.
|
**Status**: shipped, ahead of the originally planned slot. Lives in `crates/manager-core/src/{authz,api_keys_api,api_key_repo}.rs`, the extended `auth_middleware.rs`, shared types under `crates/shared/src/auth.rs`, and migration `0006_users_authz.sql`. `can(principal, capability)` and `require(principal, capability)` are the single gate every admin handler goes through.
|
||||||
|
|
||||||
**Purpose**: bridge Phase 3b → Phase 4. Phase 4's v1.1 SDKs (KV, docs, HTTP, cron) each gate access on the calling principal. Without a real authorization model in place, every SDK addition has to either invent its own gate or stay open. Phase 3.5 lands `can(principal, capability)` as the single check every future SDK + admin endpoint goes through, so v1.1 work focuses on data plane shape, not on re-litigating auth.
|
**Purpose**: bridge Phase 3b → Phase 4. Phase 4's v1.1 SDKs (KV, docs, HTTP, cron) each gate access on the calling principal. Without a real authorization model in place, every SDK addition has to either invent its own gate or stay open. Phase 3.5 lands `can(principal, capability)` as the single check every future SDK + admin endpoint goes through, so v1.1 work focuses on data plane shape, not on re-litigating auth.
|
||||||
|
|
||||||
@@ -1049,7 +1049,7 @@ pub struct Principal {
|
|||||||
| Role | Powers |
|
| Role | Powers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `owner` | full instance control, manage other owners, implicit `app_admin` on every app. Multiple owners allowed. |
|
| `owner` | full instance control, manage other owners, implicit `app_admin` on every app. Multiple owners allowed. |
|
||||||
| `admin` | create apps, invite users, implicit `editor` on every app. Cannot manage instance-wide settings or other owners. |
|
| `admin` | create apps, invite users, implicit `app_admin` on every app. Cannot manage instance-wide settings (sandbox ceiling, etc.) or other owners. |
|
||||||
| `member` | invited into specific apps only. Cannot create apps, cannot invite. **Strict isolation enforced at SQL** — list endpoints `WHERE app_id IN (SELECT app_id FROM app_members WHERE user_id = $1)`; the API never returns apps a member isn't part of. |
|
| `member` | invited into specific apps only. Cannot create apps, cannot invite. **Strict isolation enforced at SQL** — list endpoints `WHERE app_id IN (SELECT app_id FROM app_members WHERE user_id = $1)`; the API never returns apps a member isn't part of. |
|
||||||
|
|
||||||
The current Phase 3a `admin_users` rows all become `owner` via `DEFAULT 'owner'` on the new column. Multi-owner installs get a startup `tracing::warn!` listing the active owner usernames so the operator can demote extras via `PATCH /api/v1/admin/admins/{id}`.
|
The current Phase 3a `admin_users` rows all become `owner` via `DEFAULT 'owner'` on the new column. Multi-owner installs get a startup `tracing::warn!` listing the active owner usernames so the operator can demote extras via `PATCH /api/v1/admin/admins/{id}`.
|
||||||
@@ -1058,11 +1058,13 @@ The current Phase 3a `admin_users` rows all become `owner` via `DEFAULT 'owner'`
|
|||||||
|
|
||||||
| Role | Grants |
|
| Role | Grants |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `app_admin` | settings, domain claims, delete app |
|
| `app_admin` | settings, domain claims, delete app, **delete scripts** |
|
||||||
| `editor` | CRUD on scripts, routes, sandbox config |
|
| `editor` | create + edit scripts, routes, sandbox config (no script delete) |
|
||||||
| `viewer` | read scripts + execution logs |
|
| `viewer` | read scripts + execution logs |
|
||||||
|
|
||||||
Implicit grants from instance role: every `owner` is `app_admin` on every app; every `admin` is `editor` on every app. Explicit `app_members` rows are the only path for `member` users.
|
Implicit grants from instance role: every `owner` and every `admin` is `app_admin` on every app — a single-human install would otherwise have to add itself to each new app's `app_members`. Explicit `app_members` rows are the only path for `member` users.
|
||||||
|
|
||||||
|
Script **save** uses `AppWriteScript` (editor+); script **delete** uses `AppAdmin` (app_admin+). Editors can iterate on a script's source freely but cannot remove it — destructive cleanup stays with the role that also owns the app.
|
||||||
|
|
||||||
### Auth Methods — Same Principal, Different Extractor
|
### Auth Methods — Same Principal, Different Extractor
|
||||||
|
|
||||||
@@ -1156,6 +1158,35 @@ DELETE /api/v1/admin/api-keys/{id} — caller's own only
|
|||||||
|
|
||||||
Every existing `/api/v1/admin/*` endpoint is re-gated from "any authed admin" to a specific `Capability`. Request/response shapes are unchanged; what changes is the set of callers each endpoint accepts (a `member` now gets 403 on app surfaces they're not part of, where before they would have been 401-or-200 depending only on session validity).
|
Every existing `/api/v1/admin/*` endpoint is re-gated from "any authed admin" to a specific `Capability`. Request/response shapes are unchanged; what changes is the set of callers each endpoint accepts (a `member` now gets 403 on app surfaces they're not part of, where before they would have been 401-or-200 depending only on session validity).
|
||||||
|
|
||||||
|
### App Member Management Endpoints
|
||||||
|
|
||||||
|
Exposes the `app_members` table as a first-class CRUD surface so app admins can manage who they share an app with from the dashboard, not just from SQL.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/admin/apps/{id_or_slug}/members — list members (ordered by username),
|
||||||
|
joined with admin_users for
|
||||||
|
username / email / instance_role / is_active
|
||||||
|
POST /api/v1/admin/apps/{id_or_slug}/members — { user_id, role } → 201 enriched DTO
|
||||||
|
409 on duplicate (promotions go through PATCH)
|
||||||
|
422 if target user is_active = false
|
||||||
|
422 if target user instance_role != 'member'
|
||||||
|
(owners/admins have implicit authority;
|
||||||
|
an explicit row would be dead weight)
|
||||||
|
PATCH /api/v1/admin/apps/{id_or_slug}/members/{user_id} — { role } → 200 enriched DTO
|
||||||
|
404 if no existing membership
|
||||||
|
DELETE /api/v1/admin/apps/{id_or_slug}/members/{user_id} — 204 (idempotent — 204 also when missing)
|
||||||
|
```
|
||||||
|
|
||||||
|
All four are gated on `Capability::AppAdmin(app_id)`. Editors and viewers get 403 on list and never see the dashboard's Members tab.
|
||||||
|
|
||||||
|
**`my_role` on the app lookup endpoint.** `GET /api/v1/admin/apps/{id_or_slug}` now returns an additional `my_role: Option<AppRole>`, computed server-side from the principal: `Owner → app_admin`, `Admin → editor`, `Member → app_members.role`. The dashboard uses this single field to decide whether to render the Members tab (visible iff `my_role == app_admin`), keeping API and UI gate logic identical.
|
||||||
|
|
||||||
|
**No last-app-admin guard.** Unlike the last-owner protection on `admin_users`, removing the final `app_admin` row from `app_members` is allowed. Every `owner` instance-role user implicitly satisfies `Capability::AppAdmin(_)` via the top-level `role_grants` branch, so no app can become permanently orphaned — an owner can always re-issue grants. The `admin` instance role is only implicit *editor*, so it does **not** provide a fallback path; the owner guarantee alone is what makes the no-guard position safe.
|
||||||
|
|
||||||
|
**Dead-row sweep on promotion (deferred).** Promoting a user from `member` → `admin`/`owner` leaves their `app_members` rows in place. They become inert (implicit grants supersede), but are not auto-deleted. A future hook can sweep them; harmless for now.
|
||||||
|
|
||||||
|
Additive within `/api/v1/admin/...` — no API major bump per [docs/versioning.md](docs/versioning.md).
|
||||||
|
|
||||||
### Out of Scope (Phase 3.5)
|
### Out of Scope (Phase 3.5)
|
||||||
|
|
||||||
Schema room only, not built:
|
Schema room only, not built:
|
||||||
@@ -1164,7 +1195,7 @@ Schema room only, not built:
|
|||||||
- **MFA / TOTP** — `mfa_secret` column reserved on `admin_users`.
|
- **MFA / TOTP** — `mfa_secret` column reserved on `admin_users`.
|
||||||
- **Service accounts** — reserved as a future table; for now, every API key belongs to a human `admin_users` row.
|
- **Service accounts** — reserved as a future table; for now, every API key belongs to a human `admin_users` row.
|
||||||
|
|
||||||
Defer to follow-up sessions: dashboard surfaces for invites / member management / key minting (curl is the supported interface this phase), OIDC / SAML / SCIM, the `picloud` CLI binary itself, email/SMTP delivery of invites, audit log shipping.
|
Defer to follow-up sessions: dashboard surfaces for invites / key minting (curl is the supported interface this phase — member management has a dashboard tab; see above), OIDC / SAML / SCIM, the `picloud` CLI binary itself, email/SMTP delivery of invites, audit log shipping.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1192,7 +1223,7 @@ Defer to follow-up sessions: dashboard surfaces for invites / member management
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 3: v1.0.x — Foundations (Current focus)
|
### Phase 3: v1.0.x — Foundations ✓ (Shipped)
|
||||||
|
|
||||||
Three foundation pieces that must land before the v1.1 service expansion, because retrofitting them later is expensive.
|
Three foundation pieces that must land before the v1.1 service expansion, because retrofitting them later is expensive.
|
||||||
|
|
||||||
@@ -1200,24 +1231,27 @@ Three foundation pieces that must land before the v1.1 service expansion, becaus
|
|||||||
|
|
||||||
**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.
|
**3b. Multi-app scoping** — ✓ shipped. See section 11.5. `apps`, `app_domains`, `app_slug_history` tables; `app_id` columns on `scripts`, `routes`, `execution_logs`. Migration assigns existing data to a `default` app and always claims `localhost`; a Rust-side bootstrap inserts a `Hello World` script + `/hello` route when the default app is empty. Orchestrator dispatch is two-phase (Host → app → route trie). `/api/v1/execute/{id}/*` continues to work without a public domain claim. Dashboard is app-hierarchical (`/admin/apps`, `/admin/apps/{slug}/...`); API stays flat with new endpoints under `/api/v1/admin/apps/*` and a `?app=` filter on script listing. Per-app admin roles deferred.
|
||||||
|
|
||||||
**3c. Users, roles, and bearer-token auth** — pending. See section 11.6. Adds `instance_role` to `admin_users` (`owner`/`admin`/`member`), `app_members` for per-app `app_admin`/`editor`/`viewer` grants, and `api_keys` for `Authorization: Bearer pic_…` credentials. Unifies cookie-session and API-key paths behind a single `can(principal, capability)` gate; list endpoints filter by membership at SQL for `member` users. Dashboard surfaces, invites, MFA, service accounts, and the `picloud` CLI binary are deferred — schema room only.
|
**3c. Users, roles, and bearer-token auth (Phase 3.5)** — ✓ shipped. See section 11.6. Adds `instance_role` to `admin_users` (`owner`/`admin`/`member`), `app_members` for per-app `app_admin`/`editor`/`viewer` grants, and `api_keys` for `Authorization: Bearer pic_…` credentials. Unifies cookie-session and API-key paths behind a single `can(principal, capability)` gate; list endpoints filter by membership at SQL for `member` users. Dashboard surfaces, invites, MFA, service accounts, and the `picloud` CLI binary are deferred — schema room only.
|
||||||
|
|
||||||
**Why all three before v1.1**: every v1.1 service (KV, docs, users, etc.) needs both an `app_id` scoping key in its schema and a `Principal` to authorize against. Adding both now is one migration each on a small surface; adding them after the SDKs ship is many migrations on populated data plus a re-gate of every SDK call.
|
**Why all three before v1.1**: every v1.1 service (KV, docs, users, etc.) needs both an `app_id` scoping key in its schema and a `Principal` to authorize against. Adding both now is one migration each on a small surface; adding them after the SDKs ship is many migrations on populated data plus a re-gate of every SDK call.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 4: v1.1 (Expand Capabilities & Services)
|
### Phase 4: v1.1 (Expand Capabilities & Services) — Current focus
|
||||||
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)
|
Released in patch steps (v1.1.0 → v1.1.8), each landing one focused capability. The split lets each release ship behind tests + docs without long-lived branches. SDK shape (handle pattern, `::` namespace, error convention, `ExecutionGate`, `SdkCallCx`, `ServiceEventEmitter` — see §7.5 and [docs/sdk-shape.md](../docs/sdk-shape.md)) is fixed in v1.1.0; every subsequent release fills in the contents without re-litigating the shape.
|
||||||
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)
|
| Version | Capability |
|
||||||
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)
|
| **v1.1.0** | **Foundation & Standard Library** — SDK shape (`Services` bundle, `SdkCallCx`, `ExecutionGate`, `ServiceEventEmitter` trait shape); stdlib utilities (regex, random, time, json, base64, hex, url). |
|
||||||
6. **Rhai SDK: User Management** (auth, CRUD, roles, permissions, invitations, password reset; depends on email for invites; scoped per app)
|
| **v1.1.1** | **Storage & Events** — KV store keyed `(app_id, collection, key)`; triggers framework (outbox + dispatcher + trigger CRUD + `ctx.event` + depth limit); KV trigger kinds. |
|
||||||
7. **Queue triggers** (start with Postgres LISTEN/NOTIFY; RabbitMQ/Redis later if needed)
|
| **v1.1.2** | **Documents** — `docs::collection(name).create/find/update/delete/list` with `docs:*` triggers. |
|
||||||
8. **`invoke()` + `retry::*`** (function-to-function calls; execution_logs gain `parent_execution_id`)
|
| **v1.1.3** | **Modules** — `scripts.kind`, per-app resolver replaces `DummyModuleResolver`, AST cache + dep-graph invalidation. |
|
||||||
9. **Secrets management** (encrypted env vars, per app)
|
| **v1.1.4** | **Outbound HTTP & Scheduled Tasks** — `http::*` with SSRF deny-list; cron triggers. |
|
||||||
|
| **v1.1.5** | **Files & Messaging** — filesystem-backed blobs with `files:*` triggers; pub/sub via LISTEN/NOTIFY with `pubsub:*` triggers. |
|
||||||
|
| **v1.1.6** | **Configuration & Email** — encrypted per-app secrets; outbound `email::send` / `send_html` + inbound `email:receive` trigger. |
|
||||||
|
| **v1.1.7** | **User Management** — `users::*` for in-script CRUD, auth, roles, invites, password reset. |
|
||||||
|
| **v1.1.8** | **Durable Queues & Function Composition** — `queue::*` with `queue:receive` trigger; `invoke()` + `retry::*` (closures-as-args, re-entrant Rhai). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1278,59 +1312,71 @@ Ordered roughly by foundation value: each row enables the rows below it.
|
|||||||
| **ctx** (global) | `ctx.execution_id`, `ctx.script_id`, `ctx.script_name`, `ctx.request_id`, `ctx.trace_id`, `ctx.invocation_type`, `ctx.parent_execution_id`, `ctx.request.path`, `ctx.request.headers`, `ctx.request.body` | MVP+ |
|
| **ctx** (global) | `ctx.execution_id`, `ctx.script_id`, `ctx.script_name`, `ctx.request_id`, `ctx.trace_id`, `ctx.invocation_type`, `ctx.parent_execution_id`, `ctx.request.path`, `ctx.request.headers`, `ctx.request.body` | MVP+ |
|
||||||
| **Response** | Return `{ statusCode, headers?, body }` | MVP |
|
| **Response** | Return `{ statusCode, headers?, body }` | MVP |
|
||||||
|
|
||||||
|
## 7.5 SDK Architecture (v1.1.x foundation)
|
||||||
|
|
||||||
|
Stateful Rhai SDK services (KV, docs, HTTP, …) hang off a common shape laid down by the v1.1.0 SDK foundation PR. Full reference lives in [docs/sdk-shape.md](../docs/sdk-shape.md); this section sketches the moving parts so other sections can refer to them by name.
|
||||||
|
|
||||||
|
**`Services` bundle** (`picloud_shared::Services`) — an `#[non_exhaustive]` struct constructed once at startup. v1.1.0 ships it empty; each subsequent v1.1.x PR adds one `Arc<dyn KvService>` / `Arc<dyn DocsService>` / … field. Held on `Engine`, passed by reference to the per-call registration hook.
|
||||||
|
|
||||||
|
**Per-call context** (`picloud_shared::SdkCallCx`) — every stateful service trait method takes `&SdkCallCx` as its first non-self argument. Carries `app_id`, `Option<Principal>`, `execution_id`, `request_id`, and the `trigger_depth` / `root_execution_id` slots that the triggers framework populates. Services derive `app_id` from the cx — never from script-passed args. **That rule is the cross-app isolation boundary**; scripts cannot name another app's data.
|
||||||
|
|
||||||
|
**Handle pattern** — collection-scoped services expose `kv::collection("widgets").get("k")`, not `kv::get("widgets", "k")`. Removes the wrong-collection-name foot-gun and lets implementations cache per-collection state. `(app_id, collection, key)` is the identity tuple for KV; `(app_id, collection, id)` for docs. Collections are mandatory.
|
||||||
|
|
||||||
|
**Error convention** — throw on failure, `()` for absent, `bool` for predicates. Uniform across every v1.1.x service. Scripts opt into handling errors via Rhai's `try/catch`.
|
||||||
|
|
||||||
|
**`ExecutionGate`** (`orchestrator-core::gate::ExecutionGate`) — single global semaphore capping concurrent script executions. Default 32, override via the `PICLOUD_MAX_CONCURRENT_EXECUTIONS` env var. Non-blocking — on overflow, the orchestrator returns HTTP 503 with `Retry-After: 1` immediately. No queue. Rationale: Rhai runs under `spawn_blocking`, so unbounded concurrency would park every blocking thread and starve every other workload.
|
||||||
|
|
||||||
|
**`ServiceEventEmitter`** (`picloud_shared::ServiceEventEmitter`) — every mutating service method emits a `ServiceEvent { source, op, collection, key, payload, old_payload }`. v1.1.0 ships `NoopEventEmitter`; the real outbox-backed dispatcher lands with v1.1.1 (see 7.5.1).
|
||||||
|
|
||||||
|
### 7.5.1 Trigger architecture (sketch)
|
||||||
|
|
||||||
|
Triggers fire scripts in response to service events. Three locked properties; full design and CRUD endpoints land with v1.1.1.
|
||||||
|
|
||||||
|
1. **Async outbox**: services emit events synchronously into a Postgres outbox table; a separate dispatcher worker reads, matches them against registered triggers, and fans out script executions. Service writes don't block on trigger fan-out.
|
||||||
|
2. **Depth-limited**: each trigger-spawned execution increments `cx.trigger_depth`. The dispatcher refuses to fan out beyond a configured ceiling to prevent runaway feedback loops. `cx.root_execution_id` preserves the originating execution id for audit grouping.
|
||||||
|
3. **Trigger model**: a trigger is `(service, event, filter) → script`, stored in a `triggers` table. The filter is the dispatcher's match predicate on the emitted `ServiceEvent`.
|
||||||
|
|
||||||
### 8.1 KV Store Service
|
### 8.1 KV Store Service
|
||||||
**Purpose**: Simple key-value persistence organized by collections, shared across script invocations and scripts.
|
**Purpose**: Simple key-value persistence organized by collections, scoped per app and shared across script invocations and scripts within that app.
|
||||||
|
|
||||||
**PostgreSQL Setup:**
|
**PostgreSQL Schema:**
|
||||||
```sql
|
```sql
|
||||||
-- Enable hstore extension (one-time setup)
|
|
||||||
CREATE EXTENSION IF NOT EXISTS hstore;
|
|
||||||
|
|
||||||
-- Create KV table with collection support
|
|
||||||
CREATE TABLE kv_store (
|
CREATE TABLE kv_store (
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
collection TEXT NOT NULL,
|
collection TEXT NOT NULL,
|
||||||
key TEXT NOT NULL,
|
key TEXT NOT NULL,
|
||||||
value hstore NOT NULL,
|
value JSONB NOT NULL,
|
||||||
expires_at TIMESTAMP,
|
expires_at TIMESTAMP,
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP DEFAULT NOW(),
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
|
||||||
PRIMARY KEY (collection, key)
|
PRIMARY KEY (app_id, collection, key)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_kv_collection ON kv_store(collection);
|
CREATE INDEX idx_kv_app_collection ON kv_store(app_id, collection);
|
||||||
CREATE INDEX idx_kv_expires ON kv_store(expires_at)
|
CREATE INDEX idx_kv_expires ON kv_store(expires_at)
|
||||||
WHERE expires_at IS NOT NULL;
|
WHERE expires_at IS NOT NULL;
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why hstore + collections?**
|
**Why JSONB + mandatory collections + `app_id` first:**
|
||||||
- Lightweight, purpose-built for key-value storage
|
- `(app_id, collection, key)` is the identity tuple. The PK begins with `app_id` so the index is naturally per-app; cross-app reads can't happen even if the service layer has a bug.
|
||||||
- Collections allow logical grouping (e.g., `kv:sessions`, `kv:counters`, `kv:flags`)
|
- Collections are **mandatory** — every set / get / delete names one. The same key can legitimately live in multiple collections within one app (`sessions:abc` and `counters:abc` are distinct rows).
|
||||||
- Faster than JSONB for simple KV use cases
|
- JSONB carries arbitrary script-side values (nested objects, arrays) without a separate serialization step. `hstore` was considered and ruled out — it doesn't carry nested types and would force a second JSONB column the moment a script writes a structured value.
|
||||||
- Built-in indexing support
|
|
||||||
- Keeps all data in one database (no Redis dependency)
|
|
||||||
|
|
||||||
**Rhai SDK:**
|
**Value-size cap:** 64 KiB per value, enforced at the service layer (script-visible error on overflow). The cap keeps KV "small fast values, not blob storage"; the v1.1.5 files SDK is the right home for large payloads.
|
||||||
|
|
||||||
|
**Rhai SDK (handle pattern — see [docs/sdk-shape.md](docs/sdk-shape.md)):**
|
||||||
```rhai
|
```rhai
|
||||||
// Get a value from a collection
|
let sessions = kv::collection("sessions");
|
||||||
let val = kv.get("sessions", "user:123"); // Returns object or null
|
sessions.set("user:123", #{ token: "abc", created: "2026-04-10" });
|
||||||
|
let val = sessions.get("user:123"); // value or () if absent
|
||||||
|
sessions.delete("user:123");
|
||||||
|
sessions.set("user:123", #{ token: "xyz" }, 3600); // TTL in seconds
|
||||||
|
if sessions.has("user:123") { ... }
|
||||||
|
|
||||||
// Set a value in a collection
|
// Distinct collections in one script — different handles.
|
||||||
kv.set("sessions", "user:123", { token: "abc", created: "2026-04-10" });
|
let counters = kv::collection("counters");
|
||||||
|
counters.set("api:calls", 42);
|
||||||
// Delete a key from a collection
|
|
||||||
kv.delete("sessions", "user:123");
|
|
||||||
|
|
||||||
// Set with TTL (seconds)
|
|
||||||
kv.set("sessions", "user:123", { token: "xyz" }, 3600);
|
|
||||||
|
|
||||||
// Check if key exists in a collection
|
|
||||||
if kv.has("sessions", "user:123") { ... }
|
|
||||||
|
|
||||||
// Use different collections for different purposes
|
|
||||||
kv.set("counters", "api:calls", 42);
|
|
||||||
kv.set("flags", "feature:beta", true);
|
|
||||||
kv.set("cache", "page:home", { html: "..." });
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Use Cases:**
|
**Use Cases:**
|
||||||
|
|||||||
Reference in New Issue
Block a user