Compare commits
90 Commits
878cbe9439
...
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 | ||
|
|
6eb32a78bf | ||
|
|
fc35d59236 | ||
|
|
0c9f11558a | ||
|
|
39a6df2bfe | ||
|
|
d21cbdb164 | ||
|
|
700ae7b7d1 | ||
|
|
f16ff22a5a | ||
|
|
bd2258499e | ||
|
|
df691038d7 | ||
|
|
3688c26cb4 | ||
|
|
2aab92af31 | ||
|
|
063595be31 | ||
|
|
30a1584667 | ||
|
|
d229120df6 | ||
|
|
8659a58eb2 | ||
|
|
5f7ddd23ab | ||
|
|
44db8d107a | ||
|
|
abaabb68d8 | ||
|
|
fd6f2b1f13 | ||
|
|
d435322f9c | ||
|
|
5546323cdc | ||
|
|
a393f11344 | ||
|
|
ad5492a4bd | ||
|
|
ee0dbc428f | ||
|
|
4c41374db4 | ||
|
|
6891496589 | ||
|
|
646bd55174 | ||
|
|
56de652f7a | ||
|
|
3d4c7b160b | ||
|
|
267c40f59c | ||
|
|
1dc53a0226 | ||
|
|
6cdb1244b8 | ||
|
|
bc8b512b56 | ||
|
|
a80e6d1ca4 | ||
|
|
0eaf4aee69 |
@@ -29,3 +29,11 @@ RUST_LOG=info,picloud=debug
|
|||||||
# Public base URL the dashboard uses to render full URLs for user routes.
|
# Public base URL the dashboard uses to render full URLs for user routes.
|
||||||
# Set to the host:port (and scheme) users actually reach in their browser.
|
# Set to the host:port (and scheme) users actually reach in their browser.
|
||||||
PICLOUD_PUBLIC_BASE_URL=http://localhost:8000
|
PICLOUD_PUBLIC_BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# ---------- Bootstrap admin ----------
|
||||||
|
# Required. Used once on first startup to seed the admin_users table.
|
||||||
|
# Ignored on subsequent boots if the table is non-empty. For prod,
|
||||||
|
# prefer PICLOUD_ADMIN_PASSWORD_HASH (pre-computed Argon2id PHC) so the
|
||||||
|
# raw password never lands in env or compose files; see blueprint §11.5.
|
||||||
|
PICLOUD_ADMIN_USERNAME=admin
|
||||||
|
PICLOUD_ADMIN_PASSWORD=admin
|
||||||
|
|||||||
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
|
||||||
|
|||||||
27
CLAUDE.md
27
CLAUDE.md
@@ -8,6 +8,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
|
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
|
||||||
|
|
||||||
|
**Current focus (Phase 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
|
||||||
|
|
||||||
The platform splits into three logical services, each backed by a `*-core` library crate so the same logic runs in single-process MVP mode and split-process cluster mode:
|
The platform splits into three logical services, each backed by a `*-core` library crate so the same logic runs in single-process MVP mode and split-process cluster mode:
|
||||||
@@ -26,7 +28,7 @@ In MVP, all three run in one process (`picloud` binary). In cluster mode, each r
|
|||||||
|
|
||||||
Versioned API surfaces live under `/api/v{N}/...`. See [docs/versioning.md](docs/versioning.md) for the full scheme.
|
Versioned API surfaces live under `/api/v{N}/...`. See [docs/versioning.md](docs/versioning.md) for the full scheme.
|
||||||
|
|
||||||
- `/api/v1/admin/*` — manager (control plane: script CRUD, routes CRUD + check + match, logs, config)
|
- `/api/v1/admin/*` — manager (control plane: script CRUD, routes CRUD + check + match, logs, config; apps CRUD once Phase 3b lands)
|
||||||
- `/api/v1/execute/{id}` — orchestrator (data plane: invoke a script by ID, always-available bypass)
|
- `/api/v1/execute/{id}` — orchestrator (data plane: invoke a script by ID, always-available bypass)
|
||||||
- `/admin/*` — dashboard SPA (SvelteKit, `paths.base = '/admin'`)
|
- `/admin/*` — dashboard SPA (SvelteKit, `paths.base = '/admin'`)
|
||||||
- `/healthz` — liveness (string `"ok"`)
|
- `/healthz` — liveness (string `"ok"`)
|
||||||
@@ -37,12 +39,16 @@ Reserved path prefixes (rejected at route creation): `/api/`, `/admin/`, `/healt
|
|||||||
|
|
||||||
Caddy fronts everything. Same Caddyfile shape works for single-node and cluster — only upstream targets change.
|
Caddy fronts everything. Same Caddyfile shape works for single-node and cluster — only upstream targets change.
|
||||||
|
|
||||||
|
**Param syntax convention:** route paths use `:name` (e.g., `/users/:id`); domains (once apps land) use `{name}` (e.g., `{tenant}.example.com`). These are deliberately distinct — never use `:` in a domain context or `{}` in a route-path context.
|
||||||
|
|
||||||
|
**Two-phase dispatch (Phase 3b onward):** the orchestrator first resolves `Host` → app (most-specific domain claim wins), then runs that app's route trie. The route matcher itself is unchanged and never sees other apps' routes.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Rust 1.92+** workspace, pinned via `rust-toolchain.toml`
|
- **Rust 1.92+** workspace, pinned via `rust-toolchain.toml`
|
||||||
- **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
|
||||||
@@ -97,9 +103,24 @@ 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()`, auth, multi-tenancy, secrets, metrics dashboard. All deferred to v1.1+ per the blueprint. Don't pre-build for them — but don't make decisions that close the door on them either.
|
Queue triggers, cron triggers, SMTP ingress, KV / docs / email / users / HTTP SDKs in scripts, interceptors, workflows, function-to-function `invoke()`, secrets, metrics dashboard. All deferred to v1.1+ per the blueprint. Don't pre-build for them — but don't make decisions that close the door on them either.
|
||||||
|
|
||||||
|
**Pulled forward to Phase 3 (pre-v1.1):** admin auth, multi-app scoping. Cross-app data sharing (export/import) stays at v1.3+; the initial cut enforces strict isolation. See blueprint §11.5.
|
||||||
|
|||||||
415
Cargo.lock
generated
415
Cargo.lock
generated
@@ -40,12 +40,74 @@ 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"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"blake2",
|
||||||
|
"cpufeatures",
|
||||||
|
"password-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "assert-json-diff"
|
name = "assert-json-diff"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
@@ -56,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"
|
||||||
@@ -206,6 +283,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -215,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"
|
||||||
@@ -281,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"
|
||||||
@@ -387,6 +530,12 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
@@ -413,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"
|
||||||
@@ -425,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"
|
||||||
@@ -489,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"
|
||||||
@@ -509,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"
|
||||||
@@ -983,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"
|
||||||
@@ -1050,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"
|
||||||
@@ -1134,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"
|
||||||
@@ -1204,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"
|
||||||
@@ -1233,6 +1454,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-hash"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pear"
|
name = "pear"
|
||||||
version = "0.2.9"
|
version = "0.2.9"
|
||||||
@@ -1273,12 +1505,13 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud"
|
name = "picloud"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-test",
|
"axum-test",
|
||||||
|
"chrono",
|
||||||
"figment",
|
"figment",
|
||||||
"picloud-executor-core",
|
"picloud-executor-core",
|
||||||
"picloud-manager-core",
|
"picloud-manager-core",
|
||||||
@@ -1293,11 +1526,33 @@ dependencies = [
|
|||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"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.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-executor-core",
|
"picloud-executor-core",
|
||||||
@@ -1309,7 +1564,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-executor-core"
|
name = "picloud-executor-core"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"picloud-shared",
|
"picloud-shared",
|
||||||
@@ -1323,7 +1578,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-manager"
|
name = "picloud-manager"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-manager-core",
|
"picloud-manager-core",
|
||||||
@@ -1335,17 +1590,23 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-manager-core"
|
name = "picloud-manager-core"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"data-encoding",
|
||||||
"picloud-orchestrator-core",
|
"picloud-orchestrator-core",
|
||||||
"picloud-shared",
|
"picloud-shared",
|
||||||
|
"rand 0.8.6",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -1353,7 +1614,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-orchestrator"
|
name = "picloud-orchestrator"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-orchestrator-core",
|
"picloud-orchestrator-core",
|
||||||
@@ -1365,7 +1626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-orchestrator-core"
|
name = "picloud-orchestrator-core"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1384,7 +1645,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-shared"
|
name = "picloud-shared"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -1463,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"
|
||||||
@@ -1658,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"
|
||||||
@@ -1683,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",
|
||||||
@@ -1766,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"
|
||||||
@@ -1786,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"
|
||||||
@@ -1807,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"
|
||||||
@@ -2281,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"
|
||||||
@@ -2318,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"
|
||||||
@@ -2737,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"
|
||||||
@@ -2767,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"
|
||||||
@@ -3020,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"
|
||||||
|
|||||||
10
Cargo.toml
10
Cargo.toml
@@ -9,10 +9,11 @@ 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]
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
@@ -66,6 +67,13 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
|||||||
url = "2"
|
url = "2"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
|
||||||
|
# Auth (admin users + sessions + API keys)
|
||||||
|
argon2 = "0.5"
|
||||||
|
rand = { version = "0.8", features = ["getrandom"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
data-encoding = "2.6"
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,3 +22,12 @@ uuid.workspace = true
|
|||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
|
|
||||||
|
argon2.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
data-encoding.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio.workspace = true
|
||||||
|
|||||||
33
crates/manager-core/migrations/0004_admin_auth.sql
Normal file
33
crates/manager-core/migrations/0004_admin_auth.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
-- Phase 3a admin auth — see blueprint §11.4.
|
||||||
|
--
|
||||||
|
-- Per-user platform-operator accounts (distinct from the v1.1+ `users`
|
||||||
|
-- table, which is for script-end users). Every authenticated admin is a
|
||||||
|
-- full admin in this cut; role/permission tables will be added later
|
||||||
|
-- without touching this schema.
|
||||||
|
--
|
||||||
|
-- `admin_sessions.token_hash` stores SHA-256 of the raw token; the raw
|
||||||
|
-- value only ever exists in the login response, the HttpOnly cookie, and
|
||||||
|
-- bearer-token requests. Cascade on user delete kills the user's sessions
|
||||||
|
-- automatically — which is also why deactivating a user can simply wipe
|
||||||
|
-- their rows instead of marking each session expired.
|
||||||
|
|
||||||
|
CREATE TABLE admin_users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_login_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE admin_sessions (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX admin_sessions_user_idx ON admin_sessions (user_id);
|
||||||
|
CREATE INDEX admin_sessions_expiry_idx ON admin_sessions (expires_at);
|
||||||
117
crates/manager-core/migrations/0005_apps.sql
Normal file
117
crates/manager-core/migrations/0005_apps.sql
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
-- Phase 3b multi-app scoping — see blueprint §11.5.
|
||||||
|
--
|
||||||
|
-- Apps are the top-level isolation boundary for scripts, routes, domain
|
||||||
|
-- claims and (forward) data. The orchestrator dispatches Host → app_id →
|
||||||
|
-- route trie; cross-app resource access is not possible.
|
||||||
|
--
|
||||||
|
-- This migration is unconditional:
|
||||||
|
-- 1. Creates the three new tables (apps, app_domains, app_slug_history).
|
||||||
|
-- 2. Always inserts a "default" app claiming `localhost` so existing
|
||||||
|
-- installs get a usable home for their pre-existing scripts/routes.
|
||||||
|
-- 3. Backfills app_id on scripts, routes, execution_logs from the
|
||||||
|
-- default app row, then promotes the columns to NOT NULL + FK.
|
||||||
|
--
|
||||||
|
-- Fresh installs get the same "default" app row; an in-Rust bootstrap
|
||||||
|
-- step (manager-core::app_bootstrap) decides whether to seed a Hello
|
||||||
|
-- World script into it. Doing the seed in Rust keeps it testable and
|
||||||
|
-- lets the script source live in a real .rhai file.
|
||||||
|
|
||||||
|
CREATE TABLE apps (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
-- URL-safe identifier; mutable via the rename flow which records
|
||||||
|
-- the prior slug in app_slug_history for permanent 301 redirects.
|
||||||
|
-- Format validation (`^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
|
||||||
|
-- check) lives in Rust handlers, not SQL.
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Domain claims. Most-specific wins at request time; same-shape
|
||||||
|
-- collisions are rejected at claim time via the UNIQUE(shape_key).
|
||||||
|
-- shape_key encoding:
|
||||||
|
-- exact:<lowercased-host> for shape='exact'
|
||||||
|
-- wildcard:<lowercased-suffix> for shape='wildcard' AND 'parameterized'
|
||||||
|
-- (parameterized is the same shape as wildcard for collision — the
|
||||||
|
-- parameter name is a binding, not a discriminator. See blueprint §11.5.)
|
||||||
|
CREATE TABLE app_domains (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
pattern TEXT NOT NULL,
|
||||||
|
shape TEXT NOT NULL CHECK (shape IN ('exact', 'wildcard', 'parameterized')),
|
||||||
|
shape_key TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX app_domains_app_id_idx ON app_domains (app_id);
|
||||||
|
|
||||||
|
-- Permanent 301 redirects after a slug rename. A row dies only when
|
||||||
|
-- another app explicitly claims the retired slug (with confirmation in
|
||||||
|
-- the UI). On_delete cascade: if the owning app is deleted, its history
|
||||||
|
-- row goes too (otherwise the redirect would point at a dead app).
|
||||||
|
CREATE TABLE app_slug_history (
|
||||||
|
slug TEXT PRIMARY KEY,
|
||||||
|
current_app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
retired_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed the default app + a localhost claim. Used by both upgrade and
|
||||||
|
-- fresh-install paths; the Rust bootstrap layers Hello World on top
|
||||||
|
-- only when the install was fresh.
|
||||||
|
WITH default_app AS (
|
||||||
|
INSERT INTO apps (slug, name, description)
|
||||||
|
VALUES ('default', 'Default', 'The default application — assigned to all pre-existing scripts and routes during the multi-app migration.')
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
INSERT INTO app_domains (app_id, pattern, shape, shape_key)
|
||||||
|
SELECT id, 'localhost', 'exact', 'exact:localhost' FROM default_app;
|
||||||
|
|
||||||
|
-- Add app_id to scripts. The default app already exists (above), so
|
||||||
|
-- there is exactly one row to look up.
|
||||||
|
ALTER TABLE scripts ADD COLUMN app_id UUID;
|
||||||
|
UPDATE scripts SET app_id = (SELECT id FROM apps WHERE slug = 'default');
|
||||||
|
ALTER TABLE scripts ALTER COLUMN app_id SET NOT NULL;
|
||||||
|
ALTER TABLE scripts
|
||||||
|
ADD CONSTRAINT scripts_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
-- Per-app name uniqueness. Two apps can each have a script called
|
||||||
|
-- "echo"; previously they could not.
|
||||||
|
DROP INDEX scripts_name_uidx;
|
||||||
|
CREATE UNIQUE INDEX scripts_name_uidx ON scripts (app_id, LOWER(name));
|
||||||
|
|
||||||
|
CREATE INDEX scripts_app_id_idx ON scripts (app_id);
|
||||||
|
|
||||||
|
-- Add app_id to routes, mirroring the script's app.
|
||||||
|
ALTER TABLE routes ADD COLUMN app_id UUID;
|
||||||
|
UPDATE routes
|
||||||
|
SET app_id = scripts.app_id
|
||||||
|
FROM scripts
|
||||||
|
WHERE routes.script_id = scripts.id;
|
||||||
|
ALTER TABLE routes ALTER COLUMN app_id SET NOT NULL;
|
||||||
|
ALTER TABLE routes
|
||||||
|
ADD CONSTRAINT routes_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Replace the route uniqueness index so two apps can claim identical
|
||||||
|
-- (host_kind, host, path_kind, path, method) tuples — they live in
|
||||||
|
-- separate route trees and never see each other.
|
||||||
|
DROP INDEX routes_unique_binding_idx;
|
||||||
|
CREATE UNIQUE INDEX routes_unique_binding_idx
|
||||||
|
ON routes (app_id, host_kind, host, path_kind, path, COALESCE(method, ''));
|
||||||
|
|
||||||
|
CREATE INDEX routes_app_id_idx ON routes (app_id);
|
||||||
|
|
||||||
|
-- Add app_id to execution_logs. Materialized at write time so future
|
||||||
|
-- script-moves (or eventual export/import) don't silently retag history.
|
||||||
|
ALTER TABLE execution_logs ADD COLUMN app_id UUID;
|
||||||
|
UPDATE execution_logs
|
||||||
|
SET app_id = scripts.app_id
|
||||||
|
FROM scripts
|
||||||
|
WHERE execution_logs.script_id = scripts.id;
|
||||||
|
ALTER TABLE execution_logs ALTER COLUMN app_id SET NOT NULL;
|
||||||
|
ALTER TABLE execution_logs
|
||||||
|
ADD CONSTRAINT execution_logs_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX execution_logs_app_id_created_at_idx
|
||||||
|
ON execution_logs (app_id, created_at DESC);
|
||||||
112
crates/manager-core/migrations/0006_users_authz.sql
Normal file
112
crates/manager-core/migrations/0006_users_authz.sql
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
-- Phase 3.5 users, roles, and bearer-token auth — see blueprint §11.6.
|
||||||
|
--
|
||||||
|
-- Lays down the schema that the unified can(principal, capability) gate
|
||||||
|
-- runs against, plus the api_keys table that backs `Authorization: Bearer
|
||||||
|
-- pic_…` credentials. No data-plane impact; Phase 4 SDKs (KV, docs, HTTP,
|
||||||
|
-- cron) will plug into this same authz pipeline.
|
||||||
|
--
|
||||||
|
-- Three changes:
|
||||||
|
-- 1. admin_users gains instance_role ('owner'/'admin'/'member') plus a
|
||||||
|
-- reserved email column and mfa_secret slot (neither is read yet).
|
||||||
|
-- Every pre-existing row becomes 'owner' via the DEFAULT — Phase 3a
|
||||||
|
-- had no role concept, so promoting all current admins to owner is
|
||||||
|
-- the only safe interpretation (and matches the spec). The Rust
|
||||||
|
-- startup path logs a warning when more than one active owner
|
||||||
|
-- exists, so operators can demote extras via the admin PATCH.
|
||||||
|
-- 2. app_members records explicit per-app grants for 'member' users.
|
||||||
|
-- Owners and admins get implicit grants in code (owner→app_admin
|
||||||
|
-- everywhere, admin→editor everywhere); no rows here.
|
||||||
|
-- 3. api_keys holds Argon2id-hashed bearer credentials. Lookup is
|
||||||
|
-- prefix-indexed (first 8 chars after `pic_`) then hash-verified;
|
||||||
|
-- raw token only ever exists in the POST response. Optional
|
||||||
|
-- expires_at / app_id implement TTL and app-binding respectively.
|
||||||
|
|
||||||
|
ALTER TABLE admin_users
|
||||||
|
-- DEFAULT 'owner' so the Phase 3a bootstrap admin (and any other
|
||||||
|
-- pre-existing rows) become full owners without a backfill step.
|
||||||
|
-- Multi-owner installs are flagged at startup; demotion is a
|
||||||
|
-- deliberate PATCH, not an automatic migration choice.
|
||||||
|
ADD COLUMN instance_role TEXT NOT NULL DEFAULT 'owner'
|
||||||
|
CHECK (instance_role IN ('owner', 'admin', 'member')),
|
||||||
|
-- Reserved for the eventual invite flow + Phase 4 user-management
|
||||||
|
-- SDK. UNIQUE so we never end up with two rows claiming the same
|
||||||
|
-- contact. Nullable because pre-existing admins have no email on
|
||||||
|
-- file and we don't want to force a backfill.
|
||||||
|
ADD COLUMN email TEXT UNIQUE,
|
||||||
|
-- Reserved slot for TOTP secrets. Not read in Phase 3.5 — present
|
||||||
|
-- now only to avoid a schema bump when MFA lands.
|
||||||
|
ADD COLUMN mfa_secret TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX admin_users_instance_role_idx ON admin_users (instance_role);
|
||||||
|
|
||||||
|
-- Per-(user, app) explicit grant. Owners and admins do NOT appear here;
|
||||||
|
-- their app authority is implicit in their instance_role and resolved in
|
||||||
|
-- code. Only 'member' users need rows in this table — without one, a
|
||||||
|
-- member has no access to the app at all.
|
||||||
|
CREATE TABLE app_members (
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('app_admin', 'editor', 'viewer')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (app_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Lookup pattern is "what apps can this user see?" — needed for the
|
||||||
|
-- membership-filtered GET /admin/apps and GET /admin/scripts.
|
||||||
|
CREATE INDEX app_members_user_id_idx ON app_members (user_id);
|
||||||
|
|
||||||
|
-- Bearer API keys. Format on the wire: `pic_<base32(32 random bytes)>`.
|
||||||
|
-- prefix = first 8 chars after `pic_` (indexed for O(1) candidate lookup)
|
||||||
|
-- hash = Argon2id PHC of the full body after `pic_`
|
||||||
|
-- Raw value is returned exactly once at mint time and never persisted.
|
||||||
|
--
|
||||||
|
-- Optional fields:
|
||||||
|
-- expires_at: TTL. Lookup always filters `expires_at IS NULL OR > NOW()`.
|
||||||
|
-- app_id : "bound key" — capability checks deny any App*(other_app),
|
||||||
|
-- regardless of the owning user's role. Cannot combine with
|
||||||
|
-- instance:* scopes (validated in the mint handler, not SQL).
|
||||||
|
CREATE TABLE api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
hash TEXT NOT NULL,
|
||||||
|
prefix TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
-- TEXT[] keeps the scope set open to additions without a migration;
|
||||||
|
-- the seven legal values are validated at mint time in Rust, not by
|
||||||
|
-- a CHECK constraint here (so new scopes can land without a schema
|
||||||
|
-- bump).
|
||||||
|
scopes TEXT[] NOT NULL,
|
||||||
|
app_id UUID NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
expires_at TIMESTAMPTZ NULL,
|
||||||
|
last_used_at TIMESTAMPTZ NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX api_keys_prefix_idx ON api_keys (prefix);
|
||||||
|
CREATE INDEX api_keys_user_id_idx ON api_keys (user_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Reserved schema room (not built in Phase 3.5)
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- These tables are deliberately commented out, not created. They are
|
||||||
|
-- listed here so the design intent is visible at the migration boundary
|
||||||
|
-- and future authors don't reinvent the shape. Each lands in its own
|
||||||
|
-- numbered migration when the corresponding flow ships.
|
||||||
|
--
|
||||||
|
-- CREATE TABLE invites (
|
||||||
|
-- token TEXT PRIMARY KEY, -- raw at email-link time, hashed at rest
|
||||||
|
-- email TEXT NOT NULL,
|
||||||
|
-- instance_role TEXT NULL CHECK (instance_role IN ('owner','admin','member')),
|
||||||
|
-- app_id UUID NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
-- app_role TEXT NULL CHECK (app_role IN ('app_admin','editor','viewer')),
|
||||||
|
-- invited_by UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
-- expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
-- consumed_at TIMESTAMPTZ NULL
|
||||||
|
-- );
|
||||||
|
--
|
||||||
|
-- CREATE TABLE service_accounts (
|
||||||
|
-- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
-- name TEXT NOT NULL,
|
||||||
|
-- owning_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE RESTRICT,
|
||||||
|
-- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
-- );
|
||||||
15
crates/manager-core/seeds/hello.rhai
Normal file
15
crates/manager-core/seeds/hello.rhai
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Hello World — the reference example seeded into the default app on
|
||||||
|
// fresh installs. Bound to GET /hello.
|
||||||
|
|
||||||
|
let who = ctx.request.body;
|
||||||
|
let name = if who != () && type_of(who) == "map" && who.contains("name") {
|
||||||
|
who.name
|
||||||
|
} else {
|
||||||
|
"world"
|
||||||
|
};
|
||||||
|
|
||||||
|
return #{
|
||||||
|
statusCode: 200,
|
||||||
|
headers: #{ "Content-Type": "application/json" },
|
||||||
|
body: #{ message: `Hello, ${name}!` }
|
||||||
|
};
|
||||||
152
crates/manager-core/src/admin_session_repo.rs
Normal file
152
crates/manager-core/src/admin_session_repo.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
//! CRUD over the `admin_sessions` table.
|
||||||
|
//!
|
||||||
|
//! The token never appears in this module — only its SHA-256 hash. The
|
||||||
|
//! raw value lives in `auth::GeneratedToken` long enough to hit the
|
||||||
|
//! cookie and the JSON response, then is forgotten. Lookups also filter
|
||||||
|
//! expired rows at query time so a delayed prune sweep can never extend
|
||||||
|
//! a session's life.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::AdminUserId;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AdminSessionRepositoryError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a session lookup. Includes the user id (for auth context)
|
||||||
|
/// and the existing `expires_at` so the middleware can decide whether
|
||||||
|
/// the sliding window bump is worth a write.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AdminSessionLookup {
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AdminSessionRepository: Send + Sync {
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
token_hash: &str,
|
||||||
|
expires_at: DateTime<Utc>,
|
||||||
|
) -> Result<(), AdminSessionRepositoryError>;
|
||||||
|
/// Look up a session by token hash. Returns `None` for missing or
|
||||||
|
/// already-expired rows (the query filters them).
|
||||||
|
async fn lookup(
|
||||||
|
&self,
|
||||||
|
token_hash: &str,
|
||||||
|
) -> Result<Option<AdminSessionLookup>, AdminSessionRepositoryError>;
|
||||||
|
/// Sliding-window bump. Sets `last_used_at = NOW()` and `expires_at`
|
||||||
|
/// to the supplied value.
|
||||||
|
async fn touch(
|
||||||
|
&self,
|
||||||
|
token_hash: &str,
|
||||||
|
new_expires_at: DateTime<Utc>,
|
||||||
|
) -> Result<(), AdminSessionRepositoryError>;
|
||||||
|
async fn delete(&self, token_hash: &str) -> Result<(), AdminSessionRepositoryError>;
|
||||||
|
/// Delete every session belonging to a user. Used when the user is
|
||||||
|
/// deactivated or has their password reset out-of-band — both
|
||||||
|
/// invalidate all current logins for that account.
|
||||||
|
async fn delete_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<u64, AdminSessionRepositoryError>;
|
||||||
|
/// Sweep expired rows. The auth middleware filters expired rows on
|
||||||
|
/// lookup, so this is just bounded-growth hygiene, not correctness.
|
||||||
|
async fn prune_expired(&self) -> Result<u64, AdminSessionRepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresAdminSessionRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresAdminSessionRepository {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AdminSessionRepository for PostgresAdminSessionRepository {
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
token_hash: &str,
|
||||||
|
expires_at: DateTime<Utc>,
|
||||||
|
) -> Result<(), AdminSessionRepositoryError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO admin_sessions (token_hash, user_id, expires_at) \
|
||||||
|
VALUES ($1, $2, $3)",
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.bind(expires_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn lookup(
|
||||||
|
&self,
|
||||||
|
token_hash: &str,
|
||||||
|
) -> Result<Option<AdminSessionLookup>, AdminSessionRepositoryError> {
|
||||||
|
let row: Option<(uuid::Uuid, DateTime<Utc>)> = sqlx::query_as(
|
||||||
|
"SELECT user_id, expires_at FROM admin_sessions \
|
||||||
|
WHERE token_hash = $1 AND expires_at > NOW()",
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|(uid, exp)| AdminSessionLookup {
|
||||||
|
user_id: uid.into(),
|
||||||
|
expires_at: exp,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn touch(
|
||||||
|
&self,
|
||||||
|
token_hash: &str,
|
||||||
|
new_expires_at: DateTime<Utc>,
|
||||||
|
) -> Result<(), AdminSessionRepositoryError> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE admin_sessions SET last_used_at = NOW(), expires_at = $2 \
|
||||||
|
WHERE token_hash = $1",
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(new_expires_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, token_hash: &str) -> Result<(), AdminSessionRepositoryError> {
|
||||||
|
sqlx::query("DELETE FROM admin_sessions WHERE token_hash = $1")
|
||||||
|
.bind(token_hash)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<u64, AdminSessionRepositoryError> {
|
||||||
|
let res = sqlx::query("DELETE FROM admin_sessions WHERE user_id = $1")
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prune_expired(&self) -> Result<u64, AdminSessionRepositoryError> {
|
||||||
|
let res = sqlx::query("DELETE FROM admin_sessions WHERE expires_at <= NOW()")
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected())
|
||||||
|
}
|
||||||
|
}
|
||||||
466
crates/manager-core/src/admin_user_repo.rs
Normal file
466
crates/manager-core/src/admin_user_repo.rs
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
//! CRUD over the `admin_users` table.
|
||||||
|
//!
|
||||||
|
//! Password hashes go in and come out as opaque strings — this module
|
||||||
|
//! never inspects or computes them; that's `auth.rs`'s job. The "must
|
||||||
|
//! keep at least one active admin" guard is implemented as a separate
|
||||||
|
//! count query the API layer composes around `set_active` / `delete`.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::{AdminUserId, InstanceRole};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AdminUserRepositoryError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
|
||||||
|
#[error("not found: {0}")]
|
||||||
|
NotFound(AdminUserId),
|
||||||
|
|
||||||
|
#[error("username already taken: {0}")]
|
||||||
|
DuplicateUsername(String),
|
||||||
|
|
||||||
|
#[error("email already taken: {0}")]
|
||||||
|
DuplicateEmail(String),
|
||||||
|
|
||||||
|
#[error("invalid instance_role stored in DB: {0}")]
|
||||||
|
InvalidInstanceRole(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row returned to handlers and bootstrap. Never includes the password
|
||||||
|
/// hash by accident — that lives in `AdminUserCredentials` (separate
|
||||||
|
/// fetch from `get_credentials_by_username`).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AdminUserRow {
|
||||||
|
pub id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub last_login_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Credentials fetched for the login path only. Splitting the hash off
|
||||||
|
/// from the public row makes it obvious in handler code which calls
|
||||||
|
/// touch a secret.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AdminUserCredentials {
|
||||||
|
pub id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AdminUserRepository: Send + Sync {
|
||||||
|
async fn get(&self, id: AdminUserId) -> Result<Option<AdminUserRow>, AdminUserRepositoryError>;
|
||||||
|
async fn get_by_username(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError>;
|
||||||
|
async fn get_credentials_by_username(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError>;
|
||||||
|
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
|
||||||
|
/// Create a new admin. `instance_role` defaults to `Owner` for the
|
||||||
|
/// env-var bootstrap path; admin-creates-admin flows pass an
|
||||||
|
/// explicit role. `email` is optional — pass `None` to leave the
|
||||||
|
/// column NULL.
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
instance_role: InstanceRole,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
|
async fn update_username(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
|
async fn update_password_hash(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
|
/// Set or clear the email address. `None` writes NULL to the column.
|
||||||
|
async fn update_email(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
|
/// Update the instance_role. Used by `PATCH /api/v1/admin/admins/{id}`;
|
||||||
|
/// callers enforce the last-owner guard (`count_other_active_owners`)
|
||||||
|
/// before invoking when role transitions away from `Owner`.
|
||||||
|
async fn update_instance_role(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
instance_role: InstanceRole,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
|
async fn set_active(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
is_active: bool,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
|
async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError>;
|
||||||
|
async fn touch_last_login(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError>;
|
||||||
|
/// Count of `is_active = true` rows. Used at bootstrap to decide
|
||||||
|
/// whether to seed the first admin.
|
||||||
|
async fn count_active(&self) -> Result<i64, AdminUserRepositoryError>;
|
||||||
|
/// Count of `is_active = true` rows excluding the given id. Used by
|
||||||
|
/// last-admin protection: "would deactivating / deleting this user
|
||||||
|
/// leave zero active admins?"
|
||||||
|
async fn count_active_excluding(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
) -> Result<i64, AdminUserRepositoryError>;
|
||||||
|
/// All active owners — used for the multi-owner startup warning.
|
||||||
|
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
|
||||||
|
/// Count of active owners excluding the given id. Used by the
|
||||||
|
/// last-owner guard when demoting / deactivating / deleting an
|
||||||
|
/// owner: "would this leave zero owners?"
|
||||||
|
async fn count_other_active_owners(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
) -> Result<i64, AdminUserRepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresAdminUserRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresAdminUserRepository {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AdminUserRepository for PostgresAdminUserRepository {
|
||||||
|
async fn get(&self, id: AdminUserId) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"SELECT id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at \
|
||||||
|
FROM admin_users WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_username(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"SELECT id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at \
|
||||||
|
FROM admin_users WHERE username = $1",
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_credentials_by_username(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AdminCredsRecord>(
|
||||||
|
"SELECT id, username, password_hash, is_active, instance_role \
|
||||||
|
FROM admin_users WHERE username = $1",
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"SELECT id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at \
|
||||||
|
FROM admin_users ORDER BY username",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
instance_role: InstanceRole,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"INSERT INTO admin_users (username, password_hash, instance_role, email) \
|
||||||
|
VALUES ($1, $2, $3, $4) \
|
||||||
|
RETURNING id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.bind(password_hash)
|
||||||
|
.bind(instance_role.as_str())
|
||||||
|
.bind(email)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(row) => row.try_into(),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
// username and email both have unique constraints; the
|
||||||
|
// create path can collide on either, so peek at the
|
||||||
|
// constraint name to surface the right error.
|
||||||
|
if e.constraint() == Some("admin_users_email_key") {
|
||||||
|
Err(AdminUserRepositoryError::DuplicateEmail(
|
||||||
|
email.unwrap_or("").to_string(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Err(AdminUserRepositoryError::DuplicateUsername(
|
||||||
|
username.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_username(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"UPDATE admin_users SET username = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(Some(row)) => row.try_into(),
|
||||||
|
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||||
|
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
|
||||||
|
),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_password_hash(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"UPDATE admin_users SET password_hash = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(password_hash)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||||
|
.and_then(TryInto::try_into)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_email(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"UPDATE admin_users SET email = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(email)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(Some(row)) => row.try_into(),
|
||||||
|
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||||
|
AdminUserRepositoryError::DuplicateEmail(email.unwrap_or("").to_string()),
|
||||||
|
),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_instance_role(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
instance_role: InstanceRole,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"UPDATE admin_users SET instance_role = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(instance_role.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||||
|
.and_then(TryInto::try_into)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_active(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
is_active: bool,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"UPDATE admin_users SET is_active = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(is_active)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||||
|
.and_then(TryInto::try_into)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||||
|
let res = sqlx::query("DELETE FROM admin_users WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(AdminUserRepositoryError::NotFound(id));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn touch_last_login(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||||
|
sqlx::query("UPDATE admin_users SET last_login_at = NOW() WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_active(&self) -> Result<i64, AdminUserRepositoryError> {
|
||||||
|
let (count,): (i64,) =
|
||||||
|
sqlx::query_as("SELECT COUNT(*)::BIGINT FROM admin_users WHERE is_active")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_active_excluding(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
) -> Result<i64, AdminUserRepositoryError> {
|
||||||
|
let (count,): (i64,) =
|
||||||
|
sqlx::query_as("SELECT COUNT(*)::BIGINT FROM admin_users WHERE is_active AND id <> $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"SELECT id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at \
|
||||||
|
FROM admin_users \
|
||||||
|
WHERE is_active AND instance_role = 'owner' \
|
||||||
|
ORDER BY username",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_other_active_owners(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
) -> Result<i64, AdminUserRepositoryError> {
|
||||||
|
let (count,): (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*)::BIGINT FROM admin_users \
|
||||||
|
WHERE is_active AND instance_role = 'owner' AND id <> $1",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AdminUserRecord {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
username: String,
|
||||||
|
is_active: bool,
|
||||||
|
instance_role: String,
|
||||||
|
email: Option<String>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
updated_at: DateTime<Utc>,
|
||||||
|
last_login_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<AdminUserRecord> for AdminUserRow {
|
||||||
|
type Error = AdminUserRepositoryError;
|
||||||
|
fn try_from(r: AdminUserRecord) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
id: r.id.into(),
|
||||||
|
username: r.username,
|
||||||
|
is_active: r.is_active,
|
||||||
|
instance_role: InstanceRole::from_db_str(&r.instance_role).ok_or(
|
||||||
|
AdminUserRepositoryError::InvalidInstanceRole(r.instance_role),
|
||||||
|
)?,
|
||||||
|
email: r.email,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
last_login_at: r.last_login_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AdminCredsRecord {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
username: String,
|
||||||
|
password_hash: String,
|
||||||
|
is_active: bool,
|
||||||
|
instance_role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<AdminCredsRecord> for AdminUserCredentials {
|
||||||
|
type Error = AdminUserRepositoryError;
|
||||||
|
fn try_from(r: AdminCredsRecord) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
id: r.id.into(),
|
||||||
|
username: r.username,
|
||||||
|
password_hash: r.password_hash,
|
||||||
|
is_active: r.is_active,
|
||||||
|
instance_role: InstanceRole::from_db_str(&r.instance_role).ok_or(
|
||||||
|
AdminUserRepositoryError::InvalidInstanceRole(r.instance_role),
|
||||||
|
)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
533
crates/manager-core/src/admin_users_api.rs
Normal file
533
crates/manager-core/src/admin_users_api.rs
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
//! `/api/v1/admin/admins/*` — admin user CRUD. Guarded by
|
||||||
|
//! `require_admin`; every authenticated admin can call all of these.
|
||||||
|
//! Role/permission walls land later (see blueprint §11.4 — no
|
||||||
|
//! privilege levels in this cut).
|
||||||
|
//!
|
||||||
|
//! "Last active admin" protection lives at the service layer (not just
|
||||||
|
//! the DB) so it can produce a clean 422 with a human-readable message
|
||||||
|
//! rather than a SQL constraint violation. Deactivating a user also
|
||||||
|
//! wipes their sessions; deleting cascades through the FK.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::{Extension, Router};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::{AdminUserId, InstanceRole, Principal};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::admin_session_repo::AdminSessionRepository;
|
||||||
|
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
|
||||||
|
use crate::api_key_repo::ApiKeyRepository;
|
||||||
|
use crate::auth::hash_password;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
|
|
||||||
|
/// Validation knobs are tuned by NIST 800-63B-ish guidance: username is
|
||||||
|
/// a strict ASCII subset so the lookup column stays predictable, and
|
||||||
|
/// password has a minimum length but no complexity rules (complexity
|
||||||
|
/// rules push users to predictable patterns).
|
||||||
|
const USERNAME_MIN: usize = 2;
|
||||||
|
const USERNAME_MAX: usize = 32;
|
||||||
|
const PASSWORD_MIN: usize = 8;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AdminsState {
|
||||||
|
pub users: Arc<dyn AdminUserRepository>,
|
||||||
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||||
|
/// Phase 3.5 deactivation symmetry — flipping `is_active = false`
|
||||||
|
/// also expires every active API key for that user so cookie and
|
||||||
|
/// bearer credentials become inert at the same moment.
|
||||||
|
pub keys: Arc<dyn ApiKeyRepository>,
|
||||||
|
/// Capability gate: every endpoint here requires
|
||||||
|
/// `InstanceManageUsers` (owner / admin).
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn admins_router(state: AdminsState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/admins", get(list_admins).post(create_admin))
|
||||||
|
.route(
|
||||||
|
"/admins/{id}",
|
||||||
|
get(get_admin).patch(patch_admin).delete(delete_admin),
|
||||||
|
)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// DTOs
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AdminDto {
|
||||||
|
pub id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub last_login_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AdminUserRow> for AdminDto {
|
||||||
|
fn from(r: AdminUserRow) -> Self {
|
||||||
|
Self {
|
||||||
|
id: r.id,
|
||||||
|
username: r.username,
|
||||||
|
is_active: r.is_active,
|
||||||
|
instance_role: r.instance_role,
|
||||||
|
email: r.email,
|
||||||
|
created_at: r.created_at,
|
||||||
|
last_login_at: r.last_login_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateAdminRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
/// Defaults to `Admin` when absent — minting an owner via the API
|
||||||
|
/// is a deliberate step. The env-var bootstrap path is the only
|
||||||
|
/// channel that defaults to `Owner`.
|
||||||
|
#[serde(default = "default_create_role")]
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
/// Optional contact email. Blank/whitespace is normalized to None.
|
||||||
|
#[serde(default)]
|
||||||
|
pub email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_create_role() -> InstanceRole {
|
||||||
|
InstanceRole::Admin
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
pub struct PatchAdminRequest {
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub is_active: Option<bool>,
|
||||||
|
pub instance_role: Option<InstanceRole>,
|
||||||
|
/// JSON Merge Patch (RFC 7396) semantics for email:
|
||||||
|
/// absent → don't change
|
||||||
|
/// null → clear (set DB column to NULL)
|
||||||
|
/// "<string>" → set to that string
|
||||||
|
/// `Option<Option<T>>` is the idiomatic Rust shape for that
|
||||||
|
/// tri-state; the custom deserializer below distinguishes the
|
||||||
|
/// "missing" case from the "present-and-null" case that serde
|
||||||
|
/// would otherwise collapse together.
|
||||||
|
#[allow(clippy::option_option)]
|
||||||
|
#[serde(default, deserialize_with = "deserialize_present_optional")]
|
||||||
|
pub email: Option<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::option_option)]
|
||||||
|
fn deserialize_present_optional<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
|
||||||
|
where
|
||||||
|
T: serde::Deserialize<'de>,
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(Some(Option::<T>::deserialize(deserializer)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_admins(
|
||||||
|
State(state): State<AdminsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
) -> Result<Json<Vec<AdminDto>>, AdminApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let rows = state.users.list().await?;
|
||||||
|
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_admin(
|
||||||
|
State(state): State<AdminsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id): Path<AdminUserId>,
|
||||||
|
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
state
|
||||||
|
.users
|
||||||
|
.get(id)
|
||||||
|
.await?
|
||||||
|
.map(AdminDto::from)
|
||||||
|
.map(Json)
|
||||||
|
.ok_or(AdminApiError::NotFound(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_admin(
|
||||||
|
State(state): State<AdminsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Json(input): Json<CreateAdminRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<AdminDto>), AdminApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// Minting an owner via the API requires the caller to ALSO be an
|
||||||
|
// owner — admin cannot self-elevate (or elevate someone else)
|
||||||
|
// beyond their own ceiling. Owner-creation by env-var bootstrap
|
||||||
|
// bypasses this path.
|
||||||
|
if input.instance_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner
|
||||||
|
{
|
||||||
|
return Err(AdminApiError::CannotEscalate);
|
||||||
|
}
|
||||||
|
let username = input.username.trim();
|
||||||
|
validate_username(username)?;
|
||||||
|
validate_password(&input.password)?;
|
||||||
|
let email = normalize_email(input.email.as_deref())?;
|
||||||
|
let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
|
||||||
|
let row = state
|
||||||
|
.users
|
||||||
|
.create(username, &hash, input.instance_role, email.as_deref())
|
||||||
|
.await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(row.into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_admin(
|
||||||
|
State(state): State<AdminsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id): Path<AdminUserId>,
|
||||||
|
Json(input): Json<PatchAdminRequest>,
|
||||||
|
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// Verify the target exists upfront — keeps the error path uniform
|
||||||
|
// for "rename a missing user" etc.
|
||||||
|
let current = state
|
||||||
|
.users
|
||||||
|
.get(id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AdminApiError::NotFound(id))?;
|
||||||
|
|
||||||
|
let mut latest: Option<AdminUserRow> = None;
|
||||||
|
|
||||||
|
if let Some(raw_username) = input.username.as_deref() {
|
||||||
|
let new_username = raw_username.trim();
|
||||||
|
validate_username(new_username)?;
|
||||||
|
latest = Some(state.users.update_username(id, new_username).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(new_password) = input.password.as_deref() {
|
||||||
|
validate_password(new_password)?;
|
||||||
|
let hash = hash_password(new_password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
|
||||||
|
latest = Some(state.users.update_password_hash(id, &hash).await?);
|
||||||
|
// Best practice: rotating your own password should still keep
|
||||||
|
// your session alive, so we don't wipe sessions here. (If we
|
||||||
|
// wanted "log everyone else out on password change", that'd be
|
||||||
|
// a `delete_for_user` + re-issue current session. Out of scope
|
||||||
|
// for the initial cut.)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(email_patch) = input.email.as_ref() {
|
||||||
|
// email_patch is Some(None) → clear, Some(Some(s)) → set.
|
||||||
|
let normalized = normalize_email(email_patch.as_deref())?;
|
||||||
|
latest = Some(state.users.update_email(id, normalized.as_deref()).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(new_role) = input.instance_role {
|
||||||
|
// Self-elevation guard: only an owner can promote anyone TO
|
||||||
|
// owner. An admin cannot turn themselves (or anyone else)
|
||||||
|
// into one.
|
||||||
|
if new_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner {
|
||||||
|
return Err(AdminApiError::CannotEscalate);
|
||||||
|
}
|
||||||
|
// Last-active-owner guard: a transition off of `Owner` cannot
|
||||||
|
// leave the install with zero owners. The check is on the
|
||||||
|
// source role (current.instance_role) so demoting an
|
||||||
|
// already-non-owner is always fine.
|
||||||
|
if current.instance_role == InstanceRole::Owner && new_role != InstanceRole::Owner {
|
||||||
|
let remaining = state.users.count_other_active_owners(id).await?;
|
||||||
|
if remaining == 0 {
|
||||||
|
return Err(AdminApiError::LastActiveOwner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
latest = Some(state.users.update_instance_role(id, new_role).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(new_active) = input.is_active {
|
||||||
|
// Last-active-admin guard: only when transitioning to inactive.
|
||||||
|
if !new_active {
|
||||||
|
let remaining = state.users.count_active_excluding(id).await?;
|
||||||
|
if remaining == 0 {
|
||||||
|
return Err(AdminApiError::LastActiveAdmin);
|
||||||
|
}
|
||||||
|
// ALSO: if the target is currently the last active owner,
|
||||||
|
// deactivating them leaves no owner. Belt-and-suspenders to
|
||||||
|
// the role guard above (which only triggers on an explicit
|
||||||
|
// role transition).
|
||||||
|
let target_role = latest
|
||||||
|
.as_ref()
|
||||||
|
.map_or(current.instance_role, |r| r.instance_role);
|
||||||
|
if target_role == InstanceRole::Owner {
|
||||||
|
let remaining_owners = state.users.count_other_active_owners(id).await?;
|
||||||
|
if remaining_owners == 0 {
|
||||||
|
return Err(AdminApiError::LastActiveOwner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
latest = Some(state.users.set_active(id, new_active).await?);
|
||||||
|
// Deactivation invalidates BOTH credential surfaces — sessions
|
||||||
|
// (cookie / session bearer) and API keys. Both writes are
|
||||||
|
// logged on failure but do not undo the deactivation; the
|
||||||
|
// alternative (leaving the user active when one cascade fails)
|
||||||
|
// is worse than slightly stale credential rows on a DB blip.
|
||||||
|
if !new_active {
|
||||||
|
if let Err(err) = state.sessions.delete_for_user(id).await {
|
||||||
|
tracing::error!(?err, "failed to delete sessions for deactivated admin");
|
||||||
|
}
|
||||||
|
match state.keys.expire_all_for_user(id).await {
|
||||||
|
Ok(n) => {
|
||||||
|
if n > 0 {
|
||||||
|
tracing::info!(user_id = %id, expired = n, "expired api keys on deactivation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "failed to expire api keys for deactivated admin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = match latest {
|
||||||
|
Some(r) => r,
|
||||||
|
None => state
|
||||||
|
.users
|
||||||
|
.get(id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AdminApiError::NotFound(id))?,
|
||||||
|
};
|
||||||
|
Ok(Json(row.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_admin(
|
||||||
|
State(state): State<AdminsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id): Path<AdminUserId>,
|
||||||
|
) -> Result<StatusCode, AdminApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let target = state
|
||||||
|
.users
|
||||||
|
.get(id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AdminApiError::NotFound(id))?;
|
||||||
|
if target.is_active {
|
||||||
|
let remaining = state.users.count_active_excluding(id).await?;
|
||||||
|
if remaining == 0 {
|
||||||
|
return Err(AdminApiError::LastActiveAdmin);
|
||||||
|
}
|
||||||
|
// Last-owner guard mirrors the role-transition guard in
|
||||||
|
// patch_admin — deleting the only owner is just as bad as
|
||||||
|
// demoting them.
|
||||||
|
if target.instance_role == InstanceRole::Owner {
|
||||||
|
let remaining_owners = state.users.count_other_active_owners(id).await?;
|
||||||
|
if remaining_owners == 0 {
|
||||||
|
return Err(AdminApiError::LastActiveOwner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.users.delete(id).await?;
|
||||||
|
// Sessions + api_keys cascade via FK; no explicit delete needed.
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Validation
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn validate_username(s: &str) -> Result<(), AdminApiError> {
|
||||||
|
if s.len() < USERNAME_MIN || s.len() > USERNAME_MAX {
|
||||||
|
return Err(AdminApiError::InvalidUsername(format!(
|
||||||
|
"username must be {USERNAME_MIN}-{USERNAME_MAX} characters"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !s
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-'))
|
||||||
|
{
|
||||||
|
return Err(AdminApiError::InvalidUsername(
|
||||||
|
"username may contain only lowercase letters, digits, dot, underscore, and hyphen"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_password(s: &str) -> Result<(), AdminApiError> {
|
||||||
|
if s.chars().count() < PASSWORD_MIN {
|
||||||
|
return Err(AdminApiError::InvalidPassword(format!(
|
||||||
|
"password must be at least {PASSWORD_MIN} characters"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trim and reject empty / pathological emails, returning the
|
||||||
|
/// canonical form (or None when the input was blank). The shape
|
||||||
|
/// check is intentionally loose — we mainly want to reject blanks
|
||||||
|
/// and obvious junk; real verification is a future concern.
|
||||||
|
fn normalize_email(raw: Option<&str>) -> Result<Option<String>, AdminApiError> {
|
||||||
|
let Some(raw) = raw else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if trimmed.len() > 254 || !trimmed.contains('@') {
|
||||||
|
return Err(AdminApiError::InvalidEmail(
|
||||||
|
"email must contain '@' and be at most 254 characters".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Some(trimmed.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AdminApiError {
|
||||||
|
#[error("admin user not found: {0}")]
|
||||||
|
NotFound(AdminUserId),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
InvalidUsername(String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
InvalidPassword(String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
InvalidEmail(String),
|
||||||
|
|
||||||
|
#[error("cannot leave the system with zero active admins")]
|
||||||
|
LastActiveAdmin,
|
||||||
|
|
||||||
|
#[error("cannot leave the system with zero active owners")]
|
||||||
|
LastActiveOwner,
|
||||||
|
|
||||||
|
#[error("only an owner can grant the owner role")]
|
||||||
|
CannotEscalate,
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
|
#[error("failed to hash password: {0}")]
|
||||||
|
Hash(String),
|
||||||
|
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Repo(#[from] AdminUserRepositoryError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for AdminApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AdminApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, message) = match &self {
|
||||||
|
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||||
|
Self::Repo(
|
||||||
|
AdminUserRepositoryError::DuplicateUsername(_)
|
||||||
|
| AdminUserRepositoryError::DuplicateEmail(_),
|
||||||
|
) => (StatusCode::CONFLICT, self.to_string()),
|
||||||
|
Self::InvalidUsername(_)
|
||||||
|
| Self::InvalidPassword(_)
|
||||||
|
| Self::InvalidEmail(_)
|
||||||
|
| Self::LastActiveAdmin
|
||||||
|
| Self::LastActiveOwner
|
||||||
|
| Self::CannotEscalate
|
||||||
|
| Self::Repo(AdminUserRepositoryError::InvalidInstanceRole(_)) => {
|
||||||
|
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||||
|
}
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "admin_users authz error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Repo(AdminUserRepositoryError::NotFound(_)) => {
|
||||||
|
(StatusCode::NOT_FOUND, self.to_string())
|
||||||
|
}
|
||||||
|
Self::Repo(AdminUserRepositoryError::Db(e)) => {
|
||||||
|
tracing::error!(error = %e, "admin_users db error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Hash(_) => {
|
||||||
|
tracing::error!(error = %self, "password hashing failed");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(json!({ "error": message }))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn username_validation_accepts_valid() {
|
||||||
|
for u in ["ab", "alice", "user.name", "a_b-c", "00bot00"] {
|
||||||
|
assert!(validate_username(u).is_ok(), "should accept {u}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn username_validation_rejects_invalid() {
|
||||||
|
for u in ["", "a", "Alice", "user name", "user@domain", "user!"] {
|
||||||
|
assert!(validate_username(u).is_err(), "should reject {u:?}");
|
||||||
|
}
|
||||||
|
let too_long = "x".repeat(33);
|
||||||
|
assert!(validate_username(&too_long).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn password_validation_enforces_min_length() {
|
||||||
|
assert!(validate_password("1234567").is_err());
|
||||||
|
assert!(validate_password("12345678").is_ok());
|
||||||
|
assert!(validate_password("a-very-long-password-with-spaces and stuff").is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,17 +5,20 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
routing::get,
|
||||||
Json, Router,
|
Extension, Json, Router,
|
||||||
};
|
};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
|
AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptSandbox, ScriptValidator,
|
||||||
|
ValidationError,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
use crate::repo::{
|
use crate::repo::{
|
||||||
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
||||||
};
|
};
|
||||||
@@ -27,6 +30,13 @@ use crate::sandbox::{CeilingError, SandboxCeiling};
|
|||||||
pub struct AdminState<R, L> {
|
pub struct AdminState<R, L> {
|
||||||
pub repo: Arc<R>,
|
pub repo: Arc<R>,
|
||||||
pub logs: Arc<L>,
|
pub logs: Arc<L>,
|
||||||
|
/// App lookups: validates `app_id` on create, resolves `?app=<slug>`
|
||||||
|
/// filter on list. Trait-object so apps_repo can stay separate.
|
||||||
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
/// Phase 3.5 capability checks — every script handler resolves
|
||||||
|
/// `AppRead/Write/LogRead(script.app_id)` against this repo after
|
||||||
|
/// loading the resource.
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
pub validator: Arc<dyn ScriptValidator>,
|
pub validator: Arc<dyn ScriptValidator>,
|
||||||
pub sandbox_ceiling: SandboxCeiling,
|
pub sandbox_ceiling: SandboxCeiling,
|
||||||
}
|
}
|
||||||
@@ -36,6 +46,8 @@ impl<R, L> Clone for AdminState<R, L> {
|
|||||||
Self {
|
Self {
|
||||||
repo: self.repo.clone(),
|
repo: self.repo.clone(),
|
||||||
logs: self.logs.clone(),
|
logs: self.logs.clone(),
|
||||||
|
apps: self.apps.clone(),
|
||||||
|
authz: self.authz.clone(),
|
||||||
validator: self.validator.clone(),
|
validator: self.validator.clone(),
|
||||||
sandbox_ceiling: self.sandbox_ceiling,
|
sandbox_ceiling: self.sandbox_ceiling,
|
||||||
}
|
}
|
||||||
@@ -70,6 +82,9 @@ where
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateScriptRequest {
|
pub struct CreateScriptRequest {
|
||||||
|
/// Owning app. Required since Phase 3b — scripts cannot exist
|
||||||
|
/// outside an app. Use `/api/v1/admin/apps` to list known ids.
|
||||||
|
pub app_id: AppId,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub source: String,
|
pub source: String,
|
||||||
@@ -82,6 +97,14 @@ pub struct CreateScriptRequest {
|
|||||||
pub sandbox: ScriptSandbox,
|
pub sandbox: ScriptSandbox,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListScriptsQuery {
|
||||||
|
/// Optional filter: list scripts belonging to a single app, by id
|
||||||
|
/// or slug. Absent = all scripts across all apps (admin-global view).
|
||||||
|
#[serde(default)]
|
||||||
|
pub app: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateScriptRequest {
|
pub struct UpdateScriptRequest {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
@@ -113,31 +136,83 @@ where
|
|||||||
|
|
||||||
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Query(q): Query<ListScriptsQuery>,
|
||||||
) -> Result<Json<Vec<Script>>, ApiError> {
|
) -> Result<Json<Vec<Script>>, ApiError> {
|
||||||
|
// Membership filter: `member` users see only scripts in apps they
|
||||||
|
// belong to. `?app=` filters further by app and additionally
|
||||||
|
// requires the member to belong to that app (the read check uses
|
||||||
|
// the resource's app_id).
|
||||||
|
if let Some(ident) = q.app {
|
||||||
|
let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
|
||||||
|
require(state.authz.as_ref(), &principal, Capability::AppRead(app)).await?;
|
||||||
|
return Ok(Json(state.repo.list_for_app(app).await?));
|
||||||
|
}
|
||||||
|
if principal.instance_role == InstanceRole::Member {
|
||||||
|
return Ok(Json(state.repo.list_for_user(principal.user_id).await?));
|
||||||
|
}
|
||||||
Ok(Json(state.repo.list().await?))
|
Ok(Json(state.repo.list().await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Accept `?app=<uuid>` OR `?app=<slug>`. Slugs route through history
|
||||||
|
/// for redirects, but here we just need the live current id; if a
|
||||||
|
/// retired slug is given, we follow it to the current app silently.
|
||||||
|
async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApiError> {
|
||||||
|
if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||||
|
let id = AppId::from(uuid);
|
||||||
|
apps.get_by_id(id)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
let lookup = apps
|
||||||
|
.get_by_slug_or_history(ident)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
|
||||||
|
Ok(lookup.app.id)
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
) -> Result<Json<Script>, ApiError> {
|
) -> Result<Json<Script>, ApiError> {
|
||||||
state
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||||
.repo
|
require(
|
||||||
.get(id)
|
state.authz.as_ref(),
|
||||||
.await?
|
&principal,
|
||||||
.map(Json)
|
Capability::AppRead(script.app_id),
|
||||||
.ok_or(ApiError::NotFound(id))
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(script))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Json(input): Json<CreateScriptRequest>,
|
Json(input): Json<CreateScriptRequest>,
|
||||||
) -> Result<(StatusCode, Json<Script>), ApiError> {
|
) -> Result<(StatusCode, Json<Script>), ApiError> {
|
||||||
|
// Capability is bound to the *requested* app_id since there's no
|
||||||
|
// resource to load yet. If the app doesn't exist we 422 below;
|
||||||
|
// checking authz first means a Member trying to create against an
|
||||||
|
// unknown app gets 403 (no enumeration of app existence).
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteScript(input.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
state.validator.validate(&input.source)?;
|
state.validator.validate(&input.source)?;
|
||||||
state.sandbox_ceiling.check(&input.sandbox)?;
|
state.sandbox_ceiling.check(&input.sandbox)?;
|
||||||
|
// Refuse early if the app_id doesn't exist — a clean 422 beats a
|
||||||
|
// raw FK violation surfacing as 500.
|
||||||
|
if state.apps.get_by_id(input.app_id).await?.is_none() {
|
||||||
|
return Err(ApiError::AppNotFound(input.app_id.to_string()));
|
||||||
|
}
|
||||||
let created = state
|
let created = state
|
||||||
.repo
|
.repo
|
||||||
.create(NewScript {
|
.create(NewScript {
|
||||||
|
app_id: input.app_id,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
source: input.source,
|
source: input.source,
|
||||||
@@ -155,9 +230,17 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
|
|
||||||
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
Json(input): Json<UpdateScriptRequest>,
|
Json(input): Json<UpdateScriptRequest>,
|
||||||
) -> Result<Json<Script>, ApiError> {
|
) -> Result<Json<Script>, ApiError> {
|
||||||
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteScript(script.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
if let Some(src) = input.source.as_deref() {
|
if let Some(src) = input.source.as_deref() {
|
||||||
state.validator.validate(src)?;
|
state.validator.validate(src)?;
|
||||||
}
|
}
|
||||||
@@ -183,8 +266,19 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
|
|
||||||
async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
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))?;
|
||||||
|
// 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(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppAdmin(script.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
state.repo.delete(id).await?;
|
state.repo.delete(id).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -203,9 +297,17 @@ const fn default_limit() -> i64 {
|
|||||||
|
|
||||||
async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
axum::extract::Query(q): axum::extract::Query<LogsQuery>,
|
axum::extract::Query(q): axum::extract::Query<LogsQuery>,
|
||||||
) -> Result<Json<Vec<ExecutionLog>>, ApiError> {
|
) -> Result<Json<Vec<ExecutionLog>>, ApiError> {
|
||||||
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppLogRead(script.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
// Cap to keep the dashboard responsive; the data plane writes are
|
// Cap to keep the dashboard responsive; the data plane writes are
|
||||||
// unbounded over time so a paged read is the only sane default.
|
// unbounded over time so a paged read is the only sane default.
|
||||||
let limit = q.limit.clamp(1, 200);
|
let limit = q.limit.clamp(1, 200);
|
||||||
@@ -223,6 +325,9 @@ pub enum ApiError {
|
|||||||
#[error("script not found: {0}")]
|
#[error("script not found: {0}")]
|
||||||
NotFound(ScriptId),
|
NotFound(ScriptId),
|
||||||
|
|
||||||
|
#[error("app not found: {0}")]
|
||||||
|
AppNotFound(String),
|
||||||
|
|
||||||
#[error("conflict: {0}")]
|
#[error("conflict: {0}")]
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
|
|
||||||
@@ -232,18 +337,42 @@ pub enum ApiError {
|
|||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Ceiling(#[from] CeilingError),
|
Ceiling(#[from] CeilingError),
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
#[error("repository error: {0}")]
|
#[error("repository error: {0}")]
|
||||||
Repo(#[from] ScriptRepositoryError),
|
Repo(#[from] ScriptRepositoryError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for ApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, message) = match &self {
|
let (status, message) = match &self {
|
||||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||||
|
Self::AppNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
|
||||||
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
||||||
Self::Invalid(_) | Self::Ceiling(_) => {
|
Self::Invalid(_) | Self::Ceiling(_) => {
|
||||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||||
}
|
}
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||||
(StatusCode::NOT_FOUND, self.to_string())
|
(StatusCode::NOT_FOUND, self.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
292
crates/manager-core/src/api_key_repo.rs
Normal file
292
crates/manager-core/src/api_key_repo.rs
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
//! CRUD over the `api_keys` table — backs the `Authorization: Bearer
|
||||||
|
//! pic_…` credential flow from blueprint §11.6.
|
||||||
|
//!
|
||||||
|
//! The repo never sees the raw token; only the 8-char `prefix` and the
|
||||||
|
//! Argon2id `hash`. Mint logic (random-bytes generation, prefix split,
|
||||||
|
//! hash compute) lives in `api_keys_api.rs`. Verification logic
|
||||||
|
//! (prefix lookup + Argon2 verify per candidate) lives in
|
||||||
|
//! `auth_middleware.rs`. Both call this repo for the storage layer.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::{AdminUserId, ApiKeyId, AppId, Scope};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ApiKeyRepositoryError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
|
||||||
|
#[error("api key not found: {0}")]
|
||||||
|
NotFound(ApiKeyId),
|
||||||
|
|
||||||
|
#[error("invalid scope stored in DB: {0}")]
|
||||||
|
InvalidScope(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert payload — built by `api_keys_api` after generating the raw
|
||||||
|
/// token and hashing it. `hash` is an Argon2id PHC string covering the
|
||||||
|
/// body of the token (everything after `pic_`); `prefix` is the first
|
||||||
|
/// 8 chars of that body, indexed for fast candidate lookup.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NewApiKey {
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub hash: String,
|
||||||
|
pub prefix: String,
|
||||||
|
pub name: String,
|
||||||
|
pub scopes: Vec<Scope>,
|
||||||
|
pub app_id: Option<AppId>,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public-facing row — never exposes the hash. Used for `GET
|
||||||
|
/// /admin/api-keys` and the `POST` response (alongside the
|
||||||
|
/// one-shot raw token).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ApiKeyRow {
|
||||||
|
pub id: ApiKeyId,
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub prefix: String,
|
||||||
|
pub name: String,
|
||||||
|
pub scopes: Vec<Scope>,
|
||||||
|
pub app_id: Option<AppId>,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
pub last_used_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verification candidate — includes the Argon2id `hash` and `user_id`
|
||||||
|
/// so middleware can verify the supplied token and assemble the
|
||||||
|
/// `Principal`. Kept separate from `ApiKeyRow` so handlers can't leak
|
||||||
|
/// the hash through a careless `Json(row)`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ApiKeyVerification {
|
||||||
|
pub id: ApiKeyId,
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub hash: String,
|
||||||
|
pub scopes: Vec<Scope>,
|
||||||
|
pub app_id: Option<AppId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ApiKeyRepository: Send + Sync {
|
||||||
|
/// Mint. Caller has already hashed the raw token + computed prefix.
|
||||||
|
async fn create(&self, key: NewApiKey) -> Result<ApiKeyRow, ApiKeyRepositoryError>;
|
||||||
|
|
||||||
|
/// Return every non-expired key with the given 8-char prefix. The
|
||||||
|
/// caller (middleware) Argon2-verifies the supplied token against
|
||||||
|
/// each candidate's `hash`. Returning a Vec rather than one row
|
||||||
|
/// keeps the contract correct even if two keys happen to share a
|
||||||
|
/// prefix (statistically near-zero but possible).
|
||||||
|
async fn find_active_by_prefix(
|
||||||
|
&self,
|
||||||
|
prefix: &str,
|
||||||
|
) -> Result<Vec<ApiKeyVerification>, ApiKeyRepositoryError>;
|
||||||
|
|
||||||
|
/// Update `last_used_at` for an authenticated request. Inline (not
|
||||||
|
/// fire-and-forget) so a DB blip surfaces as a 500 rather than
|
||||||
|
/// silent stale timestamps.
|
||||||
|
async fn touch_last_used(&self, id: ApiKeyId) -> Result<(), ApiKeyRepositoryError>;
|
||||||
|
|
||||||
|
/// Caller's own keys, for `GET /admin/api-keys`.
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<Vec<ApiKeyRow>, ApiKeyRepositoryError>;
|
||||||
|
|
||||||
|
/// Look up a key by id — used by `DELETE` to verify ownership
|
||||||
|
/// before issuing the delete.
|
||||||
|
async fn get(&self, id: ApiKeyId) -> Result<Option<ApiKeyRow>, ApiKeyRepositoryError>;
|
||||||
|
|
||||||
|
/// Delete the row only if it belongs to `user_id`. Returns whether
|
||||||
|
/// a row was actually deleted (false = key didn't exist OR wasn't
|
||||||
|
/// theirs — handlers map both to 404 to avoid leaking the
|
||||||
|
/// distinction).
|
||||||
|
async fn delete_by_id_and_user(
|
||||||
|
&self,
|
||||||
|
id: ApiKeyId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<bool, ApiKeyRepositoryError>;
|
||||||
|
|
||||||
|
/// Set `expires_at = NOW()` on every active key for a user. Wired
|
||||||
|
/// into `set_active(false)` so deactivation invalidates both
|
||||||
|
/// sessions (already done by `AdminSessionRepository::delete_for_user`)
|
||||||
|
/// and bearer keys at the same moment.
|
||||||
|
async fn expire_all_for_user(&self, user_id: AdminUserId)
|
||||||
|
-> Result<u64, ApiKeyRepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresApiKeyRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresApiKeyRepository {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ApiKeyRepository for PostgresApiKeyRepository {
|
||||||
|
async fn create(&self, key: NewApiKey) -> Result<ApiKeyRow, ApiKeyRepositoryError> {
|
||||||
|
let scope_strings: Vec<String> =
|
||||||
|
key.scopes.iter().map(|s| s.as_str().to_string()).collect();
|
||||||
|
let row = sqlx::query_as::<_, ApiKeyRecord>(
|
||||||
|
"INSERT INTO api_keys \
|
||||||
|
(user_id, hash, prefix, name, scopes, app_id, expires_at) \
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||||
|
RETURNING id, user_id, prefix, name, scopes, app_id, \
|
||||||
|
expires_at, last_used_at, created_at",
|
||||||
|
)
|
||||||
|
.bind(key.user_id.into_inner())
|
||||||
|
.bind(&key.hash)
|
||||||
|
.bind(&key.prefix)
|
||||||
|
.bind(&key.name)
|
||||||
|
.bind(&scope_strings)
|
||||||
|
.bind(key.app_id.map(picloud_shared::AppId::into_inner))
|
||||||
|
.bind(key.expires_at)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.try_into()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_active_by_prefix(
|
||||||
|
&self,
|
||||||
|
prefix: &str,
|
||||||
|
) -> Result<Vec<ApiKeyVerification>, ApiKeyRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, ApiKeyVerifyRecord>(
|
||||||
|
"SELECT id, user_id, hash, scopes, app_id \
|
||||||
|
FROM api_keys \
|
||||||
|
WHERE prefix = $1 \
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())",
|
||||||
|
)
|
||||||
|
.bind(prefix)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn touch_last_used(&self, id: ApiKeyId) -> Result<(), ApiKeyRepositoryError> {
|
||||||
|
sqlx::query("UPDATE api_keys SET last_used_at = NOW() WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<Vec<ApiKeyRow>, ApiKeyRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, ApiKeyRecord>(
|
||||||
|
"SELECT id, user_id, prefix, name, scopes, app_id, \
|
||||||
|
expires_at, last_used_at, created_at \
|
||||||
|
FROM api_keys WHERE user_id = $1 \
|
||||||
|
ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, id: ApiKeyId) -> Result<Option<ApiKeyRow>, ApiKeyRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, ApiKeyRecord>(
|
||||||
|
"SELECT id, user_id, prefix, name, scopes, app_id, \
|
||||||
|
expires_at, last_used_at, created_at \
|
||||||
|
FROM api_keys WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_by_id_and_user(
|
||||||
|
&self,
|
||||||
|
id: ApiKeyId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<bool, ApiKeyRepositoryError> {
|
||||||
|
let res = sqlx::query("DELETE FROM api_keys WHERE id = $1 AND user_id = $2")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn expire_all_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<u64, ApiKeyRepositoryError> {
|
||||||
|
let res = sqlx::query(
|
||||||
|
"UPDATE api_keys \
|
||||||
|
SET expires_at = NOW() \
|
||||||
|
WHERE user_id = $1 \
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())",
|
||||||
|
)
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct ApiKeyRecord {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
prefix: String,
|
||||||
|
name: String,
|
||||||
|
scopes: Vec<String>,
|
||||||
|
app_id: Option<uuid::Uuid>,
|
||||||
|
expires_at: Option<DateTime<Utc>>,
|
||||||
|
last_used_at: Option<DateTime<Utc>>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<ApiKeyRecord> for ApiKeyRow {
|
||||||
|
type Error = ApiKeyRepositoryError;
|
||||||
|
fn try_from(r: ApiKeyRecord) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
id: r.id.into(),
|
||||||
|
user_id: r.user_id.into(),
|
||||||
|
prefix: r.prefix,
|
||||||
|
name: r.name,
|
||||||
|
scopes: parse_scopes(r.scopes)?,
|
||||||
|
app_id: r.app_id.map(Into::into),
|
||||||
|
expires_at: r.expires_at,
|
||||||
|
last_used_at: r.last_used_at,
|
||||||
|
created_at: r.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct ApiKeyVerifyRecord {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
hash: String,
|
||||||
|
scopes: Vec<String>,
|
||||||
|
app_id: Option<uuid::Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<ApiKeyVerifyRecord> for ApiKeyVerification {
|
||||||
|
type Error = ApiKeyRepositoryError;
|
||||||
|
fn try_from(r: ApiKeyVerifyRecord) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
id: r.id.into(),
|
||||||
|
user_id: r.user_id.into(),
|
||||||
|
hash: r.hash,
|
||||||
|
scopes: parse_scopes(r.scopes)?,
|
||||||
|
app_id: r.app_id.map(Into::into),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_scopes(raw: Vec<String>) -> Result<Vec<Scope>, ApiKeyRepositoryError> {
|
||||||
|
raw.into_iter()
|
||||||
|
.map(|s| Scope::from_wire(&s).ok_or(ApiKeyRepositoryError::InvalidScope(s)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
251
crates/manager-core/src/api_keys_api.rs
Normal file
251
crates/manager-core/src/api_keys_api.rs
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
//! `/api/v1/admin/api-keys/*` — bearer API key CRUD (blueprint §11.6).
|
||||||
|
//!
|
||||||
|
//! All endpoints are guarded by `require_authenticated`. Capability
|
||||||
|
//! checks: none — every authenticated user manages **their own** keys.
|
||||||
|
//! The repo enforces caller ownership on `delete`, and `list` is
|
||||||
|
//! scoped to the caller's user_id. No instance-level authority is
|
||||||
|
//! exposed (no listing other users' keys, no admin-issued keys for
|
||||||
|
//! another user — those flows belong with the invite system).
|
||||||
|
//!
|
||||||
|
//! Mint semantics:
|
||||||
|
//! * raw token is returned **exactly once** in the POST response and
|
||||||
|
//! never logged. Lose it = mint a new key.
|
||||||
|
//! * `app_id` (optional) binds the key to one app; capability checks
|
||||||
|
//! deny every `App*(other_app)`.
|
||||||
|
//! * scopes containing `instance:*` are rejected when `app_id` is
|
||||||
|
//! set — the combination is irreconcilable.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::{delete, get};
|
||||||
|
use axum::{Extension, Router};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::{ApiKeyId, AppId, Principal, Scope};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::api_key_repo::{ApiKeyRepository, ApiKeyRepositoryError, ApiKeyRow, NewApiKey};
|
||||||
|
use crate::auth::generate_api_key;
|
||||||
|
|
||||||
|
/// Validation bounds for the user-supplied `name` field — keeps the
|
||||||
|
/// dashboard's list view tidy and rejects accidental whole-token
|
||||||
|
/// pastes.
|
||||||
|
const NAME_MIN: usize = 1;
|
||||||
|
const NAME_MAX: usize = 64;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ApiKeysState {
|
||||||
|
pub keys: Arc<dyn ApiKeyRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn api_keys_router(state: ApiKeysState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/api-keys", get(list_keys).post(mint_key))
|
||||||
|
.route("/api-keys/{id}", delete(delete_key))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// DTOs
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct MintApiKeyRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub scopes: Vec<Scope>,
|
||||||
|
/// When set, the key is bound to this app — every `App*(other)`
|
||||||
|
/// capability is denied regardless of role.
|
||||||
|
#[serde(default)]
|
||||||
|
pub app_id: Option<AppId>,
|
||||||
|
/// When set, lookup rejects the key after this instant. Absent =
|
||||||
|
/// never expires (until explicit DELETE).
|
||||||
|
#[serde(default)]
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response body for a freshly-minted key. `raw_token` only appears
|
||||||
|
/// here — `GET /api-keys` returns `ApiKeyDto` without it.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct MintApiKeyResponse {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub key: ApiKeyDto,
|
||||||
|
/// The full wire-format token (`pic_<base32>`). Shown exactly once;
|
||||||
|
/// store it client-side immediately.
|
||||||
|
pub raw_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ApiKeyDto {
|
||||||
|
pub id: ApiKeyId,
|
||||||
|
pub prefix: String,
|
||||||
|
pub name: String,
|
||||||
|
pub scopes: Vec<Scope>,
|
||||||
|
pub app_id: Option<AppId>,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
pub last_used_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ApiKeyRow> for ApiKeyDto {
|
||||||
|
fn from(r: ApiKeyRow) -> Self {
|
||||||
|
Self {
|
||||||
|
id: r.id,
|
||||||
|
prefix: r.prefix,
|
||||||
|
name: r.name,
|
||||||
|
scopes: r.scopes,
|
||||||
|
app_id: r.app_id,
|
||||||
|
expires_at: r.expires_at,
|
||||||
|
last_used_at: r.last_used_at,
|
||||||
|
created_at: r.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn mint_key(
|
||||||
|
State(state): State<ApiKeysState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Json(input): Json<MintApiKeyRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<MintApiKeyResponse>), ApiKeysError> {
|
||||||
|
validate_name(&input.name)?;
|
||||||
|
validate_scopes(&input.scopes, input.app_id)?;
|
||||||
|
|
||||||
|
let minted = generate_api_key().map_err(|e| ApiKeysError::Hash(e.to_string()))?;
|
||||||
|
let row = state
|
||||||
|
.keys
|
||||||
|
.create(NewApiKey {
|
||||||
|
user_id: principal.user_id,
|
||||||
|
hash: minted.hash,
|
||||||
|
prefix: minted.prefix,
|
||||||
|
name: input.name,
|
||||||
|
scopes: input.scopes,
|
||||||
|
app_id: input.app_id,
|
||||||
|
expires_at: input.expires_at,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(MintApiKeyResponse {
|
||||||
|
key: row.into(),
|
||||||
|
raw_token: minted.raw,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_keys(
|
||||||
|
State(state): State<ApiKeysState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
) -> Result<Json<Vec<ApiKeyDto>>, ApiKeysError> {
|
||||||
|
let rows = state.keys.list_for_user(principal.user_id).await?;
|
||||||
|
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_key(
|
||||||
|
State(state): State<ApiKeysState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id): Path<ApiKeyId>,
|
||||||
|
) -> Result<StatusCode, ApiKeysError> {
|
||||||
|
let deleted = state
|
||||||
|
.keys
|
||||||
|
.delete_by_id_and_user(id, principal.user_id)
|
||||||
|
.await?;
|
||||||
|
if !deleted {
|
||||||
|
// 404 covers both "doesn't exist" and "exists but not yours" —
|
||||||
|
// we deliberately don't leak the distinction.
|
||||||
|
return Err(ApiKeysError::NotFound(id));
|
||||||
|
}
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Validation
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn validate_name(s: &str) -> Result<(), ApiKeysError> {
|
||||||
|
let trimmed = s.trim();
|
||||||
|
if trimmed.len() < NAME_MIN || trimmed.len() > NAME_MAX {
|
||||||
|
return Err(ApiKeysError::InvalidName(format!(
|
||||||
|
"name must be {NAME_MIN}-{NAME_MAX} characters after trimming"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_scopes(scopes: &[Scope], app_id: Option<AppId>) -> Result<(), ApiKeysError> {
|
||||||
|
if scopes.is_empty() {
|
||||||
|
return Err(ApiKeysError::InvalidScopes(
|
||||||
|
"scopes must be non-empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Bound key + any instance:* scope → irreconcilable.
|
||||||
|
if app_id.is_some() && scopes.iter().any(|s| s.is_instance()) {
|
||||||
|
return Err(ApiKeysError::InvalidScopes(
|
||||||
|
"bound keys (app_id set) cannot carry instance:* scopes".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ApiKeysError {
|
||||||
|
#[error("api key not found: {0}")]
|
||||||
|
NotFound(ApiKeyId),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
InvalidName(String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
InvalidScopes(String),
|
||||||
|
|
||||||
|
#[error("failed to hash key: {0}")]
|
||||||
|
Hash(String),
|
||||||
|
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Repo(#[from] ApiKeyRepositoryError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiKeysError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, message) = match &self {
|
||||||
|
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||||
|
Self::InvalidName(_) | Self::InvalidScopes(_) => {
|
||||||
|
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||||
|
}
|
||||||
|
Self::Hash(_) => {
|
||||||
|
tracing::error!(error = %self, "api key hash failure");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Repo(ApiKeyRepositoryError::NotFound(_)) => {
|
||||||
|
(StatusCode::NOT_FOUND, self.to_string())
|
||||||
|
}
|
||||||
|
Self::Repo(ApiKeyRepositoryError::InvalidScope(_)) => {
|
||||||
|
tracing::error!(error = %self, "api key row carries an unknown scope");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Repo(ApiKeyRepositoryError::Db(e)) => {
|
||||||
|
tracing::error!(error = %e, "api_keys db error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(json!({ "error": message }))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
92
crates/manager-core/src/app_bootstrap.rs
Normal file
92
crates/manager-core/src/app_bootstrap.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
//! Hello-World seed for fresh installs.
|
||||||
|
//!
|
||||||
|
//! Idempotent. Runs after migrations and after admin bootstrap. Only
|
||||||
|
//! seeds when the default app is empty (no scripts, no routes); on
|
||||||
|
//! upgrades it does nothing so existing content isn't polluted.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use picloud_shared::{App, AppId, HostKind, PathKind};
|
||||||
|
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError};
|
||||||
|
use crate::route_repo::{NewRoute, RouteRepository};
|
||||||
|
|
||||||
|
const HELLO_RHAI_SOURCE: &str = include_str!("../seeds/hello.rhai");
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum HelloWorldOutcome {
|
||||||
|
/// Default app already has scripts (or doesn't exist) — left alone.
|
||||||
|
SkippedExisting,
|
||||||
|
/// Inserted the hello.rhai script and the `/hello` route.
|
||||||
|
Seeded,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SeedError {
|
||||||
|
#[error("default app not found — did the migration run?")]
|
||||||
|
MissingDefaultApp,
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Repo(#[from] ScriptRepositoryError),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn seed_hello_world_if_fresh(
|
||||||
|
apps: Arc<dyn AppRepository>,
|
||||||
|
scripts: Arc<dyn ScriptRepository>,
|
||||||
|
routes: Arc<dyn RouteRepository>,
|
||||||
|
) -> Result<HelloWorldOutcome, SeedError> {
|
||||||
|
let default = apps
|
||||||
|
.get_by_slug("default")
|
||||||
|
.await?
|
||||||
|
.ok_or(SeedError::MissingDefaultApp)?;
|
||||||
|
|
||||||
|
// Idempotence: only seed when both scripts AND routes are empty.
|
||||||
|
// (Either alone is suspicious enough to skip — the operator may have
|
||||||
|
// already started shaping the default app.)
|
||||||
|
let existing_scripts = scripts.list_for_app(default.id).await?;
|
||||||
|
let existing_routes = routes.list_for_app(default.id).await?;
|
||||||
|
if !existing_scripts.is_empty() || !existing_routes.is_empty() {
|
||||||
|
return Ok(HelloWorldOutcome::SkippedExisting);
|
||||||
|
}
|
||||||
|
|
||||||
|
seed_into(&*scripts, &*routes, &default).await?;
|
||||||
|
Ok(HelloWorldOutcome::Seeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_into(
|
||||||
|
scripts: &dyn ScriptRepository,
|
||||||
|
routes: &dyn RouteRepository,
|
||||||
|
default: &App,
|
||||||
|
) -> Result<(), ScriptRepositoryError> {
|
||||||
|
let script = scripts
|
||||||
|
.create(NewScript {
|
||||||
|
app_id: default.id,
|
||||||
|
name: "hello".to_string(),
|
||||||
|
description: Some("Reference example: returns a greeting at GET /hello.".to_string()),
|
||||||
|
source: HELLO_RHAI_SOURCE.to_string(),
|
||||||
|
timeout_seconds: Some(5),
|
||||||
|
memory_limit_mb: None,
|
||||||
|
sandbox: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
routes
|
||||||
|
.create(NewRoute {
|
||||||
|
app_id: default.id,
|
||||||
|
script_id: script.id,
|
||||||
|
host_kind: HostKind::Any,
|
||||||
|
host: String::new(),
|
||||||
|
host_param_name: None,
|
||||||
|
path_kind: PathKind::Exact,
|
||||||
|
path: "/hello".to_string(),
|
||||||
|
// Accept any method so both `curl /hello` and
|
||||||
|
// `curl -d '{"name":"X"}' /hello` work out of the box.
|
||||||
|
method: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn _typecheck(_id: AppId) {} // suppress unused-import warnings if reshuffled
|
||||||
152
crates/manager-core/src/app_domain_repo.rs
Normal file
152
crates/manager-core/src/app_domain_repo.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
//! CRUD over the `app_domains` table.
|
||||||
|
//!
|
||||||
|
//! Parsing + shape_key derivation live in `orchestrator-core`'s
|
||||||
|
//! `routing::pattern::parse_app_domain` — this repo just stores what
|
||||||
|
//! the API handler hands it. Same-shape collisions surface as a unique
|
||||||
|
//! constraint violation on `shape_key`, mapped here to a clean error.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_shared::{AppDomain, AppId, DomainShape};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::repo::ScriptRepositoryError;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NewAppDomain {
|
||||||
|
pub app_id: AppId,
|
||||||
|
pub pattern: String,
|
||||||
|
pub shape: DomainShape,
|
||||||
|
pub shape_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AppDomainRepository: Send + Sync {
|
||||||
|
/// All domain claims across all apps — used by the orchestrator's
|
||||||
|
/// `AppDomainTable` to build its lookup cache at startup and after
|
||||||
|
/// every write.
|
||||||
|
async fn list_all(&self) -> Result<Vec<AppDomain>, ScriptRepositoryError>;
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<AppDomain>, ScriptRepositoryError>;
|
||||||
|
async fn get(&self, domain_id: Uuid) -> Result<Option<AppDomain>, ScriptRepositoryError>;
|
||||||
|
async fn create(&self, input: NewAppDomain) -> Result<AppDomain, ScriptRepositoryError>;
|
||||||
|
async fn delete(&self, domain_id: Uuid) -> Result<(), ScriptRepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresAppDomainRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresAppDomainRepository {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AppDomainRepository for PostgresAppDomainRepository {
|
||||||
|
async fn list_all(&self) -> Result<Vec<AppDomain>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, DomainRow>(
|
||||||
|
"SELECT id, app_id, pattern, shape, shape_key, created_at \
|
||||||
|
FROM app_domains ORDER BY pattern",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<AppDomain>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, DomainRow>(
|
||||||
|
"SELECT id, app_id, pattern, shape, shape_key, created_at \
|
||||||
|
FROM app_domains WHERE app_id = $1 ORDER BY pattern",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, domain_id: Uuid) -> Result<Option<AppDomain>, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, DomainRow>(
|
||||||
|
"SELECT id, app_id, pattern, shape, shape_key, created_at \
|
||||||
|
FROM app_domains WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(domain_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(&self, input: NewAppDomain) -> Result<AppDomain, ScriptRepositoryError> {
|
||||||
|
let res = sqlx::query_as::<_, DomainRow>(
|
||||||
|
"INSERT INTO app_domains (app_id, pattern, shape, shape_key) \
|
||||||
|
VALUES ($1, $2, $3, $4) \
|
||||||
|
RETURNING id, app_id, pattern, shape, shape_key, created_at",
|
||||||
|
)
|
||||||
|
.bind(input.app_id.into_inner())
|
||||||
|
.bind(&input.pattern)
|
||||||
|
.bind(shape_str(input.shape))
|
||||||
|
.bind(&input.shape_key)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(row) => Ok(row.into()),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"domain {:?} (or another claim of the same shape) is already claimed",
|
||||||
|
input.pattern
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, domain_id: Uuid) -> Result<(), ScriptRepositoryError> {
|
||||||
|
let res = sqlx::query("DELETE FROM app_domains WHERE id = $1")
|
||||||
|
.bind(domain_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"domain {domain_id} not found"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn shape_str(s: DomainShape) -> &'static str {
|
||||||
|
match s {
|
||||||
|
DomainShape::Exact => "exact",
|
||||||
|
DomainShape::Wildcard => "wildcard",
|
||||||
|
DomainShape::Parameterized => "parameterized",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct DomainRow {
|
||||||
|
id: Uuid,
|
||||||
|
app_id: Uuid,
|
||||||
|
pattern: String,
|
||||||
|
shape: String,
|
||||||
|
shape_key: String,
|
||||||
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DomainRow> for AppDomain {
|
||||||
|
fn from(r: DomainRow) -> Self {
|
||||||
|
Self {
|
||||||
|
id: r.id,
|
||||||
|
app_id: r.app_id.into(),
|
||||||
|
pattern: r.pattern,
|
||||||
|
shape: match r.shape.as_str() {
|
||||||
|
"wildcard" => DomainShape::Wildcard,
|
||||||
|
"parameterized" => DomainShape::Parameterized,
|
||||||
|
_ => DomainShape::Exact,
|
||||||
|
},
|
||||||
|
shape_key: r.shape_key,
|
||||||
|
created_at: r.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
331
crates/manager-core/src/app_members_api.rs
Normal file
331
crates/manager-core/src/app_members_api.rs
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
//! `/api/v1/admin/apps/{id_or_slug}/members/*` — CRUD over the
|
||||||
|
//! `app_members` table (blueprint §11.6).
|
||||||
|
//!
|
||||||
|
//! Every endpoint is gated on `Capability::AppAdmin(app_id)` after
|
||||||
|
//! resolving the app from `id_or_slug`. Editors and viewers receive
|
||||||
|
//! 403 from list and never see the dashboard's Members tab.
|
||||||
|
//!
|
||||||
|
//! POST is **non-idempotent on purpose**: a duplicate `(app_id,
|
||||||
|
//! user_id)` returns 409 rather than upsert-200, so the UI can show
|
||||||
|
//! "already a member — promote / demote them instead" cleanly. Role
|
||||||
|
//! changes go through PATCH.
|
||||||
|
//!
|
||||||
|
//! No last-app-admin guard: owners always implicitly satisfy
|
||||||
|
//! `Capability::AppAdmin(_)` (authz::role_grants), so removing the
|
||||||
|
//! final explicit `app_admin` membership cannot orphan an app.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::{get, patch};
|
||||||
|
use axum::{Extension, Router};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::{AdminUserId, AppRole, InstanceRole, Principal};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
|
||||||
|
use crate::app_members_repo::{
|
||||||
|
AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow,
|
||||||
|
};
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
|
use crate::repo::ScriptRepositoryError;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppMembersState {
|
||||||
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
pub users: Arc<dyn AdminUserRepository>,
|
||||||
|
pub members: Arc<dyn AppMembersRepository>,
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn app_members_router(state: AppMembersState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}/members",
|
||||||
|
get(list_members).post(grant_member),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}/members/{user_id}",
|
||||||
|
patch(patch_member).delete(remove_member),
|
||||||
|
)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// DTOs
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AppMemberDto {
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub role: AppRole,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppMembershipDetail> for AppMemberDto {
|
||||||
|
fn from(d: AppMembershipDetail) -> Self {
|
||||||
|
Self {
|
||||||
|
user_id: d.user_id,
|
||||||
|
username: d.username,
|
||||||
|
email: d.email,
|
||||||
|
instance_role: d.instance_role,
|
||||||
|
is_active: d.is_active,
|
||||||
|
role: d.role,
|
||||||
|
created_at: d.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compose a DTO from an `AdminUserRow` (fetched for validation) and
|
||||||
|
/// the `AppMembershipRow` returned by `upsert`. Saves a re-fetch on
|
||||||
|
/// POST/PATCH at the cost of trusting the two inputs reference the
|
||||||
|
/// same user_id — caller's responsibility.
|
||||||
|
fn compose_dto(user: AdminUserRow, membership: AppMembershipRow) -> AppMemberDto {
|
||||||
|
AppMemberDto {
|
||||||
|
user_id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
instance_role: user.instance_role,
|
||||||
|
is_active: user.is_active,
|
||||||
|
role: membership.role,
|
||||||
|
created_at: membership.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct GrantMemberRequest {
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub role: AppRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PatchMemberRequest {
|
||||||
|
pub role: AppRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_members(
|
||||||
|
State(s): State<AppMembersState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<AppMemberDto>>, AppMembersApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
let rows = s.members.list_for_app_enriched(app.id).await?;
|
||||||
|
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn grant_member(
|
||||||
|
State(s): State<AppMembersState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<GrantMemberRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<AppMemberDto>), AppMembersApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
|
||||||
|
let user = s
|
||||||
|
.users
|
||||||
|
.get(input.user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppMembersApiError::UserNotFound(input.user_id))?;
|
||||||
|
validate_grant_target(&user)?;
|
||||||
|
|
||||||
|
// Atomic insert — if a row already exists, returns None and we 409.
|
||||||
|
// Avoids the find-then-upsert race where two concurrent POSTs would
|
||||||
|
// both pass the existence check and the second `upsert` would
|
||||||
|
// silently rewrite the role.
|
||||||
|
let row = s
|
||||||
|
.members
|
||||||
|
.try_insert(app.id, user.id, input.role)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppMembersApiError::AlreadyMember {
|
||||||
|
username: user.username.clone(),
|
||||||
|
})?;
|
||||||
|
Ok((StatusCode::CREATED, Json(compose_dto(user, row))))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_member(
|
||||||
|
State(s): State<AppMembersState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
||||||
|
Json(input): Json<PatchMemberRequest>,
|
||||||
|
) -> Result<Json<AppMemberDto>, AppMembersApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
|
||||||
|
let user_id = AdminUserId::from(user_id);
|
||||||
|
let user = s
|
||||||
|
.users
|
||||||
|
.get(user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppMembersApiError::UserNotFound(user_id))?;
|
||||||
|
|
||||||
|
// Atomic update — returns None if no row exists, so 404 is decided
|
||||||
|
// by the same statement that does the write. Eliminates the
|
||||||
|
// find-then-upsert race where a concurrent DELETE between the two
|
||||||
|
// calls would let PATCH silently re-create the row.
|
||||||
|
let row = s
|
||||||
|
.members
|
||||||
|
.update_role(app.id, user_id, input.role)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppMembersApiError::MembershipNotFound)?;
|
||||||
|
Ok(Json(compose_dto(user, row)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_member(
|
||||||
|
State(s): State<AppMembersState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
||||||
|
) -> Result<StatusCode, AppMembersApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
s.members.remove(app.id, AdminUserId::from(user_id)).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Validation + helpers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn validate_grant_target(user: &AdminUserRow) -> Result<(), AppMembersApiError> {
|
||||||
|
if !user.is_active {
|
||||||
|
return Err(AppMembersApiError::TargetInactive {
|
||||||
|
username: user.username.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if user.instance_role != InstanceRole::Member {
|
||||||
|
return Err(AppMembersApiError::TargetNotMember {
|
||||||
|
username: user.username.clone(),
|
||||||
|
instance_role: user.instance_role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_app(
|
||||||
|
apps: &dyn AppRepository,
|
||||||
|
ident: &str,
|
||||||
|
) -> Result<picloud_shared::App, AppMembersApiError> {
|
||||||
|
crate::app_repo::resolve_app(apps, ident)
|
||||||
|
.await?
|
||||||
|
.map(|l| l.app)
|
||||||
|
.ok_or_else(|| AppMembersApiError::AppNotFound(ident.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppMembersApiError {
|
||||||
|
#[error("app not found: {0}")]
|
||||||
|
AppNotFound(String),
|
||||||
|
|
||||||
|
#[error("user not found: {0}")]
|
||||||
|
UserNotFound(AdminUserId),
|
||||||
|
|
||||||
|
#[error("no membership exists for this user on this app")]
|
||||||
|
MembershipNotFound,
|
||||||
|
|
||||||
|
#[error("{username} is already a member of this app — use PATCH to change their role")]
|
||||||
|
AlreadyMember { username: String },
|
||||||
|
|
||||||
|
#[error("{username} is deactivated and cannot be added as a member")]
|
||||||
|
TargetInactive { username: String },
|
||||||
|
|
||||||
|
#[error(
|
||||||
|
"{username} has instance_role {instance_role:?} and already has implicit access \
|
||||||
|
on every app — no explicit membership needed"
|
||||||
|
)]
|
||||||
|
TargetNotMember {
|
||||||
|
username: String,
|
||||||
|
instance_role: InstanceRole,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Members(#[from] AppMembersRepositoryError),
|
||||||
|
|
||||||
|
#[error("user repository error: {0}")]
|
||||||
|
Users(#[from] AdminUserRepositoryError),
|
||||||
|
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Apps(#[from] ScriptRepositoryError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for AppMembersApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppMembersApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, body) = match &self {
|
||||||
|
Self::AppNotFound(_)
|
||||||
|
| Self::UserNotFound(_)
|
||||||
|
| Self::MembershipNotFound
|
||||||
|
| Self::Apps(ScriptRepositoryError::NotFound(_)) => {
|
||||||
|
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
|
Self::AlreadyMember { .. } | Self::Apps(ScriptRepositoryError::Conflict(_)) => {
|
||||||
|
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
|
Self::TargetInactive { .. } | Self::TargetNotMember { .. } => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
json!({ "error": self.to_string() }),
|
||||||
|
),
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "app members authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Members(e) => {
|
||||||
|
tracing::error!(error = %e, "app members repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Users(e) => {
|
||||||
|
tracing::error!(error = %e, "admin users repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Apps(ScriptRepositoryError::Db(e)) => {
|
||||||
|
tracing::error!(error = %e, "apps repo error in app_members");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
340
crates/manager-core/src/app_members_repo.rs
Normal file
340
crates/manager-core/src/app_members_repo.rs
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
//! CRUD over the `app_members` table — explicit per-(user, app) role
|
||||||
|
//! grants for `member` instance-role users. Owners and admins do NOT
|
||||||
|
//! appear here; their app authority is implicit (see authz.rs).
|
||||||
|
//!
|
||||||
|
//! Doubles as the production `AuthzRepo` implementation: the
|
||||||
|
//! membership lookup `can()` needs is the same single-row SELECT as
|
||||||
|
//! `find` here.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::authz::{AuthzError, AuthzRepo};
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppMembersRepositoryError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
|
||||||
|
#[error("membership row not found: app={app_id}, user={user_id}")]
|
||||||
|
NotFound { app_id: AppId, user_id: AdminUserId },
|
||||||
|
|
||||||
|
#[error("invalid app_role stored in DB: {0}")]
|
||||||
|
InvalidRole(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of `app_members`. Returned by `list_for_user` / `list_for_app`
|
||||||
|
/// so handlers can render the cross-reference without joining to apps
|
||||||
|
/// or admin_users themselves.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppMembershipRow {
|
||||||
|
pub app_id: AppId,
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub role: AppRole,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `app_members` row joined with `admin_users` so the dashboard's
|
||||||
|
/// Members tab can render usernames / emails / status without an N+1
|
||||||
|
/// fetch per row. Drives `GET /apps/{id}/members`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppMembershipDetail {
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub role: AppRole,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AppMembersRepository: Send + Sync {
|
||||||
|
/// Single (user, app) lookup. Returns `None` for non-members and
|
||||||
|
/// for unrelated apps. This is the hot path for `authz::can`.
|
||||||
|
async fn find(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Upsert a membership. Used both for first-time grants and role
|
||||||
|
/// promotions/demotions on an existing row.
|
||||||
|
async fn upsert(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<AppMembershipRow, AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Atomic insert. Returns `Some(row)` on success, `None` if a
|
||||||
|
/// membership already exists. Lets the HTTP handler return 409
|
||||||
|
/// without a separate `find` round-trip (no TOCTOU between check
|
||||||
|
/// and insert).
|
||||||
|
async fn try_insert(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Atomic role update. Returns `Some(row)` on success, `None` if no
|
||||||
|
/// membership row exists. Lets PATCH return 404 without a separate
|
||||||
|
/// `find` round-trip (no TOCTOU between check and update).
|
||||||
|
async fn update_role(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Remove a membership. No-op (Ok) when the row doesn't exist —
|
||||||
|
/// the user wasn't a member, which is the desired post-condition.
|
||||||
|
async fn remove(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<(), AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Every membership the user holds. Drives the membership-filtered
|
||||||
|
/// list endpoints (`GET /admin/apps`, `GET /admin/scripts` for
|
||||||
|
/// `member` callers).
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Every membership on a given app. Used by `GET
|
||||||
|
/// /admin/apps/{id}/members` once that surface lands; included now
|
||||||
|
/// so the trait is complete enough for tests.
|
||||||
|
async fn list_for_app(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Like `list_for_app` but joined with `admin_users` so the
|
||||||
|
/// dashboard can render member rows in one round-trip. Ordered by
|
||||||
|
/// username for a stable list.
|
||||||
|
async fn list_for_app_enriched(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresAppMembersRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresAppMembersRepository {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AppMembersRepository for PostgresAppMembersRepository {
|
||||||
|
async fn find(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AppMembersRepositoryError> {
|
||||||
|
let row: Option<(String,)> =
|
||||||
|
sqlx::query_as("SELECT role FROM app_members WHERE user_id = $1 AND app_id = $2")
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(|(role,)| {
|
||||||
|
AppRole::from_db_str(&role).ok_or(AppMembersRepositoryError::InvalidRole(role))
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<AppMembershipRow, AppMembersRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppMembershipRecord>(
|
||||||
|
"INSERT INTO app_members (app_id, user_id, role) \
|
||||||
|
VALUES ($1, $2, $3) \
|
||||||
|
ON CONFLICT (app_id, user_id) DO UPDATE SET role = EXCLUDED.role \
|
||||||
|
RETURNING app_id, user_id, role, created_at",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.bind(role.as_str())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.try_into()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<(), AppMembersRepositoryError> {
|
||||||
|
sqlx::query("DELETE FROM app_members WHERE app_id = $1 AND user_id = $2")
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn try_insert(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppMembershipRecord>(
|
||||||
|
"INSERT INTO app_members (app_id, user_id, role) \
|
||||||
|
VALUES ($1, $2, $3) \
|
||||||
|
ON CONFLICT (app_id, user_id) DO NOTHING \
|
||||||
|
RETURNING app_id, user_id, role, created_at",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.bind(role.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_role(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppMembershipRecord>(
|
||||||
|
"UPDATE app_members SET role = $1 \
|
||||||
|
WHERE app_id = $2 AND user_id = $3 \
|
||||||
|
RETURNING app_id, user_id, role, created_at",
|
||||||
|
)
|
||||||
|
.bind(role.as_str())
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AppMembershipRecord>(
|
||||||
|
"SELECT app_id, user_id, role, created_at \
|
||||||
|
FROM app_members WHERE user_id = $1 \
|
||||||
|
ORDER BY created_at",
|
||||||
|
)
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_app(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AppMembershipRecord>(
|
||||||
|
"SELECT app_id, user_id, role, created_at \
|
||||||
|
FROM app_members WHERE app_id = $1 \
|
||||||
|
ORDER BY created_at",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_app_enriched(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AppMembershipDetailRecord>(
|
||||||
|
"SELECT au.id, au.username, au.email, au.instance_role, au.is_active, \
|
||||||
|
am.role, am.created_at \
|
||||||
|
FROM app_members am \
|
||||||
|
JOIN admin_users au ON au.id = am.user_id \
|
||||||
|
WHERE am.app_id = $1 \
|
||||||
|
ORDER BY au.username",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forwarding impl so the Postgres repo satisfies `AuthzRepo` directly
|
||||||
|
/// — handlers store a single `Arc<dyn AppMembersRepository>` and pass
|
||||||
|
/// it to `authz::can` without casting.
|
||||||
|
#[async_trait]
|
||||||
|
impl AuthzRepo for PostgresAppMembersRepository {
|
||||||
|
async fn membership(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
self.find(user_id, app_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthzError::Repo(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AppMembershipRecord {
|
||||||
|
app_id: uuid::Uuid,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
role: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<AppMembershipRecord> for AppMembershipRow {
|
||||||
|
type Error = AppMembersRepositoryError;
|
||||||
|
fn try_from(r: AppMembershipRecord) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
app_id: r.app_id.into(),
|
||||||
|
user_id: r.user_id.into(),
|
||||||
|
role: AppRole::from_db_str(&r.role)
|
||||||
|
.ok_or(AppMembersRepositoryError::InvalidRole(r.role))?,
|
||||||
|
created_at: r.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AppMembershipDetailRecord {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
username: String,
|
||||||
|
email: Option<String>,
|
||||||
|
instance_role: String,
|
||||||
|
is_active: bool,
|
||||||
|
role: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<AppMembershipDetailRecord> for AppMembershipDetail {
|
||||||
|
type Error = AppMembersRepositoryError;
|
||||||
|
fn try_from(r: AppMembershipDetailRecord) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
user_id: r.id.into(),
|
||||||
|
username: r.username,
|
||||||
|
email: r.email,
|
||||||
|
instance_role: InstanceRole::from_db_str(&r.instance_role)
|
||||||
|
.ok_or(AppMembersRepositoryError::InvalidRole(r.instance_role))?,
|
||||||
|
is_active: r.is_active,
|
||||||
|
role: AppRole::from_db_str(&r.role)
|
||||||
|
.ok_or(AppMembersRepositoryError::InvalidRole(r.role))?,
|
||||||
|
created_at: r.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
450
crates/manager-core/src/app_repo.rs
Normal file
450
crates/manager-core/src/app_repo.rs
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
//! CRUD over the `apps` and `app_slug_history` tables.
|
||||||
|
//!
|
||||||
|
//! Slug validation (regex, reserved-word check) lives in the API
|
||||||
|
//! handler; this repo enforces only what Postgres enforces (uniqueness,
|
||||||
|
//! FK). The slug-rename flow is exposed as a single `rename_slug` call
|
||||||
|
//! that writes the history row in the same transaction.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_shared::{AdminUserId, App, AppId};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::repo::ScriptRepositoryError;
|
||||||
|
|
||||||
|
/// Result of looking up an app by slug or via the redirect history.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppLookup {
|
||||||
|
pub app: App,
|
||||||
|
/// `true` when the slug was found in `app_slug_history` rather than
|
||||||
|
/// directly on `apps`. Dashboards should issue a redirect.
|
||||||
|
pub redirected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a free-form path param (UUID *or* slug *or* historical slug)
|
||||||
|
/// to an `AppLookup`. UUID lookups never set `redirected`; slug lookups
|
||||||
|
/// fall through to `app_slug_history` and set `redirected: true` when
|
||||||
|
/// they hit it.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(None)` when nothing matches — callers map that to their
|
||||||
|
/// own not-found error variant.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Propagates any underlying repository error.
|
||||||
|
pub async fn resolve_app(
|
||||||
|
apps: &dyn AppRepository,
|
||||||
|
ident: &str,
|
||||||
|
) -> Result<Option<AppLookup>, ScriptRepositoryError> {
|
||||||
|
if let Ok(uuid) = ident.parse::<Uuid>() {
|
||||||
|
return Ok(apps
|
||||||
|
.get_by_id(AppId::from(uuid))
|
||||||
|
.await?
|
||||||
|
.map(|app| AppLookup {
|
||||||
|
app,
|
||||||
|
redirected: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
apps.get_by_slug_or_history(ident).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AppRepository: Send + Sync {
|
||||||
|
/// Every app on the instance. For owner/admin callers — `member`
|
||||||
|
/// users go through `list_for_user`.
|
||||||
|
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||||
|
/// Only apps the user has an `app_members` row for. Drives the
|
||||||
|
/// membership-filtered `GET /admin/apps` for `member` callers.
|
||||||
|
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||||
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
|
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
|
async fn get_by_slug_or_history(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<Option<AppLookup>, ScriptRepositoryError>;
|
||||||
|
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
|
/// Create that also consumes a matching `app_slug_history` row, if
|
||||||
|
/// any. Used after the operator has confirmed they want to break old
|
||||||
|
/// redirects.
|
||||||
|
async fn create_with_takeover(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
id: AppId,
|
||||||
|
name: Option<&str>,
|
||||||
|
description: Option<Option<&str>>,
|
||||||
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
|
/// Rename and record the old slug in `app_slug_history` (so
|
||||||
|
/// retired URLs keep redirecting). If `take_over_history` is true,
|
||||||
|
/// any existing history row for `new_slug` is consumed.
|
||||||
|
async fn rename_slug(
|
||||||
|
&self,
|
||||||
|
id: AppId,
|
||||||
|
new_slug: &str,
|
||||||
|
take_over_history: bool,
|
||||||
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
|
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
|
||||||
|
/// Delete the app along with all its scripts (which in turn cascades
|
||||||
|
/// routes and execution logs via their `script_id` FK). Domains and
|
||||||
|
/// app-slug-history rows cascade off the app row itself. Runs in a
|
||||||
|
/// single transaction so a partial delete cannot be observed.
|
||||||
|
async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
|
||||||
|
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresAppRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresAppRepository {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AppRepository for PostgresAppRepository {
|
||||||
|
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT id, slug, name, description, created_at, updated_at \
|
||||||
|
FROM apps ORDER BY name",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||||
|
FROM apps a \
|
||||||
|
JOIN app_members m ON m.app_id = a.id \
|
||||||
|
WHERE m.user_id = $1 \
|
||||||
|
ORDER BY a.name",
|
||||||
|
)
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT id, slug, name, description, created_at, updated_at \
|
||||||
|
FROM apps WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT id, slug, name, description, created_at, updated_at \
|
||||||
|
FROM apps WHERE slug = $1",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_slug_or_history(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<Option<AppLookup>, ScriptRepositoryError> {
|
||||||
|
if let Some(app) = self.get_by_slug(slug).await? {
|
||||||
|
return Ok(Some(AppLookup {
|
||||||
|
app,
|
||||||
|
redirected: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if let Some(app) = self.slug_in_history(slug).await? {
|
||||||
|
return Ok(Some(AppLookup {
|
||||||
|
app,
|
||||||
|
redirected: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||||
|
FROM app_slug_history h \
|
||||||
|
JOIN apps a ON a.id = h.current_app_id \
|
||||||
|
WHERE h.slug = $1",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
let res = sqlx::query_as::<_, AppRow>(
|
||||||
|
"INSERT INTO apps (slug, name, description) \
|
||||||
|
VALUES ($1, $2, $3) \
|
||||||
|
RETURNING id, slug, name, description, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.bind(name)
|
||||||
|
.bind(description)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(row) => Ok(row.into()),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||||
|
ScriptRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
|
||||||
|
),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_with_takeover(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||||
|
.bind(slug)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"INSERT INTO apps (slug, name, description) \
|
||||||
|
VALUES ($1, $2, $3) \
|
||||||
|
RETURNING id, slug, name, description, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.bind(name)
|
||||||
|
.bind(description)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await;
|
||||||
|
let row = match row {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"slug {slug:?} is already in use"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(row.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
id: AppId,
|
||||||
|
name: Option<&str>,
|
||||||
|
description: Option<Option<&str>>,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"UPDATE apps SET \
|
||||||
|
name = COALESCE($2, name), \
|
||||||
|
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||||
|
updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, slug, name, description, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(name)
|
||||||
|
.bind(description.is_some())
|
||||||
|
.bind(description.and_then(|d| d))
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(Into::into)
|
||||||
|
.ok_or_else(|| ScriptRepositoryError::Conflict(format!("app {id} not found")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rename_slug(
|
||||||
|
&self,
|
||||||
|
id: AppId,
|
||||||
|
new_slug: &str,
|
||||||
|
take_over_history: bool,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
|
||||||
|
// 1. Read the current slug (so we can record it in history).
|
||||||
|
let current: Option<(String,)> = sqlx::query_as("SELECT slug FROM apps WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let Some((current_slug,)) = current else {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"app {id} not found"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
if current_slug == new_slug {
|
||||||
|
// No-op rename; just return the row.
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT id, slug, name, description, created_at, updated_at \
|
||||||
|
FROM apps WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
return Ok(row.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If renaming back to this app's own retired slug, just
|
||||||
|
// consume the history row silently (no warning, no takeover
|
||||||
|
// flag required).
|
||||||
|
let owns_history: Option<(uuid::Uuid,)> =
|
||||||
|
sqlx::query_as("SELECT current_app_id FROM app_slug_history WHERE slug = $1")
|
||||||
|
.bind(new_slug)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match owns_history {
|
||||||
|
Some((owner,)) if owner == id.into_inner() => {
|
||||||
|
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||||
|
.bind(new_slug)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Some(_) if take_over_history => {
|
||||||
|
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||||
|
.bind(new_slug)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"slug {new_slug:?} is in history; rename with takeover to claim it"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Record the current slug in history (replacing any older
|
||||||
|
// entry — the same slug can pass through history multiple
|
||||||
|
// times across many renames).
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO app_slug_history (slug, current_app_id) \
|
||||||
|
VALUES ($1, $2) \
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET current_app_id = EXCLUDED.current_app_id, \
|
||||||
|
retired_at = NOW()",
|
||||||
|
)
|
||||||
|
.bind(¤t_slug)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 4. Apply the rename. Unique violation = another live app
|
||||||
|
// already holds this slug.
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"UPDATE apps SET slug = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, slug, name, description, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(new_slug)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await;
|
||||||
|
let row = match row {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"slug {new_slug:?} is already in use by another app"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(row.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
|
||||||
|
let res = sqlx::query("DELETE FROM apps WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await;
|
||||||
|
match res {
|
||||||
|
Ok(r) if r.rows_affected() == 0 => Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"app {id} not found"
|
||||||
|
))),
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||||
|
// ON DELETE RESTRICT on scripts.app_id — surface a clean
|
||||||
|
// "has dependents" error rather than a raw SQL message.
|
||||||
|
Err(ScriptRepositoryError::Conflict(
|
||||||
|
"app still contains scripts; delete or move them first".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
sqlx::query("DELETE FROM scripts WHERE app_id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let res = sqlx::query("DELETE FROM apps WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"app {id} not found"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError> {
|
||||||
|
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scripts WHERE app_id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AppRow {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
slug: String,
|
||||||
|
name: String,
|
||||||
|
description: Option<String>,
|
||||||
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppRow> for App {
|
||||||
|
fn from(r: AppRow) -> Self {
|
||||||
|
Self {
|
||||||
|
id: r.id.into(),
|
||||||
|
slug: r.slug,
|
||||||
|
name: r.name,
|
||||||
|
description: r.description,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
619
crates/manager-core/src/apps_api.rs
Normal file
619
crates/manager-core/src/apps_api.rs
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
//! `/api/v1/admin/apps/*` — app + domain claim CRUD.
|
||||||
|
//!
|
||||||
|
//! All endpoints are guarded by `require_admin`. Per-app permissions
|
||||||
|
//! are deferred (every authenticated admin can act on every app); the
|
||||||
|
//! middleware seam exists for when that lands.
|
||||||
|
//!
|
||||||
|
//! Slug validation: regex `^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
|
||||||
|
//! list rejected. Slug renames record the old slug in
|
||||||
|
//! `app_slug_history` for permanent 301 redirects; reclaiming a
|
||||||
|
//! historical slug requires `"force_takeover": true` in the request.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::{delete, get, post};
|
||||||
|
use axum::{Extension, Router};
|
||||||
|
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
||||||
|
use picloud_shared::{App, AppDomain, AppId, AppRole, InstanceRole, Principal};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||||
|
use crate::repo::ScriptRepositoryError;
|
||||||
|
use crate::route_repo::RouteRepository;
|
||||||
|
|
||||||
|
const SLUG_MIN: usize = 1;
|
||||||
|
const SLUG_MAX: usize = 63;
|
||||||
|
const RESERVED_SLUGS: &[&str] = &[
|
||||||
|
"new", "api", "admin", "admins", "healthz", "version", "login", "logout", "apps",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppsState {
|
||||||
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
pub domains: Arc<dyn AppDomainRepository>,
|
||||||
|
pub routes: Arc<dyn RouteRepository>,
|
||||||
|
/// Cached host → app_id lookup; replaced after every domain CRUD
|
||||||
|
/// operation so the orchestrator sees changes immediately.
|
||||||
|
pub domain_table: Arc<AppDomainTable>,
|
||||||
|
/// Capability gate — Phase 3.5.
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apps_router(state: AppsState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/apps", get(list_apps).post(create_app))
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}",
|
||||||
|
get(get_app).patch(patch_app).delete(delete_app),
|
||||||
|
)
|
||||||
|
.route("/apps/{id_or_slug}/slug:check", post(slug_check))
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}/domains",
|
||||||
|
get(list_domains).post(create_domain),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}/domains/{domain_id}",
|
||||||
|
delete(delete_domain),
|
||||||
|
)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// DTOs
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AppDto {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub app: App,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateAppRequest {
|
||||||
|
pub slug: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Set to `true` to consume an existing `app_slug_history` row for
|
||||||
|
/// the requested slug (breaking old redirects).
|
||||||
|
#[serde(default)]
|
||||||
|
pub force_takeover: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PatchAppRequest {
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_optional")]
|
||||||
|
#[allow(clippy::option_option)]
|
||||||
|
pub description: Option<Option<String>>,
|
||||||
|
pub slug: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub force_takeover: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::option_option)]
|
||||||
|
fn deserialize_optional_optional<'de, D>(d: D) -> Result<Option<Option<String>>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Option::<String>::deserialize(d).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SlugCheckRequest {
|
||||||
|
pub new_slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SlugCheckResponse {
|
||||||
|
pub ok: bool,
|
||||||
|
pub conflict_kind: Option<&'static str>,
|
||||||
|
pub current_app: Option<App>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateDomainRequest {
|
||||||
|
pub pattern: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query params for `DELETE /apps/{id_or_slug}`. `force=true` opts into
|
||||||
|
/// a cascading delete that also removes every script in the app (and
|
||||||
|
/// thereby their routes and execution logs). Without it the request is
|
||||||
|
/// rejected when the app still contains scripts.
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
pub struct DeleteAppQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
pub force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AppLookupResponse {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub app: App,
|
||||||
|
/// When the operator hits the API with a retired slug, this points
|
||||||
|
/// at the live slug so dashboards can redirect.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub redirect_to: Option<String>,
|
||||||
|
/// The caller's role on this app, used by the dashboard to decide
|
||||||
|
/// whether to render admin-only surfaces (Members tab, settings).
|
||||||
|
/// `Owner` and `Admin` both map to `app_admin` (implicit per
|
||||||
|
/// blueprint §11.6); `Member` carries its explicit
|
||||||
|
/// `app_members.role`.
|
||||||
|
pub my_role: Option<AppRole>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_apps(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
) -> Result<Json<Vec<App>>, AppsApiError> {
|
||||||
|
// Member callers see only apps they're a member of; owner/admin
|
||||||
|
// see everything. Filter at the SQL layer (not just in the
|
||||||
|
// dashboard) — that's the strict-isolation guarantee from §11.6.
|
||||||
|
let apps = if principal.instance_role == InstanceRole::Member {
|
||||||
|
s.apps.list_for_user(principal.user_id).await?
|
||||||
|
} else {
|
||||||
|
s.apps.list().await?
|
||||||
|
};
|
||||||
|
Ok(Json(apps))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_app(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Json(input): Json<CreateAppRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<App>), AppsApiError> {
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
|
||||||
|
validate_slug(&input.slug)?;
|
||||||
|
|
||||||
|
// Historical-slug check before insert: if the slug is in history
|
||||||
|
// and the caller hasn't asked to force takeover, surface a clean
|
||||||
|
// 409 so the dashboard can present a "this will break old links"
|
||||||
|
// confirmation.
|
||||||
|
if !input.force_takeover {
|
||||||
|
if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
|
||||||
|
return Err(AppsApiError::SlugInHistory(current));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = if input.force_takeover {
|
||||||
|
s.apps
|
||||||
|
.create_with_takeover(&input.slug, &input.name, input.description.as_deref())
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
s.apps
|
||||||
|
.create(&input.slug, &input.name, input.description.as_deref())
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_app(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<AppLookupResponse>, AppsApiError> {
|
||||||
|
let lookup = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppRead(lookup.app.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let redirect_to = if lookup.redirected {
|
||||||
|
Some(lookup.app.slug.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let my_role = compute_my_role(s.authz.as_ref(), &principal, lookup.app.id).await?;
|
||||||
|
Ok(Json(AppLookupResponse {
|
||||||
|
app: lookup.app,
|
||||||
|
redirect_to,
|
||||||
|
my_role,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the caller's effective `AppRole` on a specific app. Mirrors
|
||||||
|
/// the implicit-grant logic in `authz::role_grants` but returns the
|
||||||
|
/// role itself (for UI gating) rather than a yes/no decision. `Owner`
|
||||||
|
/// 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(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<PatchAppRequest>,
|
||||||
|
) -> Result<Json<App>, AppsApiError> {
|
||||||
|
let current = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppAdmin(current.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Edits to name/description go first (separate from rename so we
|
||||||
|
// don't conflate the two errors).
|
||||||
|
let after_meta = if input.name.is_some() || input.description.is_some() {
|
||||||
|
s.apps
|
||||||
|
.update(
|
||||||
|
current.id,
|
||||||
|
input.name.as_deref(),
|
||||||
|
input.description.as_ref().map(|d| d.as_deref()),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
current
|
||||||
|
};
|
||||||
|
|
||||||
|
// Slug rename is a separate operation; the rename method does its
|
||||||
|
// own history bookkeeping in a transaction.
|
||||||
|
let after_rename = if let Some(new_slug) = input.slug.as_deref() {
|
||||||
|
validate_slug(new_slug)?;
|
||||||
|
match s
|
||||||
|
.apps
|
||||||
|
.rename_slug(after_meta.id, new_slug, input.force_takeover)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(app) => app,
|
||||||
|
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
|
||||||
|
if let Some(current) = s.apps.slug_in_history(new_slug).await? {
|
||||||
|
return Err(AppsApiError::SlugInHistory(current));
|
||||||
|
}
|
||||||
|
return Err(AppsApiError::Conflict(msg));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
after_meta
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(after_rename))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_app(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Query(q): Query<DeleteAppQuery>,
|
||||||
|
) -> Result<StatusCode, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
|
||||||
|
if q.force {
|
||||||
|
s.apps.delete_cascade(app.id).await?;
|
||||||
|
} else {
|
||||||
|
// Soft pre-check for a clean error; the DB FK is the real guard
|
||||||
|
// (ON DELETE RESTRICT on scripts.app_id).
|
||||||
|
let n_scripts = s.apps.count_scripts_in_app(app.id).await?;
|
||||||
|
if n_scripts > 0 {
|
||||||
|
return Err(AppsApiError::HasScripts(n_scripts));
|
||||||
|
}
|
||||||
|
s.apps.delete(app.id).await?;
|
||||||
|
}
|
||||||
|
refresh_domain_cache(&s).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn slug_check(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<SlugCheckRequest>,
|
||||||
|
) -> Result<Json<SlugCheckResponse>, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
match validate_slug(&input.new_slug) {
|
||||||
|
Err(AppsApiError::InvalidSlug(reason)) => {
|
||||||
|
return Ok(Json(SlugCheckResponse {
|
||||||
|
ok: false,
|
||||||
|
conflict_kind: Some("invalid"),
|
||||||
|
current_app: None,
|
||||||
|
reason: Some(reason),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Err(other) => return Err(other),
|
||||||
|
Ok(()) => {}
|
||||||
|
}
|
||||||
|
if let Some(app) = s.apps.get_by_slug(&input.new_slug).await? {
|
||||||
|
return Ok(Json(SlugCheckResponse {
|
||||||
|
ok: false,
|
||||||
|
conflict_kind: Some("current"),
|
||||||
|
current_app: Some(app),
|
||||||
|
reason: Some("another app currently uses this slug".into()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if let Some(app) = s.apps.slug_in_history(&input.new_slug).await? {
|
||||||
|
return Ok(Json(SlugCheckResponse {
|
||||||
|
ok: false,
|
||||||
|
conflict_kind: Some("historical"),
|
||||||
|
current_app: Some(app),
|
||||||
|
reason: Some("slug is a retired redirect; using it will break old links".into()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(Json(SlugCheckResponse {
|
||||||
|
ok: true,
|
||||||
|
conflict_kind: None,
|
||||||
|
current_app: None,
|
||||||
|
reason: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_domains(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<AppDomain>>, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppRead(app.id)).await?;
|
||||||
|
Ok(Json(s.domains.list_for_app(app.id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_domain(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<CreateDomainRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppManageDomains(app.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let parsed = pattern::parse_app_domain(&input.pattern)?;
|
||||||
|
let created = s
|
||||||
|
.domains
|
||||||
|
.create(NewAppDomain {
|
||||||
|
app_id: app.id,
|
||||||
|
pattern: input.pattern,
|
||||||
|
shape: parsed.shape,
|
||||||
|
shape_key: parsed.shape_key,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
refresh_domain_cache(&s).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_domain(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((id_or_slug, domain_id)): Path<(String, Uuid)>,
|
||||||
|
) -> Result<StatusCode, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppManageDomains(app.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let Some(domain) = s.domains.get(domain_id).await? else {
|
||||||
|
return Err(AppsApiError::DomainNotFound(domain_id));
|
||||||
|
};
|
||||||
|
if domain.app_id != app.id {
|
||||||
|
return Err(AppsApiError::DomainNotFound(domain_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: routes inside this app may reference this exact host
|
||||||
|
// pattern. The host-kind on the route is `strict` or `wildcard`
|
||||||
|
// (Any routes don't pin a specific host). We block deletion in
|
||||||
|
// either case and let the operator clean up first.
|
||||||
|
let strict = s
|
||||||
|
.routes
|
||||||
|
.count_for_app_host(app.id, picloud_shared::HostKind::Strict, &domain.pattern)
|
||||||
|
.await?;
|
||||||
|
let wild_suffix = domain
|
||||||
|
.pattern
|
||||||
|
.split_once('.')
|
||||||
|
.map(|(_, s)| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let wild = if wild_suffix.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
s.routes
|
||||||
|
.count_for_app_host(app.id, picloud_shared::HostKind::Wildcard, &wild_suffix)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
if strict + wild > 0 {
|
||||||
|
return Err(AppsApiError::DomainHasRoutes(strict + wild));
|
||||||
|
}
|
||||||
|
|
||||||
|
s.domains.delete(domain_id).await?;
|
||||||
|
refresh_domain_cache(&s).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn resolve_app(
|
||||||
|
apps: &dyn AppRepository,
|
||||||
|
ident: &str,
|
||||||
|
) -> Result<crate::app_repo::AppLookup, AppsApiError> {
|
||||||
|
crate::app_repo::resolve_app(apps, ident)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppsApiError::AppNotFound(ident.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_slug(slug: &str) -> Result<(), AppsApiError> {
|
||||||
|
if slug.len() < SLUG_MIN || slug.len() > SLUG_MAX {
|
||||||
|
return Err(AppsApiError::InvalidSlug(format!(
|
||||||
|
"slug length must be between {SLUG_MIN} and {SLUG_MAX}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !slug
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.is_some_and(|c| c.is_ascii_alphanumeric())
|
||||||
|
{
|
||||||
|
return Err(AppsApiError::InvalidSlug(
|
||||||
|
"slug must start with [a-z0-9]".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for c in slug.chars() {
|
||||||
|
if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
|
||||||
|
return Err(AppsApiError::InvalidSlug(
|
||||||
|
"slug may only contain lowercase letters, digits, and '-'".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if RESERVED_SLUGS.contains(&slug) {
|
||||||
|
return Err(AppsApiError::InvalidSlug(format!(
|
||||||
|
"slug {slug:?} is reserved for system use"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the in-memory host → app_id cache used by the orchestrator.
|
||||||
|
/// Called after every domain CRUD operation.
|
||||||
|
pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError> {
|
||||||
|
let all = state.domains.list_all().await?;
|
||||||
|
let compiled = all
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|d| {
|
||||||
|
// Parse the stored pattern; skip on parse error rather than
|
||||||
|
// poisoning the entire cache. The handlers reject bad input,
|
||||||
|
// so this is purely defensive against a future migration
|
||||||
|
// that loosens the constraints.
|
||||||
|
pattern::parse_app_domain(&d.pattern)
|
||||||
|
.ok()
|
||||||
|
.map(|p| CompiledAppDomain {
|
||||||
|
app_id: d.app_id,
|
||||||
|
pattern: p.pattern,
|
||||||
|
shape_key: p.shape_key,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
state.domain_table.replace(compiled);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppsApiError {
|
||||||
|
#[error("app not found: {0}")]
|
||||||
|
AppNotFound(String),
|
||||||
|
|
||||||
|
#[error("domain not found: {0}")]
|
||||||
|
DomainNotFound(Uuid),
|
||||||
|
|
||||||
|
#[error("invalid slug: {0}")]
|
||||||
|
InvalidSlug(String),
|
||||||
|
|
||||||
|
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
|
||||||
|
SlugInHistory(App),
|
||||||
|
|
||||||
|
#[error("app still contains {0} script(s); delete or move them first")]
|
||||||
|
HasScripts(i64),
|
||||||
|
|
||||||
|
#[error("domain has {0} route(s) bound to it; delete the routes first")]
|
||||||
|
DomainHasRoutes(i64),
|
||||||
|
|
||||||
|
#[error("invalid pattern: {0}")]
|
||||||
|
Pattern(#[from] pattern::ParseError),
|
||||||
|
|
||||||
|
#[error("conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Repo(#[from] ScriptRepositoryError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for AppsApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzError> for AppsApiError {
|
||||||
|
fn from(e: AuthzError) -> Self {
|
||||||
|
Self::AuthzRepo(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppsApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, body) = match &self {
|
||||||
|
Self::AppNotFound(_)
|
||||||
|
| Self::DomainNotFound(_)
|
||||||
|
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||||
|
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
|
Self::InvalidSlug(_) | Self::Pattern(_) => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
json!({ "error": self.to_string() }),
|
||||||
|
),
|
||||||
|
Self::SlugInHistory(current) => (
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
json!({
|
||||||
|
"error": self.to_string(),
|
||||||
|
"conflict_kind": "historical",
|
||||||
|
"current_app": current,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Self::HasScripts(n) => (
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
json!({ "error": self.to_string(), "script_count": n }),
|
||||||
|
),
|
||||||
|
Self::DomainHasRoutes(n) => (
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
json!({ "error": self.to_string(), "route_count": n }),
|
||||||
|
),
|
||||||
|
Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => {
|
||||||
|
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "apps authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Repo(ScriptRepositoryError::Db(e)) => {
|
||||||
|
tracing::error!(error = %e, "apps api db error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
231
crates/manager-core/src/auth.rs
Normal file
231
crates/manager-core/src/auth.rs
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
//! Pure auth helpers: password hashing, session-token generation, and
|
||||||
|
//! token-to-hash conversion. No DB, no HTTP — repos and middleware live
|
||||||
|
//! in their own modules. Keeping this surface pure also keeps the unit
|
||||||
|
//! tests fast (no Postgres needed).
|
||||||
|
//!
|
||||||
|
//! Hash algorithm is Argon2id with the OWASP default parameters
|
||||||
|
//! (`Argon2::default()`). Tokens are 32 cryptographically random bytes
|
||||||
|
//! base64-url-encoded for the wire; their SHA-256 (hex) is what hits the
|
||||||
|
//! sessions table.
|
||||||
|
|
||||||
|
use argon2::password_hash::rand_core::OsRng as ArgonRng;
|
||||||
|
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||||
|
use argon2::Argon2;
|
||||||
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
|
use base64::Engine as _;
|
||||||
|
use data_encoding::BASE32_NOPAD;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// Returned when the supplied password hash string isn't a valid PHC
|
||||||
|
/// Argon2id encoding. Only surfaces at bootstrap time when the operator
|
||||||
|
/// passes `PICLOUD_ADMIN_PASSWORD_HASH`.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[error("invalid Argon2id PHC hash")]
|
||||||
|
pub struct InvalidPasswordHash;
|
||||||
|
|
||||||
|
/// Hash a raw password into an Argon2id PHC-formatted string suitable
|
||||||
|
/// for `admin_users.password_hash`. The output already encodes the salt
|
||||||
|
/// and parameters; nothing else needs to be persisted alongside it.
|
||||||
|
pub fn hash_password(raw: &str) -> Result<String, argon2::password_hash::Error> {
|
||||||
|
let salt = SaltString::generate(&mut ArgonRng);
|
||||||
|
let hash = Argon2::default().hash_password(raw.as_bytes(), &salt)?;
|
||||||
|
Ok(hash.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constant-ish-time verify of a raw password against a PHC hash.
|
||||||
|
/// Returns `false` for any error (including malformed stored hash) —
|
||||||
|
/// callers should treat that case identically to "wrong password" so
|
||||||
|
/// nothing leaks about why auth failed.
|
||||||
|
#[must_use]
|
||||||
|
pub fn verify_password(stored_hash: &str, raw: &str) -> bool {
|
||||||
|
let Ok(parsed) = PasswordHash::new(stored_hash) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(raw.as_bytes(), &parsed)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that a string parses as a PHC Argon2id hash — used at
|
||||||
|
/// bootstrap to fail fast on malformed `PICLOUD_ADMIN_PASSWORD_HASH`
|
||||||
|
/// rather than write garbage into the DB and discover it at first login.
|
||||||
|
pub fn validate_password_hash(stored_hash: &str) -> Result<(), InvalidPasswordHash> {
|
||||||
|
PasswordHash::new(stored_hash).map_err(|_| InvalidPasswordHash)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newly minted session token: `raw` goes to the client (cookie + JSON
|
||||||
|
/// response), `hash` is what gets stored. Raw is unrecoverable from hash
|
||||||
|
/// even if the DB leaks.
|
||||||
|
pub struct GeneratedToken {
|
||||||
|
pub raw: String,
|
||||||
|
pub hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a fresh session token (32 random bytes base64-url-encoded).
|
||||||
|
/// Always succeeds — `OsRng::fill_bytes` panics on entropy failure
|
||||||
|
/// instead of returning, but that's a non-recoverable system condition.
|
||||||
|
#[must_use]
|
||||||
|
pub fn generate_session_token() -> GeneratedToken {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
let raw = URL_SAFE_NO_PAD.encode(bytes);
|
||||||
|
let hash = hash_token(&raw);
|
||||||
|
GeneratedToken { raw, hash }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SHA-256(raw) as lower-case hex. Stable lookup key for
|
||||||
|
/// `admin_sessions.token_hash`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn hash_token(raw: &str) -> String {
|
||||||
|
let digest = Sha256::digest(raw.as_bytes());
|
||||||
|
hex(&digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||||
|
let mut out = String::with_capacity(bytes.len() * 2);
|
||||||
|
for &b in bytes {
|
||||||
|
out.push(HEX[(b >> 4) as usize] as char);
|
||||||
|
out.push(HEX[(b & 0x0f) as usize] as char);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// API key generation (Phase 3.5)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Wire-format prefix that marks a Bearer value as an API key (vs. a
|
||||||
|
/// session token). Mirrors `auth_middleware::API_KEY_PREFIX` so the
|
||||||
|
/// generator and the verifier agree.
|
||||||
|
pub const API_KEY_WIRE_PREFIX: &str = "pic_";
|
||||||
|
|
||||||
|
/// Length of the indexed prefix portion (the first 8 chars of the
|
||||||
|
/// `pic_`-stripped body). Mirrors `auth_middleware::API_KEY_PREFIX_LEN`.
|
||||||
|
pub const API_KEY_INDEX_PREFIX_LEN: usize = 8;
|
||||||
|
|
||||||
|
/// Newly minted API key — returned exactly once by `POST /api/v1/admin/api-keys`.
|
||||||
|
///
|
||||||
|
/// * `raw` is the full wire-format token (`pic_<base32>`) shown to the
|
||||||
|
/// caller in the response body and never persisted.
|
||||||
|
/// * `prefix` is the indexed 8-char slice persisted to
|
||||||
|
/// `api_keys.prefix` for lookup.
|
||||||
|
/// * `hash` is the Argon2id PHC string persisted to `api_keys.hash`;
|
||||||
|
/// covers the body after `pic_` (i.e., `raw[4..]`).
|
||||||
|
pub struct GeneratedApiKey {
|
||||||
|
pub raw: String,
|
||||||
|
pub prefix: String,
|
||||||
|
pub hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a fresh API key. 32 random bytes → unpadded base32, then
|
||||||
|
/// `pic_` prefix on the wire. The first 8 base32 chars are the index
|
||||||
|
/// key; everything after `pic_` is what the verifier hashes.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `argon2::password_hash::Error` if the Argon2 hash step
|
||||||
|
/// fails (which it shouldn't under normal conditions).
|
||||||
|
pub fn generate_api_key() -> Result<GeneratedApiKey, argon2::password_hash::Error> {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
let body = BASE32_NOPAD.encode(&bytes);
|
||||||
|
debug_assert!(
|
||||||
|
body.len() >= API_KEY_INDEX_PREFIX_LEN,
|
||||||
|
"32 bytes base32 must exceed the 8-char prefix length"
|
||||||
|
);
|
||||||
|
let prefix = body[..API_KEY_INDEX_PREFIX_LEN].to_string();
|
||||||
|
let salt = SaltString::generate(&mut ArgonRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(body.as_bytes(), &salt)?
|
||||||
|
.to_string();
|
||||||
|
let raw = format!("{API_KEY_WIRE_PREFIX}{body}");
|
||||||
|
Ok(GeneratedApiKey { raw, prefix, hash })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a wire-format token body (the portion *after* `pic_`)
|
||||||
|
/// against a stored Argon2id hash. Convenience wrapper around
|
||||||
|
/// `verify_password` named to reflect its caller.
|
||||||
|
#[must_use]
|
||||||
|
pub fn verify_api_key(stored_hash: &str, presented_body: &str) -> bool {
|
||||||
|
verify_password(stored_hash, presented_body)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_verify_roundtrip() {
|
||||||
|
let h = hash_password("correct horse battery staple").unwrap();
|
||||||
|
assert!(verify_password(&h, "correct horse battery staple"));
|
||||||
|
assert!(!verify_password(&h, "wrong"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_returns_false_on_malformed_hash() {
|
||||||
|
assert!(!verify_password("not-a-phc-string", "anything"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_password_hash_accepts_phc() {
|
||||||
|
let h = hash_password("pw").unwrap();
|
||||||
|
assert!(validate_password_hash(&h).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_password_hash_rejects_garbage() {
|
||||||
|
assert!(validate_password_hash("not a hash").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_token_unique_and_hash_stable() {
|
||||||
|
let a = generate_session_token();
|
||||||
|
let b = generate_session_token();
|
||||||
|
assert_ne!(a.raw, b.raw, "tokens must be unique");
|
||||||
|
assert_ne!(a.hash, b.hash, "hashes must differ");
|
||||||
|
assert_eq!(a.hash, hash_token(&a.raw), "hash must be reproducible");
|
||||||
|
assert_eq!(a.hash.len(), 64, "sha256-hex is 64 chars");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_api_key_round_trip() {
|
||||||
|
let key = generate_api_key().expect("mint");
|
||||||
|
assert!(
|
||||||
|
key.raw.starts_with(API_KEY_WIRE_PREFIX),
|
||||||
|
"raw must carry the pic_ prefix"
|
||||||
|
);
|
||||||
|
let body = key
|
||||||
|
.raw
|
||||||
|
.strip_prefix(API_KEY_WIRE_PREFIX)
|
||||||
|
.expect("starts with prefix");
|
||||||
|
assert_eq!(
|
||||||
|
&body[..API_KEY_INDEX_PREFIX_LEN],
|
||||||
|
key.prefix,
|
||||||
|
"stored prefix matches the first 8 chars of the body"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
verify_api_key(&key.hash, body),
|
||||||
|
"Argon2 verify must accept the original body"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!verify_api_key(&key.hash, "wrong-body-entirely"),
|
||||||
|
"Argon2 verify must reject anything else"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_api_key_unique() {
|
||||||
|
let a = generate_api_key().expect("mint a");
|
||||||
|
let b = generate_api_key().expect("mint b");
|
||||||
|
assert_ne!(a.raw, b.raw);
|
||||||
|
assert_ne!(a.hash, b.hash);
|
||||||
|
assert_ne!(
|
||||||
|
a.prefix, b.prefix,
|
||||||
|
"32 random bytes → prefix collision is negligible"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
269
crates/manager-core/src/auth_api.rs
Normal file
269
crates/manager-core/src/auth_api.rs
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
//! `/api/v1/admin/auth/*` — login, logout, who-am-I.
|
||||||
|
//!
|
||||||
|
//! Login mints an opaque session token, stores its SHA-256, sets the
|
||||||
|
//! `picloud_session` HttpOnly cookie, and also returns the raw token in
|
||||||
|
//! the JSON body for non-browser clients. The same token works as
|
||||||
|
//! `Authorization: Bearer …` afterward; there is no separate "API
|
||||||
|
//! token" concept yet.
|
||||||
|
//!
|
||||||
|
//! Logout deletes the session row regardless of whether the supplied
|
||||||
|
//! token matched anything (idempotent). `me` returns the row that the
|
||||||
|
//! middleware already attached to the request extensions.
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::{Extension, Request, State};
|
||||||
|
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||||
|
use axum::middleware::from_fn_with_state;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::Router;
|
||||||
|
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||||
|
use picloud_shared::{AdminUserId, InstanceRole};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use picloud_shared::Principal;
|
||||||
|
|
||||||
|
use crate::auth::{generate_session_token, hash_token, verify_password};
|
||||||
|
use crate::auth_middleware::{require_authenticated, AuthState, SESSION_COOKIE};
|
||||||
|
|
||||||
|
pub fn auth_router(state: AuthState) -> Router {
|
||||||
|
// /login + /logout are unguarded (login is how you get in; logout
|
||||||
|
// is idempotent). /me is guarded — by definition it needs to know
|
||||||
|
// who you are, so the middleware must run first.
|
||||||
|
let guarded = Router::new()
|
||||||
|
.route("/auth/me", get(me))
|
||||||
|
.route_layer(from_fn_with_state(state.clone(), require_authenticated));
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.route("/auth/login", post(login))
|
||||||
|
.route("/auth/logout", post(logout))
|
||||||
|
.merge(guarded)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// DTOs
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LoginResponse {
|
||||||
|
pub user: AdminUserDto,
|
||||||
|
pub token: String,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AdminUserDto {
|
||||||
|
pub id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>) -> Response {
|
||||||
|
// Always perform a verify, even on missing/inactive users, to flatten
|
||||||
|
// timing and prevent username enumeration. The dummy hash is a real
|
||||||
|
// Argon2id PHC string for "x" — the verify will simply fail.
|
||||||
|
const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$dGltaW5nLWZsYXR0ZW4$Ux6dgPqgX1Mhg5fRgIeKZF3MWdYqJplKEz/cKLcSdks";
|
||||||
|
|
||||||
|
let creds = match state
|
||||||
|
.users
|
||||||
|
.get_credentials_by_username(&input.username)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_users credentials lookup failed");
|
||||||
|
return internal_error();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// username from creds is discarded — the re-fetch below carries the
|
||||||
|
// canonical row used in the response DTO.
|
||||||
|
let (stored_hash, user_id, is_active) = match creds {
|
||||||
|
Some(c) => (c.password_hash, Some(c.id), c.is_active),
|
||||||
|
None => (DUMMY_HASH.to_string(), None, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
let password_ok = verify_password(&stored_hash, &input.password);
|
||||||
|
if !password_ok || user_id.is_none() || !is_active {
|
||||||
|
return invalid_credentials();
|
||||||
|
}
|
||||||
|
let user_id = user_id.unwrap();
|
||||||
|
|
||||||
|
// Re-fetch the full row so the login response carries the same
|
||||||
|
// shape /me does (instance_role, email). The credentials struct
|
||||||
|
// intentionally omits email; one extra query per login is fine.
|
||||||
|
let user_row = match state.users.get(user_id).await {
|
||||||
|
Ok(Some(row)) => row,
|
||||||
|
Ok(None) => return invalid_credentials(),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_users lookup after login failed");
|
||||||
|
return internal_error();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = generate_session_token();
|
||||||
|
let expires_at = Utc::now()
|
||||||
|
+ ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
|
||||||
|
|
||||||
|
if let Err(err) = state
|
||||||
|
.sessions
|
||||||
|
.create(user_id, &token.hash, expires_at)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(?err, "admin_sessions insert failed");
|
||||||
|
return internal_error();
|
||||||
|
}
|
||||||
|
if let Err(err) = state.users.touch_last_login(user_id).await {
|
||||||
|
// Non-fatal — log and continue. Login itself succeeded.
|
||||||
|
tracing::warn!(?err, "failed to touch admin last_login_at");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
HeaderValue::from_str(&build_cookie(&token.raw, state.ttl)).unwrap_or_else(|_| {
|
||||||
|
// Cookie text is ASCII-clean by construction; this branch is
|
||||||
|
// unreachable in practice but the type signature requires it.
|
||||||
|
HeaderValue::from_static("")
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
headers,
|
||||||
|
Json(LoginResponse {
|
||||||
|
user: AdminUserDto {
|
||||||
|
id: user_row.id,
|
||||||
|
username: user_row.username,
|
||||||
|
instance_role: user_row.instance_role,
|
||||||
|
email: user_row.email,
|
||||||
|
},
|
||||||
|
token: token.raw,
|
||||||
|
expires_at,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn logout(State(state): State<AuthState>, req: Request<Body>) -> Response {
|
||||||
|
// Pull token without requiring a valid session (logout is idempotent
|
||||||
|
// and we still want to clear the cookie on the client side).
|
||||||
|
let token = extract_token_for_logout(&req);
|
||||||
|
if let Some(raw) = token {
|
||||||
|
let hash = hash_token(&raw);
|
||||||
|
if let Err(err) = state.sessions.delete(&hash).await {
|
||||||
|
tracing::error!(?err, "admin_sessions delete failed");
|
||||||
|
// Still clear the cookie below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
HeaderValue::from_static("picloud_session=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0"),
|
||||||
|
);
|
||||||
|
(StatusCode::NO_CONTENT, headers).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn me(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
) -> Response {
|
||||||
|
// /me consumes the resolved Principal directly; we re-fetch the
|
||||||
|
// user row only to surface a fresh username (it can change via
|
||||||
|
// PATCH while a session/key is still valid).
|
||||||
|
match state.users.get(principal.user_id).await {
|
||||||
|
Ok(Some(row)) => Json(AdminUserDto {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
instance_role: row.instance_role,
|
||||||
|
email: row.email,
|
||||||
|
})
|
||||||
|
.into_response(),
|
||||||
|
Ok(None) => invalid_credentials(),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_users lookup for /me failed");
|
||||||
|
internal_error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn build_cookie(raw_token: &str, ttl: std::time::Duration) -> String {
|
||||||
|
// Secure is on by default; flip to off for HTTP-only dev with
|
||||||
|
// PICLOUD_COOKIE_SECURE=0. The header-injected bearer token works
|
||||||
|
// either way, so this is purely for browsers that prefer the cookie
|
||||||
|
// path (e.g., direct API hits without the dashboard's auth.ts).
|
||||||
|
let secure = std::env::var("PICLOUD_COOKIE_SECURE").ok().is_none_or(|v| {
|
||||||
|
!matches!(
|
||||||
|
v.to_ascii_lowercase().as_str(),
|
||||||
|
"0" | "false" | "no" | "off"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let secure_attr = if secure { "; Secure" } else { "" };
|
||||||
|
format!(
|
||||||
|
"{SESSION_COOKIE}={raw_token}; HttpOnly{secure_attr}; SameSite=Lax; Path=/; Max-Age={}",
|
||||||
|
ttl.as_secs()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_token_for_logout(req: &Request<Body>) -> Option<String> {
|
||||||
|
// Same precedence as the middleware — Authorization first, cookie
|
||||||
|
// fallback. Duplicated here because logout has to read the request
|
||||||
|
// before any middleware would run.
|
||||||
|
if let Some(value) = req.headers().get(header::AUTHORIZATION) {
|
||||||
|
if let Ok(s) = value.to_str() {
|
||||||
|
if let Some(token) = s.strip_prefix("Bearer ") {
|
||||||
|
let trimmed = token.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = req.headers().get(header::COOKIE) {
|
||||||
|
if let Ok(s) = value.to_str() {
|
||||||
|
for chunk in s.split(';') {
|
||||||
|
let chunk = chunk.trim();
|
||||||
|
if let Some(rest) = chunk.strip_prefix(&format!("{SESSION_COOKIE}=")) {
|
||||||
|
if !rest.is_empty() {
|
||||||
|
return Some(rest.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_credentials() -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(json!({ "error": "invalid credentials" })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal_error() -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": "internal error" })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
331
crates/manager-core/src/auth_bootstrap.rs
Normal file
331
crates/manager-core/src/auth_bootstrap.rs
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
//! First-run admin seeding from env vars. Idempotent: if any admin
|
||||||
|
//! already exists, this is a no-op (and a warning is logged when the
|
||||||
|
//! env vars are also set, so the operator notices the inert state).
|
||||||
|
//!
|
||||||
|
//! On a fresh install, exactly one row is inserted from:
|
||||||
|
//! - `PICLOUD_ADMIN_USERNAME` (required)
|
||||||
|
//! - `PICLOUD_ADMIN_PASSWORD_HASH` (preferred — pre-computed PHC) OR
|
||||||
|
//! - `PICLOUD_ADMIN_PASSWORD` (fallback — raw, hashed on the way in)
|
||||||
|
//!
|
||||||
|
//! After that initial seed, the env vars become inert. This is
|
||||||
|
//! deliberate: the env var is a one-time setup hatch, not a permanent
|
||||||
|
//! override (which would let anyone with systemd/compose access change
|
||||||
|
//! any admin's password without authentication). Recovery is the CLI
|
||||||
|
//! subcommand `picloud admin reset-password <username>`.
|
||||||
|
//!
|
||||||
|
//! The env-var reading is factored into `BootstrapEnv::from_process`
|
||||||
|
//! so the core logic stays pure (and testable) — the only side effect
|
||||||
|
//! in `bootstrap_first_admin` is the DB write and a tracing log.
|
||||||
|
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::admin_user_repo::AdminUserRepository;
|
||||||
|
use crate::auth::{hash_password, validate_password_hash};
|
||||||
|
|
||||||
|
pub const ENV_USERNAME: &str = "PICLOUD_ADMIN_USERNAME";
|
||||||
|
pub const ENV_PASSWORD: &str = "PICLOUD_ADMIN_PASSWORD";
|
||||||
|
pub const ENV_PASSWORD_HASH: &str = "PICLOUD_ADMIN_PASSWORD_HASH";
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum BootstrapError {
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Repo(#[from] crate::admin_user_repo::AdminUserRepositoryError),
|
||||||
|
|
||||||
|
#[error("{ENV_USERNAME} not set (required to bootstrap the first admin)")]
|
||||||
|
MissingUsername,
|
||||||
|
|
||||||
|
#[error(
|
||||||
|
"no admin password env var set; provide {ENV_PASSWORD_HASH} (preferred) or {ENV_PASSWORD}"
|
||||||
|
)]
|
||||||
|
MissingPassword,
|
||||||
|
|
||||||
|
#[error("{ENV_PASSWORD_HASH} is not a valid Argon2id PHC string")]
|
||||||
|
InvalidHash,
|
||||||
|
|
||||||
|
#[error("failed to hash password: {0}")]
|
||||||
|
HashFailure(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captured-at-call-site env values. The fields map 1:1 to the bootstrap
|
||||||
|
/// env vars. Read from the live process with `from_process`, or build
|
||||||
|
/// directly in tests to keep them free of process-env races.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct BootstrapEnv {
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub password_hash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BootstrapEnv {
|
||||||
|
/// Snapshot the bootstrap env vars from the current process.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_process() -> Self {
|
||||||
|
Self {
|
||||||
|
username: std::env::var(ENV_USERNAME).ok(),
|
||||||
|
password: std::env::var(ENV_PASSWORD).ok(),
|
||||||
|
password_hash: std::env::var(ENV_PASSWORD_HASH).ok(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn any_set(&self) -> bool {
|
||||||
|
self.username.is_some() || self.password.is_some() || self.password_hash.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the bootstrap. Reads env vars from the live process — the
|
||||||
|
/// canonical wiring for the binary.
|
||||||
|
pub async fn bootstrap_first_admin<R: AdminUserRepository + ?Sized>(
|
||||||
|
repo: &R,
|
||||||
|
) -> Result<(), BootstrapError> {
|
||||||
|
bootstrap_first_admin_with(repo, BootstrapEnv::from_process()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the bootstrap against an explicit env. Used by tests to keep
|
||||||
|
/// the bootstrap logic independent of process state.
|
||||||
|
pub async fn bootstrap_first_admin_with<R: AdminUserRepository + ?Sized>(
|
||||||
|
repo: &R,
|
||||||
|
env: BootstrapEnv,
|
||||||
|
) -> Result<(), BootstrapError> {
|
||||||
|
if repo.count_active().await? > 0 {
|
||||||
|
if env.any_set() {
|
||||||
|
warn!(
|
||||||
|
"{ENV_USERNAME}/{ENV_PASSWORD}/{ENV_PASSWORD_HASH} set but admin_users \
|
||||||
|
already populated — env values ignored. Use \
|
||||||
|
`picloud admin reset-password <user>` to change a password."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let username = env.username.ok_or(BootstrapError::MissingUsername)?;
|
||||||
|
|
||||||
|
let password_hash = match (env.password_hash, env.password) {
|
||||||
|
(Some(hash), maybe_raw) => {
|
||||||
|
if maybe_raw.is_some() {
|
||||||
|
warn!(
|
||||||
|
"both {ENV_PASSWORD_HASH} and {ENV_PASSWORD} set — \
|
||||||
|
using the pre-computed hash; raw password ignored."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validate_password_hash(&hash).map_err(|_| BootstrapError::InvalidHash)?;
|
||||||
|
hash
|
||||||
|
}
|
||||||
|
(None, Some(raw)) => {
|
||||||
|
hash_password(&raw).map_err(|e| BootstrapError::HashFailure(e.to_string()))?
|
||||||
|
}
|
||||||
|
(None, None) => return Err(BootstrapError::MissingPassword),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bootstrap admin is always seeded as Owner — Phase 3.5 keys the
|
||||||
|
// first row to full instance control. Subsequent admins minted via
|
||||||
|
// the API default to Admin and can be promoted explicitly.
|
||||||
|
repo.create(
|
||||||
|
&username,
|
||||||
|
&password_hash,
|
||||||
|
picloud_shared::InstanceRole::Owner,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
info!(username = %username, "bootstrapped initial admin user");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
//! These tests use an in-memory `AdminUserRepository` and the
|
||||||
|
//! `bootstrap_first_admin_with` overload so they never touch
|
||||||
|
//! process-global env vars. They can run in parallel safely.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::Utc;
|
||||||
|
use picloud_shared::{AdminUserId, InstanceRole};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::admin_user_repo::{AdminUserCredentials, AdminUserRepositoryError, AdminUserRow};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct InMemoryRepo {
|
||||||
|
rows: Mutex<Vec<AdminUserRow>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AdminUserRepository for InMemoryRepo {
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
_id: AdminUserId,
|
||||||
|
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn get_by_username(
|
||||||
|
&self,
|
||||||
|
_u: &str,
|
||||||
|
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn get_credentials_by_username(
|
||||||
|
&self,
|
||||||
|
_u: &str,
|
||||||
|
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
_password_hash: &str,
|
||||||
|
instance_role: InstanceRole,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let row = AdminUserRow {
|
||||||
|
id: AdminUserId::new(),
|
||||||
|
username: username.to_string(),
|
||||||
|
is_active: true,
|
||||||
|
instance_role,
|
||||||
|
email: email.map(str::to_string),
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
last_login_at: None,
|
||||||
|
};
|
||||||
|
self.rows.lock().unwrap().push(row.clone());
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
async fn update_username(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
_u: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn update_password_hash(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
_h: &str,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn update_email(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
_e: Option<&str>,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn update_instance_role(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
_r: InstanceRole,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn set_active(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
_a: bool,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn delete(&self, _i: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn touch_last_login(&self, _i: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn count_active(&self) -> Result<i64, AdminUserRepositoryError> {
|
||||||
|
Ok(i64::try_from(self.rows.lock().unwrap().len()).unwrap_or(i64::MAX))
|
||||||
|
}
|
||||||
|
async fn count_active_excluding(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
) -> Result<i64, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn count_other_active_owners(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
) -> Result<i64, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_db_creates_admin_from_raw_password() {
|
||||||
|
let repo = InMemoryRepo::default();
|
||||||
|
let env = BootstrapEnv {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: Some("supersecret".into()),
|
||||||
|
password_hash: None,
|
||||||
|
};
|
||||||
|
bootstrap_first_admin_with(&repo, env).await.unwrap();
|
||||||
|
assert_eq!(repo.rows.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_db_with_pre_hashed_password_succeeds() {
|
||||||
|
let repo = InMemoryRepo::default();
|
||||||
|
let prehashed = hash_password("pw").unwrap();
|
||||||
|
let env = BootstrapEnv {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: None,
|
||||||
|
password_hash: Some(prehashed),
|
||||||
|
};
|
||||||
|
bootstrap_first_admin_with(&repo, env).await.unwrap();
|
||||||
|
assert_eq!(repo.rows.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn populated_db_is_noop() {
|
||||||
|
let repo = InMemoryRepo::default();
|
||||||
|
repo.create("seeded", "x", InstanceRole::Owner, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let env = BootstrapEnv {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: Some("supersecret".into()),
|
||||||
|
password_hash: None,
|
||||||
|
};
|
||||||
|
bootstrap_first_admin_with(&repo, env).await.unwrap();
|
||||||
|
assert_eq!(repo.rows.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_username_fails() {
|
||||||
|
let repo = InMemoryRepo::default();
|
||||||
|
let env = BootstrapEnv {
|
||||||
|
username: None,
|
||||||
|
password: Some("supersecret".into()),
|
||||||
|
password_hash: None,
|
||||||
|
};
|
||||||
|
let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err();
|
||||||
|
assert!(matches!(err, BootstrapError::MissingUsername));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_password_fails() {
|
||||||
|
let repo = InMemoryRepo::default();
|
||||||
|
let env = BootstrapEnv {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: None,
|
||||||
|
password_hash: None,
|
||||||
|
};
|
||||||
|
let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err();
|
||||||
|
assert!(matches!(err, BootstrapError::MissingPassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_hash_fails() {
|
||||||
|
let repo = InMemoryRepo::default();
|
||||||
|
let env = BootstrapEnv {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: None,
|
||||||
|
password_hash: Some("not a phc hash".into()),
|
||||||
|
};
|
||||||
|
let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err();
|
||||||
|
assert!(matches!(err, BootstrapError::InvalidHash));
|
||||||
|
}
|
||||||
|
}
|
||||||
377
crates/manager-core/src/auth_middleware.rs
Normal file
377
crates/manager-core/src/auth_middleware.rs
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
//! Authentication middleware — resolves the caller's `Principal` from
|
||||||
|
//! either a session cookie / Bearer session-token OR an API key
|
||||||
|
//! (`Authorization: Bearer pic_…`). Both paths converge on the same
|
||||||
|
//! request extension so downstream handlers see one shape.
|
||||||
|
//!
|
||||||
|
//! Capability checks live in `crate::authz` and are called per-handler
|
||||||
|
//! (after the relevant resource is loaded, so the capability binds to
|
||||||
|
//! the actual resource's `app_id`). This middleware is gate-only: it
|
||||||
|
//! ensures *some* `Principal` is attached, or returns 401.
|
||||||
|
//!
|
||||||
|
//! Token discriminator: the `pic_` prefix on a Bearer value selects
|
||||||
|
//! the API-key path; anything else (raw 32-byte base64-url-encoded
|
||||||
|
//! string) takes the session path. The session cookie can only ever
|
||||||
|
//! carry a session token (cookies are never API keys).
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::{Request, State};
|
||||||
|
use axum::http::{header, StatusCode};
|
||||||
|
use axum::middleware::Next;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use chrono::Utc;
|
||||||
|
use picloud_shared::{AdminUserId, Principal};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::admin_session_repo::AdminSessionRepository;
|
||||||
|
use crate::admin_user_repo::AdminUserRepository;
|
||||||
|
use crate::api_key_repo::{ApiKeyRepository, ApiKeyVerification};
|
||||||
|
use crate::auth::{hash_token, verify_password};
|
||||||
|
|
||||||
|
pub const SESSION_COOKIE: &str = "picloud_session";
|
||||||
|
|
||||||
|
/// Prefix on the wire that selects the API-key path. The body that
|
||||||
|
/// follows is `base32(32 random bytes)`; the first 8 chars of the body
|
||||||
|
/// index into `api_keys.prefix` for verification.
|
||||||
|
pub const API_KEY_PREFIX: &str = "pic_";
|
||||||
|
|
||||||
|
/// Length of the indexed prefix portion of an API key (the 8 chars
|
||||||
|
/// immediately after `pic_`). Schema-side index is on this slice.
|
||||||
|
pub const API_KEY_PREFIX_LEN: usize = 8;
|
||||||
|
|
||||||
|
/// Shared state for auth: the user / session / API-key repos plus the
|
||||||
|
/// configured sliding session TTL. Cheap to clone (`Arc` everywhere).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthState {
|
||||||
|
pub users: Arc<dyn AdminUserRepository>,
|
||||||
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||||
|
pub keys: Arc<dyn ApiKeyRepository>,
|
||||||
|
pub ttl: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy request-extension alias retained so the (only remaining)
|
||||||
|
/// handler that pulled `AuthedAdmin` out — `GET /admin/auth/me` —
|
||||||
|
/// keeps compiling during the migration. New handlers should pull
|
||||||
|
/// `Extension<Principal>` directly.
|
||||||
|
#[deprecated(note = "use Extension<Principal> directly")]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuthedAdmin {
|
||||||
|
pub id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Middleware entry point. Wire with
|
||||||
|
/// `axum::middleware::from_fn_with_state(auth_state, require_authenticated)`.
|
||||||
|
/// Inserts `Principal` (and the legacy `AuthedAdmin`) as request
|
||||||
|
/// extensions on success; returns 401 on any failure mode.
|
||||||
|
pub async fn require_authenticated(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
mut req: Request<Body>,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
let Some(token) = extract_token(&req) else {
|
||||||
|
return unauthorized();
|
||||||
|
};
|
||||||
|
let principal = match resolve_principal(&state, &token).await {
|
||||||
|
Ok(Some(p)) => p,
|
||||||
|
Ok(None) => return unauthorized(),
|
||||||
|
Err(InternalError) => return internal_error(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let username_for_legacy = username_for(&state, principal.user_id).await;
|
||||||
|
req.extensions_mut().insert(principal.clone());
|
||||||
|
#[allow(deprecated)]
|
||||||
|
if let Some(username) = username_for_legacy {
|
||||||
|
req.extensions_mut().insert(AuthedAdmin {
|
||||||
|
id: principal.user_id,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next.run(req).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backwards-compatible alias — the single callsite that still names
|
||||||
|
/// `require_admin` keeps working without an immediate rename. New
|
||||||
|
/// wiring should call `require_authenticated`.
|
||||||
|
#[deprecated(note = "renamed to require_authenticated")]
|
||||||
|
pub async fn require_admin(state: State<AuthState>, req: Request<Body>, next: Next) -> Response {
|
||||||
|
require_authenticated(state, req, next).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// token, then resolve the corresponding `Principal`. `Ok(None)`
|
||||||
|
/// means the token was structurally valid but didn't match any active
|
||||||
|
/// credential; `Err(InternalError)` means a DB blip.
|
||||||
|
async fn resolve_principal(
|
||||||
|
state: &AuthState,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<Option<Principal>, InternalError> {
|
||||||
|
if let Some(rest) = token.strip_prefix(API_KEY_PREFIX) {
|
||||||
|
return verify_api_key(state, rest).await;
|
||||||
|
}
|
||||||
|
verify_session(state, token).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_session(
|
||||||
|
state: &AuthState,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<Option<Principal>, InternalError> {
|
||||||
|
let token_hash = hash_token(token);
|
||||||
|
|
||||||
|
let lookup = match state.sessions.lookup(&token_hash).await {
|
||||||
|
Ok(Some(l)) => l,
|
||||||
|
Ok(None) => return Ok(None),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_sessions lookup failed");
|
||||||
|
return Err(InternalError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = match state.users.get(lookup.user_id).await {
|
||||||
|
Ok(Some(u)) if u.is_active => u,
|
||||||
|
Ok(_) => return Ok(None),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_users lookup failed");
|
||||||
|
return Err(InternalError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sliding-window bump — inline so a DB blip surfaces as 500 rather
|
||||||
|
// than silent stale sessions. Same shape as Phase 3a.
|
||||||
|
let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default();
|
||||||
|
if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await {
|
||||||
|
tracing::error!(?err, "admin_sessions touch failed");
|
||||||
|
return Err(InternalError);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(Principal {
|
||||||
|
user_id: user.id,
|
||||||
|
instance_role: user.instance_role,
|
||||||
|
scopes: None,
|
||||||
|
app_binding: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API-key verification path. `rest` is the portion of the bearer
|
||||||
|
/// value *after* `pic_`. We slice off the first 8 chars as the
|
||||||
|
/// indexed lookup key, then Argon2id-verify each candidate's hash
|
||||||
|
/// against the full `rest`. At most one match is expected; multiple
|
||||||
|
/// candidates with the same prefix is statistically negligible but
|
||||||
|
/// handled correctly (verify each, take the first match).
|
||||||
|
async fn verify_api_key(state: &AuthState, rest: &str) -> Result<Option<Principal>, InternalError> {
|
||||||
|
if rest.len() <= API_KEY_PREFIX_LEN {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let prefix = &rest[..API_KEY_PREFIX_LEN];
|
||||||
|
|
||||||
|
let candidates = match state.keys.find_active_by_prefix(prefix).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "api_keys lookup failed");
|
||||||
|
return Err(InternalError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let matched: Option<ApiKeyVerification> = candidates
|
||||||
|
.into_iter()
|
||||||
|
.find(|c| verify_password(&c.hash, rest));
|
||||||
|
let Some(matched) = matched else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve the owning user. is_active = false → reject even if the
|
||||||
|
// key itself hasn't been expired yet (the expire_all_for_user
|
||||||
|
// cascade on deactivation is the primary defense; this is the
|
||||||
|
// belt-and-suspenders check at request time).
|
||||||
|
let user = match state.users.get(matched.user_id).await {
|
||||||
|
Ok(Some(u)) if u.is_active => u,
|
||||||
|
Ok(_) => return Ok(None),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_users lookup for api key failed");
|
||||||
|
return Err(InternalError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = state.keys.touch_last_used(matched.id).await {
|
||||||
|
tracing::error!(?err, "api_keys touch_last_used failed");
|
||||||
|
// Soft-fail: a timestamp blip should not invalidate the
|
||||||
|
// request. Continue with the resolved Principal.
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(Principal {
|
||||||
|
user_id: user.id,
|
||||||
|
instance_role: user.instance_role,
|
||||||
|
scopes: Some(matched.scopes),
|
||||||
|
app_binding: matched.app_id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort username lookup for the legacy `AuthedAdmin` extension.
|
||||||
|
/// Returns `None` on DB error (the caller treats `None` as "skip the
|
||||||
|
/// legacy extension"). New handlers use `Principal` and don't depend
|
||||||
|
/// on this.
|
||||||
|
async fn username_for(state: &AuthState, id: AdminUserId) -> Option<String> {
|
||||||
|
match state.users.get(id).await {
|
||||||
|
Ok(Some(u)) => Some(u.username),
|
||||||
|
Ok(None) => None,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(
|
||||||
|
?err,
|
||||||
|
"username lookup for AuthedAdmin failed; skipping legacy ext"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the bearer token out of an `Authorization` header (preferred)
|
||||||
|
/// or the `picloud_session` cookie (fallback for browser clients).
|
||||||
|
/// Same shape as Phase 3a; the cookie only ever carries session
|
||||||
|
/// tokens — no `pic_` prefix expected there.
|
||||||
|
fn extract_token(req: &Request<Body>) -> Option<String> {
|
||||||
|
if let Some(value) = req.headers().get(header::AUTHORIZATION) {
|
||||||
|
if let Ok(s) = value.to_str() {
|
||||||
|
if let Some(token) = s.strip_prefix("Bearer ") {
|
||||||
|
let trimmed = token.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = req.headers().get(header::COOKIE) {
|
||||||
|
if let Ok(s) = value.to_str() {
|
||||||
|
for chunk in s.split(';') {
|
||||||
|
let chunk = chunk.trim();
|
||||||
|
if let Some(rest) = chunk.strip_prefix(&format!("{SESSION_COOKIE}=")) {
|
||||||
|
if !rest.is_empty() {
|
||||||
|
return Some(rest.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sentinel returned from the resolve functions when a DB error should
|
||||||
|
/// produce a 500 rather than a 401. Empty struct because the actual
|
||||||
|
/// error is already logged at the failure site.
|
||||||
|
struct InternalError;
|
||||||
|
|
||||||
|
fn unauthorized() -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(json!({ "error": "authentication required" })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal_error() -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": "internal error" })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::http::Request;
|
||||||
|
use picloud_shared::InstanceRole;
|
||||||
|
|
||||||
|
fn req_with_header(name: &str, value: &str) -> Request<Body> {
|
||||||
|
Request::builder()
|
||||||
|
.header(name, value)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_bearer_token() {
|
||||||
|
let r = req_with_header("authorization", "Bearer abc123");
|
||||||
|
assert_eq!(extract_token(&r).as_deref(), Some("abc123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_bearer_pic_prefixed_token() {
|
||||||
|
let r = req_with_header("authorization", "Bearer pic_abcdefghIJKL");
|
||||||
|
assert_eq!(extract_token(&r).as_deref(), Some("pic_abcdefghIJKL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_bearer_with_no_token() {
|
||||||
|
let r = req_with_header("authorization", "Bearer ");
|
||||||
|
assert_eq!(extract_token(&r), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_cookie_token() {
|
||||||
|
let r = req_with_header("cookie", "foo=bar; picloud_session=xyz; baz=qux");
|
||||||
|
assert_eq!(extract_token(&r).as_deref(), Some("xyz"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bearer_wins_over_cookie() {
|
||||||
|
let r = Request::builder()
|
||||||
|
.header("authorization", "Bearer header-token")
|
||||||
|
.header("cookie", "picloud_session=cookie-token")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(extract_token(&r).as_deref(), Some("header-token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_none_when_neither_present() {
|
||||||
|
let r = Request::builder().body(Body::empty()).unwrap();
|
||||||
|
assert_eq!(extract_token(&r), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-trip test for the unused-variable to keep `Principal`
|
||||||
|
// visibly tied to InstanceRole — caught a real bug during dev when
|
||||||
|
// the field order in the struct literal had drifted.
|
||||||
|
#[test]
|
||||||
|
fn principal_construction_is_explicit() {
|
||||||
|
let p = Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: InstanceRole::Owner,
|
||||||
|
scopes: None,
|
||||||
|
app_binding: None,
|
||||||
|
};
|
||||||
|
assert_eq!(p.instance_role, InstanceRole::Owner);
|
||||||
|
assert!(p.scopes.is_none());
|
||||||
|
assert!(p.app_binding.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
614
crates/manager-core/src/authz.rs
Normal file
614
crates/manager-core/src/authz.rs
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
//! Capability-based authorization — see blueprint §11.6.
|
||||||
|
//!
|
||||||
|
//! Single entry point for every admin endpoint: `can(repo, principal,
|
||||||
|
//! capability)` returns whether the caller can perform the action.
|
||||||
|
//! Handlers call `require` (which wraps `can` + a `Forbidden` error)
|
||||||
|
//! after loading the resource so the capability binds to the resource's
|
||||||
|
//! actual `app_id`, not a path param the caller controls.
|
||||||
|
//!
|
||||||
|
//! Three layers of intersection, evaluated in order:
|
||||||
|
//!
|
||||||
|
//! 1. **Role grant** — does the caller's `InstanceRole` plus any
|
||||||
|
//! `app_members` row authorize this capability?
|
||||||
|
//! 2. **Scope intersection** — if the principal came from an API key
|
||||||
|
//! (`principal.scopes.is_some()`), does the key's scope set cover
|
||||||
|
//! the capability's required scope?
|
||||||
|
//! 3. **App binding** — if the key was minted bound to a specific
|
||||||
|
//! app (`principal.app_binding`), does the capability target the
|
||||||
|
//! same app? (Instance-level capabilities are denied for bound
|
||||||
|
//! keys; the mint handler also rejects the combination upfront.)
|
||||||
|
//!
|
||||||
|
//! The capability set is intentionally finer-grained than the seven
|
||||||
|
//! scopes (e.g., `AppWriteScript` vs `AppWriteRoute` both fall under
|
||||||
|
//! the `script:write` / `route:write` scopes respectively). Keeping
|
||||||
|
//! capabilities precise lets a `script:write`-only key write scripts
|
||||||
|
//! without also being able to mutate routes. The scope set stays at
|
||||||
|
//! seven values — capabilities are the internal check, scopes are the
|
||||||
|
//! external user-facing label.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
|
||||||
|
|
||||||
|
/// Things a caller can attempt to do. Each app-scoped variant carries
|
||||||
|
/// the `AppId` of the resource the action targets — handlers compute
|
||||||
|
/// it from the loaded resource (e.g., `script.app_id`), not from a
|
||||||
|
/// path param.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Capability {
|
||||||
|
/// Create a new app. Owner / admin only.
|
||||||
|
InstanceCreateApp,
|
||||||
|
/// Create / update / delete admin_users rows (other than self
|
||||||
|
/// password change, which is a separate flow). Owner / admin.
|
||||||
|
InstanceManageUsers,
|
||||||
|
/// Mutate instance-wide configuration (sandbox ceiling, etc.).
|
||||||
|
/// Owner only.
|
||||||
|
InstanceManageSettings,
|
||||||
|
/// Read app metadata, scripts, routes. Viewer / editor / app_admin
|
||||||
|
/// (member); implicit for admin / owner.
|
||||||
|
AppRead(AppId),
|
||||||
|
/// Create / update / delete a script in this app.
|
||||||
|
AppWriteScript(AppId),
|
||||||
|
/// Create / update / delete a route in this app.
|
||||||
|
AppWriteRoute(AppId),
|
||||||
|
/// Manage domain claims on this app (add / remove).
|
||||||
|
AppManageDomains(AppId),
|
||||||
|
/// App settings + delete app. app_admin only (or owner via
|
||||||
|
/// implicit grant).
|
||||||
|
AppAdmin(AppId),
|
||||||
|
/// Read execution logs for scripts in this app.
|
||||||
|
AppLogRead(AppId),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Capability {
|
||||||
|
/// Extract the `AppId` for app-scoped capabilities; `None` for
|
||||||
|
/// instance-scoped ones. Used by the app-binding check on API keys.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn app_id(self) -> Option<AppId> {
|
||||||
|
match self {
|
||||||
|
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Self::AppRead(id)
|
||||||
|
| Self::AppWriteScript(id)
|
||||||
|
| Self::AppWriteRoute(id)
|
||||||
|
| Self::AppManageDomains(id)
|
||||||
|
| Self::AppAdmin(id)
|
||||||
|
| Self::AppLogRead(id) => Some(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single scope that authorizes this capability on an API key.
|
||||||
|
/// Strict mapping — a `script:write` key cannot read scripts unless
|
||||||
|
/// it also carries `script:read`. The intent is predictability: a
|
||||||
|
/// key has exactly the scopes it was minted with, no implicit
|
||||||
|
/// upgrades.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn required_scope(self) -> Scope {
|
||||||
|
match self {
|
||||||
|
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||||
|
Scope::InstanceAdmin
|
||||||
|
}
|
||||||
|
Self::AppRead(_) => Scope::ScriptRead,
|
||||||
|
Self::AppWriteScript(_) => Scope::ScriptWrite,
|
||||||
|
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||||
|
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||||
|
Self::AppAdmin(_) => Scope::AppAdmin,
|
||||||
|
Self::AppLogRead(_) => Scope::LogRead,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repo seam for membership lookups. Implemented in the DB-backed
|
||||||
|
/// repos crate (`app_members_repo.rs`); keeping it as a trait here
|
||||||
|
/// means unit tests can stub it.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AuthzRepo: Send + Sync {
|
||||||
|
async fn membership(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repo errors surface here so handlers can map them to 500 without
|
||||||
|
/// dragging sqlx types across the boundary.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AuthzError {
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
Repo(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decision flavor returned by `can` — distinguishes outright denial
|
||||||
|
/// from a partial answer that requires further checks (none today,
|
||||||
|
/// but the shape lets us add audit/explain mode later without rewriting
|
||||||
|
/// every caller).
|
||||||
|
#[must_use = "an authorization decision must be acted on"]
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Decision {
|
||||||
|
Allow,
|
||||||
|
Deny,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Decision {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_allow(self) -> bool {
|
||||||
|
matches!(self, Self::Allow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core authorization check. Walks the three intersection layers in
|
||||||
|
/// order and returns the resulting `Decision`.
|
||||||
|
pub async fn can(
|
||||||
|
repo: &dyn AuthzRepo,
|
||||||
|
principal: &Principal,
|
||||||
|
cap: Capability,
|
||||||
|
) -> Result<Decision, AuthzError> {
|
||||||
|
if !role_grants(repo, principal, cap).await? {
|
||||||
|
return Ok(Decision::Deny);
|
||||||
|
}
|
||||||
|
if !scope_allows(principal, cap) {
|
||||||
|
return Ok(Decision::Deny);
|
||||||
|
}
|
||||||
|
if !binding_allows(principal, cap) {
|
||||||
|
return Ok(Decision::Deny);
|
||||||
|
}
|
||||||
|
Ok(Decision::Allow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: returns `Ok(())` on Allow, `Err(AuthzDenied)` on Deny.
|
||||||
|
/// Handlers call this so the `?` operator threads the 403 through
|
||||||
|
/// naturally.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `AuthzDenied::Denied` when the capability is not granted,
|
||||||
|
/// or `AuthzDenied::Repo` if the underlying membership lookup fails.
|
||||||
|
pub async fn require(
|
||||||
|
repo: &dyn AuthzRepo,
|
||||||
|
principal: &Principal,
|
||||||
|
cap: Capability,
|
||||||
|
) -> Result<(), AuthzDenied> {
|
||||||
|
match can(repo, principal, cap).await {
|
||||||
|
Ok(Decision::Allow) => Ok(()),
|
||||||
|
Ok(Decision::Deny) => Err(AuthzDenied::Denied),
|
||||||
|
Err(e) => Err(AuthzDenied::Repo(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AuthzDenied {
|
||||||
|
#[error("forbidden")]
|
||||||
|
Denied,
|
||||||
|
#[error(transparent)]
|
||||||
|
Repo(#[from] AuthzError),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Layer 1: role-derived grant
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn role_grants(
|
||||||
|
repo: &dyn AuthzRepo,
|
||||||
|
principal: &Principal,
|
||||||
|
cap: Capability,
|
||||||
|
) -> Result<bool, AuthzError> {
|
||||||
|
match principal.instance_role {
|
||||||
|
InstanceRole::Owner => Ok(true),
|
||||||
|
InstanceRole::Admin => Ok(admin_grants(cap)),
|
||||||
|
InstanceRole::Member => member_grants(repo, principal.user_id, cap).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin is implicit `app_admin` on every app (per blueprint §11.6).
|
||||||
|
/// They can create apps, manage users, and take any app-scoped action
|
||||||
|
/// on any app without an explicit `app_members` row — single-human
|
||||||
|
/// installs would otherwise need to add themselves to every new app.
|
||||||
|
/// Only `InstanceManageSettings` (sandbox ceiling, etc.) stays
|
||||||
|
/// owner-only.
|
||||||
|
const fn admin_grants(cap: Capability) -> bool {
|
||||||
|
!matches!(cap, Capability::InstanceManageSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Member has zero instance authority. App authority requires an
|
||||||
|
/// explicit `app_members` row with sufficient `AppRole`.
|
||||||
|
async fn member_grants(
|
||||||
|
repo: &dyn AuthzRepo,
|
||||||
|
user_id: UserId,
|
||||||
|
cap: Capability,
|
||||||
|
) -> Result<bool, AuthzError> {
|
||||||
|
let Some(app_id) = cap.app_id() else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let Some(role) = repo.membership(user_id, app_id).await? else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
Ok(role_satisfies(role, cap))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does the per-app `AppRole` cover the capability? Viewer can read;
|
||||||
|
/// Editor adds script/route/log mutations; AppAdmin adds settings,
|
||||||
|
/// domain claims, and delete. Roles form a strict subset chain, so
|
||||||
|
/// the check is "is this capability in the role's set?".
|
||||||
|
const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||||
|
let in_viewer = matches!(cap, Capability::AppRead(_) | Capability::AppLogRead(_));
|
||||||
|
let in_editor = in_viewer
|
||||||
|
|| matches!(
|
||||||
|
cap,
|
||||||
|
Capability::AppWriteScript(_) | Capability::AppWriteRoute(_)
|
||||||
|
);
|
||||||
|
let in_app_admin = in_editor
|
||||||
|
|| matches!(
|
||||||
|
cap,
|
||||||
|
Capability::AppManageDomains(_) | Capability::AppAdmin(_)
|
||||||
|
);
|
||||||
|
match role {
|
||||||
|
AppRole::Viewer => in_viewer,
|
||||||
|
AppRole::Editor => in_editor,
|
||||||
|
AppRole::AppAdmin => in_app_admin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Layer 2: API-key scope intersection
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn scope_allows(principal: &Principal, cap: Capability) -> bool {
|
||||||
|
match &principal.scopes {
|
||||||
|
None => true, // cookie session — full role authority
|
||||||
|
Some(scopes) => scopes.contains(&cap.required_scope()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Layer 3: API-key app binding
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn binding_allows(principal: &Principal, cap: Capability) -> bool {
|
||||||
|
let Some(bound_app) = principal.app_binding else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
match cap.app_id() {
|
||||||
|
// Instance-scoped capability + bound key → always denied. The
|
||||||
|
// mint handler also rejects this combination upfront, but
|
||||||
|
// defending in depth here means a stale/malformed row can't
|
||||||
|
// escalate.
|
||||||
|
None => false,
|
||||||
|
Some(target_app) => target_app == bound_app,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use picloud_shared::{AdminUserId, AppId};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
/// In-memory `AuthzRepo` so the unit tests don't need a database.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct InMemoryAuthzRepo {
|
||||||
|
memberships: Mutex<HashMap<(UserId, AppId), AppRole>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryAuthzRepo {
|
||||||
|
async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
|
||||||
|
self.memberships.lock().await.insert((user, app), role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AuthzRepo for InMemoryAuthzRepo {
|
||||||
|
async fn membership(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
Ok(self
|
||||||
|
.memberships
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&(user_id, app_id))
|
||||||
|
.copied())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn principal(role: InstanceRole) -> Principal {
|
||||||
|
Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: role,
|
||||||
|
scopes: None,
|
||||||
|
app_binding: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn owner_can_do_everything() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Owner);
|
||||||
|
let app = AppId::new();
|
||||||
|
for cap in [
|
||||||
|
Capability::InstanceCreateApp,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
Capability::InstanceManageSettings,
|
||||||
|
Capability::AppRead(app),
|
||||||
|
Capability::AppWriteScript(app),
|
||||||
|
Capability::AppWriteRoute(app),
|
||||||
|
Capability::AppManageDomains(app),
|
||||||
|
Capability::AppAdmin(app),
|
||||||
|
Capability::AppLogRead(app),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, cap).await.unwrap(),
|
||||||
|
Decision::Allow,
|
||||||
|
"owner denied {cap:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_cannot_manage_instance_settings() {
|
||||||
|
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 p = principal(InstanceRole::Admin);
|
||||||
|
let app = AppId::new();
|
||||||
|
// Instance-scoped allowances.
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
|
||||||
|
Decision::Allow,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::InstanceManageUsers)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
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!(
|
||||||
|
can(&repo, &p, cap).await.unwrap(),
|
||||||
|
Decision::Allow,
|
||||||
|
"admin denied app-scoped capability {cap:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn member_without_row_is_denied_everywhere() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
let app = AppId::new();
|
||||||
|
for cap in [
|
||||||
|
Capability::InstanceCreateApp,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
Capability::InstanceManageSettings,
|
||||||
|
Capability::AppRead(app),
|
||||||
|
Capability::AppWriteScript(app),
|
||||||
|
Capability::AppWriteRoute(app),
|
||||||
|
Capability::AppAdmin(app),
|
||||||
|
Capability::AppLogRead(app),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, cap).await.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
"member granted {cap:?} without a membership row"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn member_with_viewer_role_can_read_but_not_write() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
let app = AppId::new();
|
||||||
|
repo.grant(p.user_id, app, AppRole::Viewer).await;
|
||||||
|
|
||||||
|
assert!(can(&repo, &p, Capability::AppRead(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert!(can(&repo, &p, Capability::AppLogRead(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn member_with_editor_role_can_write_scripts_and_routes() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
let app = AppId::new();
|
||||||
|
repo.grant(p.user_id, app, AppRole::Editor).await;
|
||||||
|
|
||||||
|
assert!(can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert!(can(&repo, &p, Capability::AppWriteRoute(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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]
|
||||||
|
async fn member_with_app_admin_role_can_do_app_admin_actions() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
let app = AppId::new();
|
||||||
|
repo.grant(p.user_id, app, AppRole::AppAdmin).await;
|
||||||
|
|
||||||
|
assert!(can(&repo, &p, Capability::AppAdmin(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert!(can(&repo, &p, Capability::AppManageDomains(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
// Membership in App A does NOT grant access to App B
|
||||||
|
let other_app = AppId::new();
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppAdmin(other_app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scoped_key_intersects_with_role() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let app = AppId::new();
|
||||||
|
// Owner key with only script:read — cannot write
|
||||||
|
let p = Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: InstanceRole::Owner,
|
||||||
|
scopes: Some(vec![Scope::ScriptRead]),
|
||||||
|
app_binding: None,
|
||||||
|
};
|
||||||
|
assert!(can(&repo, &p, Capability::AppRead(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
// Even though the user is owner — the key's scope set is the
|
||||||
|
// hard ceiling.
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bound_key_cannot_escape_its_app() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let bound_app = AppId::new();
|
||||||
|
let other_app = AppId::new();
|
||||||
|
let p = Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: InstanceRole::Owner,
|
||||||
|
scopes: Some(vec![Scope::ScriptWrite]),
|
||||||
|
app_binding: Some(bound_app),
|
||||||
|
};
|
||||||
|
assert!(can(&repo, &p, Capability::AppWriteScript(bound_app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppWriteScript(other_app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bound_key_cannot_do_instance_actions() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let bound_app = AppId::new();
|
||||||
|
let p = Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: InstanceRole::Owner,
|
||||||
|
scopes: Some(vec![Scope::InstanceAdmin]), // mint handler also rejects this combo
|
||||||
|
app_binding: Some(bound_app),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
"bound key with instance scope must still be denied at the binding layer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capability_app_id_extraction() {
|
||||||
|
let app = AppId::new();
|
||||||
|
assert_eq!(Capability::InstanceCreateApp.app_id(), None);
|
||||||
|
assert_eq!(Capability::AppRead(app).app_id(), Some(app));
|
||||||
|
assert_eq!(Capability::AppAdmin(app).app_id(), Some(app));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capability_required_scope_mapping_is_complete() {
|
||||||
|
// Sanity: every variant returns a scope. Compiler-enforced
|
||||||
|
// exhaustiveness lives in the match itself; this test guards
|
||||||
|
// against accidental drift to a default branch.
|
||||||
|
let app = AppId::new();
|
||||||
|
for cap in [
|
||||||
|
Capability::InstanceCreateApp,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
Capability::InstanceManageSettings,
|
||||||
|
Capability::AppRead(app),
|
||||||
|
Capability::AppWriteScript(app),
|
||||||
|
Capability::AppWriteRoute(app),
|
||||||
|
Capability::AppManageDomains(app),
|
||||||
|
Capability::AppAdmin(app),
|
||||||
|
Capability::AppLogRead(app),
|
||||||
|
] {
|
||||||
|
let _ = cap.required_scope(); // does not panic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,23 @@
|
|||||||
//! the same DB for now; once we add caching and per-node ingress, the
|
//! the same DB for now; once we add caching and per-node ingress, the
|
||||||
//! manager will publish change events.
|
//! manager will publish change events.
|
||||||
|
|
||||||
|
pub mod admin_session_repo;
|
||||||
|
pub mod admin_user_repo;
|
||||||
|
pub mod admin_users_api;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
|
pub mod api_key_repo;
|
||||||
|
pub mod api_keys_api;
|
||||||
|
pub mod app_bootstrap;
|
||||||
|
pub mod app_domain_repo;
|
||||||
|
pub mod app_members_api;
|
||||||
|
pub mod app_members_repo;
|
||||||
|
pub mod app_repo;
|
||||||
|
pub mod apps_api;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod auth_api;
|
||||||
|
pub mod auth_bootstrap;
|
||||||
|
pub mod auth_middleware;
|
||||||
|
pub mod authz;
|
||||||
pub mod log_sink;
|
pub mod log_sink;
|
||||||
pub mod migrations;
|
pub mod migrations;
|
||||||
pub mod repo;
|
pub mod repo;
|
||||||
@@ -13,7 +29,40 @@ pub mod route_repo;
|
|||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
|
|
||||||
|
pub use admin_session_repo::{
|
||||||
|
AdminSessionLookup, AdminSessionRepository, AdminSessionRepositoryError,
|
||||||
|
PostgresAdminSessionRepository,
|
||||||
|
};
|
||||||
|
pub use admin_user_repo::{
|
||||||
|
AdminUserCredentials, AdminUserRepository, AdminUserRepositoryError, AdminUserRow,
|
||||||
|
PostgresAdminUserRepository,
|
||||||
|
};
|
||||||
|
pub use admin_users_api::{admins_router, AdminsState};
|
||||||
pub use api::{admin_router, AdminState};
|
pub use api::{admin_router, AdminState};
|
||||||
|
pub use api_key_repo::{
|
||||||
|
ApiKeyRepository, ApiKeyRepositoryError, ApiKeyRow, ApiKeyVerification, NewApiKey,
|
||||||
|
PostgresApiKeyRepository,
|
||||||
|
};
|
||||||
|
pub use api_keys_api::{api_keys_router, ApiKeysState};
|
||||||
|
pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
|
||||||
|
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
|
||||||
|
pub use app_members_api::{app_members_router, AppMembersApiError, AppMembersState};
|
||||||
|
pub use app_members_repo::{
|
||||||
|
AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow,
|
||||||
|
PostgresAppMembersRepository,
|
||||||
|
};
|
||||||
|
pub use app_repo::{resolve_app, AppLookup, AppRepository, PostgresAppRepository};
|
||||||
|
pub use apps_api::{apps_router, AppsState};
|
||||||
|
pub use auth_api::auth_router;
|
||||||
|
pub use auth_bootstrap::{
|
||||||
|
bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError,
|
||||||
|
};
|
||||||
|
#[allow(deprecated)]
|
||||||
|
pub use auth_middleware::{
|
||||||
|
attach_principal_if_present, require_admin, require_authenticated, AuthState, AuthedAdmin,
|
||||||
|
API_KEY_PREFIX, API_KEY_PREFIX_LEN, SESSION_COOKIE,
|
||||||
|
};
|
||||||
|
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
|
||||||
pub use log_sink::PostgresExecutionLogSink;
|
pub use log_sink::PostgresExecutionLogSink;
|
||||||
pub use repo::{
|
pub use repo::{
|
||||||
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
|
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
|
||||||
|
|||||||
@@ -28,15 +28,16 @@ impl ExecutionLogSink for PostgresExecutionLogSink {
|
|||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO execution_logs ( \
|
"INSERT INTO execution_logs ( \
|
||||||
id, script_id, request_id, \
|
id, app_id, script_id, request_id, \
|
||||||
request_path, request_headers, request_body, \
|
request_path, request_headers, request_body, \
|
||||||
response_code, response_body, \
|
response_code, response_body, \
|
||||||
logs, duration_ms, status, created_at \
|
logs, duration_ms, status, created_at \
|
||||||
) VALUES ( \
|
) VALUES ( \
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 \
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 \
|
||||||
)",
|
)",
|
||||||
)
|
)
|
||||||
.bind(log.id)
|
.bind(log.id)
|
||||||
|
.bind(log.app_id.into_inner())
|
||||||
.bind(log.script_id.into_inner())
|
.bind(log.script_id.into_inner())
|
||||||
.bind(log.request_id.into_inner())
|
.bind(log.request_id.into_inner())
|
||||||
.bind(&log.request_path)
|
.bind(&log.request_path)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ use std::collections::BTreeMap;
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
||||||
use picloud_shared::{ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox};
|
use picloud_shared::{
|
||||||
|
AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
|
||||||
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -21,7 +23,18 @@ pub enum ScriptRepositoryError {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ScriptRepository: Send + Sync {
|
pub trait ScriptRepository: Send + Sync {
|
||||||
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError>;
|
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError>;
|
||||||
|
/// Every script across all apps. Mostly for tests and admin
|
||||||
|
/// "global" views; the dashboard reaches scripts via `list_for_app`.
|
||||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||||
|
/// Every script in any app the user is a member of. Drives
|
||||||
|
/// `GET /admin/scripts` for `member` instance-role callers so the
|
||||||
|
/// API never returns scripts they shouldn't see — even before the
|
||||||
|
/// per-handler capability check fires.
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
|
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
|
||||||
async fn update(
|
async fn update(
|
||||||
&self,
|
&self,
|
||||||
@@ -35,6 +48,7 @@ pub trait ScriptRepository: Send + Sync {
|
|||||||
/// constraints; the repo enforces them in the DB regardless.
|
/// constraints; the repo enforces them in the DB regardless.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NewScript {
|
pub struct NewScript {
|
||||||
|
pub app_id: AppId,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub source: String,
|
pub source: String,
|
||||||
@@ -78,7 +92,7 @@ impl PostgresScriptRepository {
|
|||||||
impl ScriptRepository for PostgresScriptRepository {
|
impl ScriptRepository for PostgresScriptRepository {
|
||||||
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||||
let row = sqlx::query_as::<_, ScriptRow>(
|
let row = sqlx::query_as::<_, ScriptRow>(
|
||||||
"SELECT id, name, description, version, source, \
|
"SELECT id, app_id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||||
FROM scripts WHERE id = $1",
|
FROM scripts WHERE id = $1",
|
||||||
)
|
)
|
||||||
@@ -90,7 +104,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
|
|
||||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||||
"SELECT id, name, description, version, source, \
|
"SELECT id, app_id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||||
FROM scripts ORDER BY name",
|
FROM scripts ORDER BY name",
|
||||||
)
|
)
|
||||||
@@ -99,17 +113,48 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
Ok(rows.into_iter().map(Into::into).collect())
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||||
|
"SELECT id, app_id, name, description, version, source, \
|
||||||
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||||
|
FROM scripts WHERE app_id = $1 ORDER BY name",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||||
|
"SELECT s.id, s.app_id, s.name, s.description, s.version, s.source, \
|
||||||
|
s.timeout_seconds, s.memory_limit_mb, s.sandbox, s.created_at, s.updated_at \
|
||||||
|
FROM scripts s \
|
||||||
|
JOIN app_members m ON m.app_id = s.app_id \
|
||||||
|
WHERE m.user_id = $1 \
|
||||||
|
ORDER BY s.name",
|
||||||
|
)
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
||||||
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
||||||
.unwrap_or_else(|_| serde_json::json!({}));
|
.unwrap_or_else(|_| serde_json::json!({}));
|
||||||
let res = sqlx::query_as::<_, ScriptRow>(
|
let res = sqlx::query_as::<_, ScriptRow>(
|
||||||
"INSERT INTO scripts ( \
|
"INSERT INTO scripts ( \
|
||||||
name, description, source, \
|
app_id, name, description, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox \
|
timeout_seconds, memory_limit_mb, sandbox \
|
||||||
) VALUES ($1, $2, $3, COALESCE($4, 30), COALESCE($5, 256), $6) \
|
) VALUES ($1, $2, $3, $4, COALESCE($5, 30), COALESCE($6, 256), $7) \
|
||||||
RETURNING id, name, description, version, source, \
|
RETURNING id, app_id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||||
)
|
)
|
||||||
|
.bind(input.app_id.into_inner())
|
||||||
.bind(&input.name)
|
.bind(&input.name)
|
||||||
.bind(input.description.as_deref())
|
.bind(input.description.as_deref())
|
||||||
.bind(&input.source)
|
.bind(&input.source)
|
||||||
@@ -123,7 +168,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
Ok(row) => Ok(row.into()),
|
Ok(row) => Ok(row.into()),
|
||||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
Err(ScriptRepositoryError::Conflict(format!(
|
Err(ScriptRepositoryError::Conflict(format!(
|
||||||
"a script named {:?} already exists",
|
"a script named {:?} already exists in this app",
|
||||||
input.name
|
input.name
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
@@ -141,12 +186,13 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
|
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
|
||||||
// Sandbox is replaced wholesale when present; per-field merging
|
// Sandbox is replaced wholesale when present; per-field merging
|
||||||
// happens in the API layer (clearer semantics for a "PUT a new
|
// happens in the API layer (clearer semantics for a "PUT a new
|
||||||
// sandbox config" call).
|
// sandbox config" call). app_id is immutable — moving a script
|
||||||
|
// to another app is a copy-and-delete, not an in-place edit.
|
||||||
let sandbox_json = patch
|
let sandbox_json = patch
|
||||||
.sandbox
|
.sandbox
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
||||||
let row = sqlx::query_as::<_, ScriptRow>(
|
let res = sqlx::query_as::<_, ScriptRow>(
|
||||||
"UPDATE scripts SET \
|
"UPDATE scripts SET \
|
||||||
name = COALESCE($2, name), \
|
name = COALESCE($2, name), \
|
||||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||||
@@ -157,7 +203,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
version = version + 1, \
|
version = version + 1, \
|
||||||
updated_at = NOW() \
|
updated_at = NOW() \
|
||||||
WHERE id = $1 \
|
WHERE id = $1 \
|
||||||
RETURNING id, name, description, version, source, \
|
RETURNING id, app_id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
@@ -169,10 +215,18 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
.bind(patch.memory_limit_mb)
|
.bind(patch.memory_limit_mb)
|
||||||
.bind(sandbox_json)
|
.bind(sandbox_json)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?;
|
.await;
|
||||||
|
|
||||||
row.map(Into::into)
|
match res {
|
||||||
.ok_or(ScriptRepositoryError::NotFound(id))
|
Ok(Some(row)) => Ok(row.into()),
|
||||||
|
Ok(None) => Err(ScriptRepositoryError::NotFound(id)),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
Err(ScriptRepositoryError::Conflict(
|
||||||
|
"a script with that name already exists in this app".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError> {
|
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError> {
|
||||||
@@ -191,6 +245,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct ScriptRow {
|
struct ScriptRow {
|
||||||
id: uuid::Uuid,
|
id: uuid::Uuid,
|
||||||
|
app_id: uuid::Uuid,
|
||||||
name: String,
|
name: String,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
version: i32,
|
version: i32,
|
||||||
@@ -211,6 +266,7 @@ impl From<ScriptRow> for Script {
|
|||||||
let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default();
|
let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default();
|
||||||
Self {
|
Self {
|
||||||
id: r.id.into(),
|
id: r.id.into(),
|
||||||
|
app_id: r.app_id.into(),
|
||||||
name: r.name,
|
name: r.name,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
version: r.version,
|
version: r.version,
|
||||||
@@ -284,7 +340,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
|
|||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> {
|
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, ExecutionLogRow>(
|
let rows = sqlx::query_as::<_, ExecutionLogRow>(
|
||||||
"SELECT id, script_id, request_id, \
|
"SELECT id, app_id, script_id, request_id, \
|
||||||
request_path, request_headers, request_body, \
|
request_path, request_headers, request_body, \
|
||||||
response_code, response_body, \
|
response_code, response_body, \
|
||||||
logs, duration_ms, status, created_at \
|
logs, duration_ms, status, created_at \
|
||||||
@@ -306,6 +362,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
|
|||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct ExecutionLogRow {
|
struct ExecutionLogRow {
|
||||||
id: uuid::Uuid,
|
id: uuid::Uuid,
|
||||||
|
app_id: uuid::Uuid,
|
||||||
script_id: uuid::Uuid,
|
script_id: uuid::Uuid,
|
||||||
request_id: uuid::Uuid,
|
request_id: uuid::Uuid,
|
||||||
request_path: Option<String>,
|
request_path: Option<String>,
|
||||||
@@ -331,6 +388,7 @@ impl From<ExecutionLogRow> for ExecutionLog {
|
|||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
app_id: r.app_id.into(),
|
||||||
script_id: r.script_id.into(),
|
script_id: r.script_id.into(),
|
||||||
request_id: RequestId::from(r.request_id),
|
request_id: RequestId::from(r.request_id),
|
||||||
request_path: r.request_path.unwrap_or_default(),
|
request_path: r.request_path.unwrap_or_default(),
|
||||||
|
|||||||
@@ -10,42 +10,56 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::{delete, get, post},
|
routing::{delete, get, post},
|
||||||
Json, Router,
|
Extension, Json, Router,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
|
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
|
||||||
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
|
use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::repo::ScriptRepositoryError;
|
use crate::app_domain_repo::AppDomainRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
|
use crate::repo::{ScriptRepository, ScriptRepositoryError};
|
||||||
use crate::route_repo::{NewRoute, RouteRepository};
|
use crate::route_repo::{NewRoute, RouteRepository};
|
||||||
|
|
||||||
pub struct RouteAdminState<RR> {
|
pub struct RouteAdminState<RR, SR> {
|
||||||
pub routes: Arc<RR>,
|
pub routes: Arc<RR>,
|
||||||
|
/// Used to resolve `script_id → app_id` when creating routes (the
|
||||||
|
/// route inherits the script's app) and to scope conflict checks.
|
||||||
|
pub scripts: Arc<SR>,
|
||||||
|
/// Used to validate the route's host against the parent app's
|
||||||
|
/// declared domain claims.
|
||||||
|
pub domains: Arc<dyn AppDomainRepository>,
|
||||||
pub table: Arc<RouteTable>,
|
pub table: Arc<RouteTable>,
|
||||||
|
/// Capability gate — Phase 3.5.
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<RR> Clone for RouteAdminState<RR> {
|
impl<RR, SR> Clone for RouteAdminState<RR, SR> {
|
||||||
fn clone(&self) -> Self {
|
fn clone(&self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
routes: self.routes.clone(),
|
routes: self.routes.clone(),
|
||||||
|
scripts: self.scripts.clone(),
|
||||||
|
domains: self.domains.clone(),
|
||||||
table: self.table.clone(),
|
table: self.table.clone(),
|
||||||
|
authz: self.authz.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn route_admin_router<RR>(state: RouteAdminState<RR>) -> Router
|
pub fn route_admin_router<RR, SR>(state: RouteAdminState<RR, SR>) -> Router
|
||||||
where
|
where
|
||||||
RR: RouteRepository + 'static,
|
RR: RouteRepository + 'static,
|
||||||
|
SR: ScriptRepository + 'static,
|
||||||
{
|
{
|
||||||
Router::new()
|
Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/scripts/{id}/routes",
|
"/scripts/{id}/routes",
|
||||||
get(list_routes::<RR>).post(create_route::<RR>),
|
get(list_routes::<RR, SR>).post(create_route::<RR, SR>),
|
||||||
)
|
)
|
||||||
.route("/routes/{route_id}", delete(delete_route::<RR>))
|
.route("/routes/{route_id}", delete(delete_route::<RR, SR>))
|
||||||
.route("/routes:check", post(check_route::<RR>))
|
.route("/routes:check", post(check_route::<RR, SR>))
|
||||||
.route("/routes:match", post(match_route::<RR>))
|
.route("/routes:match", post(match_route::<RR, SR>))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +81,10 @@ pub struct CreateRouteRequest {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CheckRouteRequest {
|
pub struct CheckRouteRequest {
|
||||||
|
/// Required: which app's route table this hypothetical route would
|
||||||
|
/// join. Conflict checks are strictly intra-app (cross-app route
|
||||||
|
/// errors would leak tenant info — see blueprint §11.5).
|
||||||
|
pub app_id: AppId,
|
||||||
pub host_kind: HostKind,
|
pub host_kind: HostKind,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub host: String,
|
pub host: String,
|
||||||
@@ -84,6 +102,9 @@ pub struct CheckRouteResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct MatchRouteRequest {
|
pub struct MatchRouteRequest {
|
||||||
|
/// Which app's route table to dispatch against. The dashboard's
|
||||||
|
/// route-preview tester always knows the current app context.
|
||||||
|
pub app_id: AppId,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
#[serde(default = "default_method")]
|
#[serde(default = "default_method")]
|
||||||
pub method: String,
|
pub method: String,
|
||||||
@@ -111,15 +132,28 @@ pub struct MatchedRoute {
|
|||||||
// Handlers
|
// Handlers
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
async fn list_routes<RR: RouteRepository>(
|
async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(script_id): Path<ScriptId>,
|
Path(script_id): Path<ScriptId>,
|
||||||
) -> Result<Json<Vec<Route>>, RouteApiError> {
|
) -> Result<Json<Vec<Route>>, RouteApiError> {
|
||||||
|
let script = state
|
||||||
|
.scripts
|
||||||
|
.get(script_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppRead(script.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(Json(state.routes.list_for_script(script_id).await?))
|
Ok(Json(state.routes.list_for_script(script_id).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_route<RR: RouteRepository>(
|
async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(script_id): Path<ScriptId>,
|
Path(script_id): Path<ScriptId>,
|
||||||
Json(input): Json<CreateRouteRequest>,
|
Json(input): Json<CreateRouteRequest>,
|
||||||
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
|
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
|
||||||
@@ -130,8 +164,28 @@ async fn create_route<RR: RouteRepository>(
|
|||||||
input.host_param_name.as_deref(),
|
input.host_param_name.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Within-kind conflict check against existing routes.
|
// Look up the script's owning app — every route inherits it.
|
||||||
let existing = state.routes.list_all().await?;
|
let script = state
|
||||||
|
.scripts
|
||||||
|
.get(script_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||||
|
let app_id = script.app_id;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteRoute(app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Validate the route's host is consistent with one of the app's
|
||||||
|
// domain claims. `HostKind::Any` is always permitted (catches every
|
||||||
|
// host the app already owns). Specific hosts must match a claim.
|
||||||
|
validate_route_host_against_app(state.domains.as_ref(), app_id, input.host_kind, &input.host)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Within-app conflict check (cross-app is impossible by construction).
|
||||||
|
let existing = state.routes.list_for_app(app_id).await?;
|
||||||
if let Some((conflicting, reason)) = first_conflict(
|
if let Some((conflicting, reason)) = first_conflict(
|
||||||
&existing,
|
&existing,
|
||||||
input.host_kind,
|
input.host_kind,
|
||||||
@@ -149,6 +203,7 @@ async fn create_route<RR: RouteRepository>(
|
|||||||
let created = state
|
let created = state
|
||||||
.routes
|
.routes
|
||||||
.create(NewRoute {
|
.create(NewRoute {
|
||||||
|
app_id,
|
||||||
script_id,
|
script_id,
|
||||||
host_kind: input.host_kind,
|
host_kind: input.host_kind,
|
||||||
host: input.host,
|
host: input.host,
|
||||||
@@ -162,23 +217,47 @@ async fn create_route<RR: RouteRepository>(
|
|||||||
Ok((StatusCode::CREATED, Json(created)))
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_route<RR: RouteRepository>(
|
async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(route_id): Path<Uuid>,
|
Path(route_id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, RouteApiError> {
|
) -> Result<StatusCode, RouteApiError> {
|
||||||
|
// Resolve the route's app before we delete, so the capability
|
||||||
|
// binds to the actual route's app_id (not a path param).
|
||||||
|
let route = state
|
||||||
|
.routes
|
||||||
|
.get(route_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(RouteApiError::RouteNotFound(route_id))?;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteRoute(route.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
state.routes.delete(route_id).await?;
|
state.routes.delete(route_id).await?;
|
||||||
refresh_table(&state).await?;
|
refresh_table(&state).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_route<RR: RouteRepository>(
|
async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Json(input): Json<CheckRouteRequest>,
|
Json(input): Json<CheckRouteRequest>,
|
||||||
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
|
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
|
||||||
|
// routes:check is read-only — peeking at a hypothetical conflict
|
||||||
|
// is bounded by AppRead on the target app (otherwise members
|
||||||
|
// could probe other apps).
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppRead(input.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
|
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
|
||||||
pattern::parse_host(input.host_kind, &input.host, None)?;
|
pattern::parse_host(input.host_kind, &input.host, None)?;
|
||||||
|
|
||||||
let existing = state.routes.list_all().await?;
|
let existing = state.routes.list_for_app(input.app_id).await?;
|
||||||
let conflict = first_conflict(
|
let conflict = first_conflict(
|
||||||
&existing,
|
&existing,
|
||||||
input.host_kind,
|
input.host_kind,
|
||||||
@@ -201,16 +280,25 @@ async fn check_route<RR: RouteRepository>(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn match_route<RR: RouteRepository>(
|
async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Json(input): Json<MatchRouteRequest>,
|
Json(input): Json<MatchRouteRequest>,
|
||||||
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
|
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppRead(input.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let parsed = url::Url::parse(&input.url)
|
let parsed = url::Url::parse(&input.url)
|
||||||
.map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?;
|
.map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?;
|
||||||
let host = parsed.host_str().unwrap_or("").to_string();
|
let host = parsed.host_str().unwrap_or("").to_string();
|
||||||
let path = parsed.path().to_string();
|
let path = parsed.path().to_string();
|
||||||
|
|
||||||
let result = state.table.match_request(&host, &input.method, &path);
|
let result = state
|
||||||
|
.table
|
||||||
|
.match_request_for_app(input.app_id, &host, &input.method, &path);
|
||||||
Ok(Json(MatchRouteResponse {
|
Ok(Json(MatchRouteResponse {
|
||||||
matched: result.map(|r| MatchedRoute {
|
matched: result.map(|r| MatchedRoute {
|
||||||
route_id: r.matched.route_id,
|
route_id: r.matched.route_id,
|
||||||
@@ -263,12 +351,12 @@ fn first_conflict(
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn refresh_table<RR: RouteRepository>(
|
async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
state: &RouteAdminState<RR>,
|
state: &RouteAdminState<RR, SR>,
|
||||||
) -> Result<(), RouteApiError> {
|
) -> Result<(), RouteApiError> {
|
||||||
let rows = state.routes.list_all().await?;
|
let rows = state.routes.list_all().await?;
|
||||||
let compiled = compile_routes(&rows)?;
|
let compiled = compile_routes(&rows)?;
|
||||||
state.table.replace(compiled);
|
state.table.replace_all(compiled);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +365,7 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
|
|||||||
.map(|r| {
|
.map(|r| {
|
||||||
Ok(CompiledRoute {
|
Ok(CompiledRoute {
|
||||||
route_id: r.id,
|
route_id: r.id,
|
||||||
|
app_id: r.app_id,
|
||||||
script_id: r.script_id,
|
script_id: r.script_id,
|
||||||
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
|
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
|
||||||
path: pattern::parse_path(r.path_kind, &r.path)?,
|
path: pattern::parse_path(r.path_kind, &r.path)?,
|
||||||
@@ -286,6 +375,79 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate that a new route's (host_kind, host) is consistent with at
|
||||||
|
/// least one of the parent app's domain claims. `HostKind::Any` is
|
||||||
|
/// always permitted — it catches every host the app already owns.
|
||||||
|
async fn validate_route_host_against_app(
|
||||||
|
domains: &dyn AppDomainRepository,
|
||||||
|
app_id: AppId,
|
||||||
|
host_kind: HostKind,
|
||||||
|
host: &str,
|
||||||
|
) -> Result<(), RouteApiError> {
|
||||||
|
if matches!(host_kind, HostKind::Any) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let claims = domains.list_for_app(app_id).await?;
|
||||||
|
if claims.is_empty() {
|
||||||
|
return Err(RouteApiError::HostNotClaimed {
|
||||||
|
host: host.to_string(),
|
||||||
|
available_claims: vec![],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let host_lower = host.to_ascii_lowercase();
|
||||||
|
for claim in &claims {
|
||||||
|
let claim_lower = claim.pattern.to_ascii_lowercase();
|
||||||
|
match (host_kind, claim.shape) {
|
||||||
|
// Strict route under exact claim: must match exactly.
|
||||||
|
(HostKind::Strict, picloud_shared::DomainShape::Exact) => {
|
||||||
|
if host_lower == claim_lower {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strict route under wildcard/parameterized: must end with
|
||||||
|
// ".<suffix>" where the claim's suffix is the part after
|
||||||
|
// `*.` or `{...}.`.
|
||||||
|
(
|
||||||
|
HostKind::Strict,
|
||||||
|
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
|
||||||
|
) => {
|
||||||
|
let suffix = claim_lower
|
||||||
|
.split_once('.')
|
||||||
|
.map(|(_, s)| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let needle = format!(".{suffix}");
|
||||||
|
if !suffix.is_empty() && host_lower.ends_with(&needle) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wildcard route: must match a wildcard or parameterized
|
||||||
|
// claim with identical suffix.
|
||||||
|
(
|
||||||
|
HostKind::Wildcard,
|
||||||
|
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
|
||||||
|
) => {
|
||||||
|
let claim_suffix = claim_lower
|
||||||
|
.split_once('.')
|
||||||
|
.map(|(_, s)| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if claim_suffix == host_lower {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wildcard route under exact claim: not allowed (would
|
||||||
|
// shadow other apps' subdomains the operator didn't claim).
|
||||||
|
(HostKind::Wildcard, picloud_shared::DomainShape::Exact) => {}
|
||||||
|
(HostKind::Any, _) => unreachable!("handled above"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(RouteApiError::HostNotClaimed {
|
||||||
|
host: host.to_string(),
|
||||||
|
available_claims: claims.into_iter().map(|c| c.pattern).collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Errors
|
// Errors
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -304,10 +466,37 @@ pub enum RouteApiError {
|
|||||||
#[error("bad request: {0}")]
|
#[error("bad request: {0}")]
|
||||||
BadRequest(String),
|
BadRequest(String),
|
||||||
|
|
||||||
|
#[error("script not found: {0}")]
|
||||||
|
ScriptNotFound(ScriptId),
|
||||||
|
|
||||||
|
#[error("route not found: {0}")]
|
||||||
|
RouteNotFound(Uuid),
|
||||||
|
|
||||||
|
#[error("host {host:?} is not claimed by this app")]
|
||||||
|
HostNotClaimed {
|
||||||
|
host: String,
|
||||||
|
available_claims: Vec<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
#[error("repository error: {0}")]
|
#[error("repository error: {0}")]
|
||||||
Repo(#[from] ScriptRepositoryError),
|
Repo(#[from] ScriptRepositoryError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for RouteApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IntoResponse for RouteApiError {
|
impl IntoResponse for RouteApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, body) = match &self {
|
let (status, body) = match &self {
|
||||||
@@ -326,10 +515,34 @@ impl IntoResponse for RouteApiError {
|
|||||||
StatusCode::UNPROCESSABLE_ENTITY,
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
serde_json::json!({ "error": self.to_string() }),
|
serde_json::json!({ "error": self.to_string() }),
|
||||||
),
|
),
|
||||||
Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
Self::ScriptNotFound(_)
|
||||||
|
| Self::RouteNotFound(_)
|
||||||
|
| Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
serde_json::json!({ "error": self.to_string() }),
|
serde_json::json!({ "error": self.to_string() }),
|
||||||
),
|
),
|
||||||
|
Self::Forbidden => (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
serde_json::json!({ "error": self.to_string() }),
|
||||||
|
),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "route authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
serde_json::json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::HostNotClaimed {
|
||||||
|
host,
|
||||||
|
available_claims,
|
||||||
|
} => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
serde_json::json!({
|
||||||
|
"error": self.to_string(),
|
||||||
|
"host": host,
|
||||||
|
"available_claims": available_claims,
|
||||||
|
}),
|
||||||
|
),
|
||||||
Self::Repo(ScriptRepositoryError::Conflict(_)) => (
|
Self::Repo(ScriptRepositoryError::Conflict(_)) => (
|
||||||
StatusCode::CONFLICT,
|
StatusCode::CONFLICT,
|
||||||
serde_json::json!({ "error": self.to_string() }),
|
serde_json::json!({ "error": self.to_string() }),
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
//! CRUD over the `routes` table.
|
//! CRUD over the `routes` table.
|
||||||
//!
|
//!
|
||||||
//! The orchestrator's `RouteTable` is repopulated from this repo after
|
//! The orchestrator's `AppRouteTables` is repopulated from this repo
|
||||||
//! every write — see the route_admin module for the binding.
|
//! after every write — see the route_admin module for the binding.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
|
use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ use crate::repo::ScriptRepositoryError;
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NewRoute {
|
pub struct NewRoute {
|
||||||
|
pub app_id: AppId,
|
||||||
pub script_id: ScriptId,
|
pub script_id: ScriptId,
|
||||||
pub host_kind: HostKind,
|
pub host_kind: HostKind,
|
||||||
pub host: String,
|
pub host: String,
|
||||||
@@ -24,12 +25,25 @@ pub struct NewRoute {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait RouteRepository: Send + Sync {
|
pub trait RouteRepository: Send + Sync {
|
||||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
|
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||||
|
/// Single-row lookup. Used by `DELETE /api/v1/admin/routes/{id}` so
|
||||||
|
/// the capability check binds to the route's actual `app_id`
|
||||||
|
/// (not a path param).
|
||||||
|
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError>;
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||||
async fn list_for_script(
|
async fn list_for_script(
|
||||||
&self,
|
&self,
|
||||||
script_id: ScriptId,
|
script_id: ScriptId,
|
||||||
) -> Result<Vec<Route>, ScriptRepositoryError>;
|
) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError>;
|
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError>;
|
||||||
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError>;
|
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError>;
|
||||||
|
/// Count routes whose host_kind/host pair matches a pattern in
|
||||||
|
/// `app_id`. Used by the domain-claim delete guard.
|
||||||
|
async fn count_for_app_host(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
host_kind: HostKind,
|
||||||
|
host: &str,
|
||||||
|
) -> Result<i64, ScriptRepositoryError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PostgresRouteRepository {
|
pub struct PostgresRouteRepository {
|
||||||
@@ -47,7 +61,7 @@ impl PostgresRouteRepository {
|
|||||||
impl RouteRepository for PostgresRouteRepository {
|
impl RouteRepository for PostgresRouteRepository {
|
||||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
|
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, RouteRow>(
|
let rows = sqlx::query_as::<_, RouteRow>(
|
||||||
"SELECT id, script_id, host_kind, host, host_param_name, \
|
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method, created_at \
|
path_kind, path, method, created_at \
|
||||||
FROM routes ORDER BY created_at",
|
FROM routes ORDER BY created_at",
|
||||||
)
|
)
|
||||||
@@ -56,12 +70,36 @@ impl RouteRepository for PostgresRouteRepository {
|
|||||||
Ok(rows.into_iter().map(Into::into).collect())
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, RouteRow>(
|
||||||
|
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
|
path_kind, path, method, created_at \
|
||||||
|
FROM routes WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(route_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, RouteRow>(
|
||||||
|
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
|
path_kind, path, method, created_at \
|
||||||
|
FROM routes WHERE app_id = $1 ORDER BY created_at",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_for_script(
|
async fn list_for_script(
|
||||||
&self,
|
&self,
|
||||||
script_id: ScriptId,
|
script_id: ScriptId,
|
||||||
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, RouteRow>(
|
let rows = sqlx::query_as::<_, RouteRow>(
|
||||||
"SELECT id, script_id, host_kind, host, host_param_name, \
|
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method, created_at \
|
path_kind, path, method, created_at \
|
||||||
FROM routes WHERE script_id = $1 ORDER BY created_at",
|
FROM routes WHERE script_id = $1 ORDER BY created_at",
|
||||||
)
|
)
|
||||||
@@ -74,12 +112,13 @@ impl RouteRepository for PostgresRouteRepository {
|
|||||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
|
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
|
||||||
let res = sqlx::query_as::<_, RouteRow>(
|
let res = sqlx::query_as::<_, RouteRow>(
|
||||||
"INSERT INTO routes ( \
|
"INSERT INTO routes ( \
|
||||||
script_id, host_kind, host, host_param_name, \
|
app_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method \
|
path_kind, path, method \
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
|
||||||
RETURNING id, script_id, host_kind, host, host_param_name, \
|
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method, created_at",
|
path_kind, path, method, created_at",
|
||||||
)
|
)
|
||||||
|
.bind(input.app_id.into_inner())
|
||||||
.bind(input.script_id.into_inner())
|
.bind(input.script_id.into_inner())
|
||||||
.bind(host_kind_str(input.host_kind))
|
.bind(host_kind_str(input.host_kind))
|
||||||
.bind(&input.host)
|
.bind(&input.host)
|
||||||
@@ -112,6 +151,24 @@ impl RouteRepository for PostgresRouteRepository {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn count_for_app_host(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
host_kind: HostKind,
|
||||||
|
host: &str,
|
||||||
|
) -> Result<i64, ScriptRepositoryError> {
|
||||||
|
let count: (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*) FROM routes \
|
||||||
|
WHERE app_id = $1 AND host_kind = $2 AND host = $3",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(host_kind_str(host_kind))
|
||||||
|
.bind(host)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count.0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn host_kind_str(k: HostKind) -> &'static str {
|
const fn host_kind_str(k: HostKind) -> &'static str {
|
||||||
@@ -133,6 +190,7 @@ const fn path_kind_str(k: PathKind) -> &'static str {
|
|||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct RouteRow {
|
struct RouteRow {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
|
app_id: Uuid,
|
||||||
script_id: Uuid,
|
script_id: Uuid,
|
||||||
host_kind: String,
|
host_kind: String,
|
||||||
host: String,
|
host: String,
|
||||||
@@ -147,6 +205,7 @@ impl From<RouteRow> for Route {
|
|||||||
fn from(r: RouteRow) -> Self {
|
fn from(r: RouteRow) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
app_id: r.app_id.into(),
|
||||||
script_id: r.script_id.into(),
|
script_id: r.script_id.into(),
|
||||||
host_kind: match r.host_kind.as_str() {
|
host_kind: match r.host_kind.as_str() {
|
||||||
"strict" => HostKind::Strict,
|
"strict" => HostKind::Strict,
|
||||||
|
|||||||
@@ -3,6 +3,64 @@
|
|||||||
|
|
||||||
## tables
|
## tables
|
||||||
|
|
||||||
|
table: admin_sessions
|
||||||
|
token_hash: text NOT NULL
|
||||||
|
user_id: uuid NOT NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
expires_at: timestamp with time zone NOT NULL
|
||||||
|
last_used_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: admin_users
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
username: text NOT NULL
|
||||||
|
password_hash: text NOT NULL
|
||||||
|
is_active: boolean NOT NULL default=true
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
last_login_at: timestamp with time zone NULL
|
||||||
|
instance_role: text NOT NULL default='owner'::text
|
||||||
|
email: text NULL
|
||||||
|
mfa_secret: text NULL
|
||||||
|
|
||||||
|
table: api_keys
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
user_id: uuid NOT NULL
|
||||||
|
hash: text NOT NULL
|
||||||
|
prefix: text NOT NULL
|
||||||
|
name: text NOT NULL
|
||||||
|
scopes: ARRAY NOT NULL
|
||||||
|
app_id: uuid NULL
|
||||||
|
expires_at: timestamp with time zone NULL
|
||||||
|
last_used_at: timestamp with time zone NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: app_domains
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
pattern: text NOT NULL
|
||||||
|
shape: text NOT NULL
|
||||||
|
shape_key: text NOT NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: app_members
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
user_id: uuid NOT NULL
|
||||||
|
role: text NOT NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: app_slug_history
|
||||||
|
slug: text NOT NULL
|
||||||
|
current_app_id: uuid NOT NULL
|
||||||
|
retired_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: apps
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
slug: text NOT NULL
|
||||||
|
name: text NOT NULL
|
||||||
|
description: text NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
table: execution_logs
|
table: execution_logs
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
script_id: uuid NOT NULL
|
script_id: uuid NOT NULL
|
||||||
@@ -16,6 +74,7 @@ table: execution_logs
|
|||||||
duration_ms: integer NOT NULL default=0
|
duration_ms: integer NOT NULL default=0
|
||||||
status: text NOT NULL
|
status: text NOT NULL
|
||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
|
||||||
table: routes
|
table: routes
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
@@ -27,6 +86,7 @@ table: routes
|
|||||||
path: text NOT NULL
|
path: text NOT NULL
|
||||||
method: text NULL
|
method: text NULL
|
||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
|
||||||
table: scripts
|
table: scripts
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
@@ -39,42 +99,119 @@ table: scripts
|
|||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
updated_at: timestamp with time zone NOT NULL default=now()
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
sandbox: jsonb NOT NULL default='{}'::jsonb
|
sandbox: jsonb NOT NULL default='{}'::jsonb
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
|
||||||
## indexes
|
## indexes
|
||||||
|
|
||||||
|
indexes on admin_sessions:
|
||||||
|
admin_sessions_expiry_idx: public.admin_sessions USING btree (expires_at)
|
||||||
|
admin_sessions_pkey: public.admin_sessions USING btree (token_hash)
|
||||||
|
admin_sessions_user_idx: public.admin_sessions USING btree (user_id)
|
||||||
|
|
||||||
|
indexes on admin_users:
|
||||||
|
admin_users_email_key: public.admin_users USING btree (email)
|
||||||
|
admin_users_instance_role_idx: public.admin_users USING btree (instance_role)
|
||||||
|
admin_users_pkey: public.admin_users USING btree (id)
|
||||||
|
admin_users_username_key: public.admin_users USING btree (username)
|
||||||
|
|
||||||
|
indexes on api_keys:
|
||||||
|
api_keys_pkey: public.api_keys USING btree (id)
|
||||||
|
api_keys_prefix_idx: public.api_keys USING btree (prefix)
|
||||||
|
api_keys_user_id_idx: public.api_keys USING btree (user_id)
|
||||||
|
|
||||||
|
indexes on app_domains:
|
||||||
|
app_domains_app_id_idx: public.app_domains USING btree (app_id)
|
||||||
|
app_domains_pkey: public.app_domains USING btree (id)
|
||||||
|
app_domains_shape_key_key: public.app_domains USING btree (shape_key)
|
||||||
|
|
||||||
|
indexes on app_members:
|
||||||
|
app_members_pkey: public.app_members USING btree (app_id, user_id)
|
||||||
|
app_members_user_id_idx: public.app_members USING btree (user_id)
|
||||||
|
|
||||||
|
indexes on app_slug_history:
|
||||||
|
app_slug_history_pkey: public.app_slug_history USING btree (slug)
|
||||||
|
|
||||||
|
indexes on apps:
|
||||||
|
apps_pkey: public.apps USING btree (id)
|
||||||
|
apps_slug_key: public.apps USING btree (slug)
|
||||||
|
|
||||||
indexes on execution_logs:
|
indexes on execution_logs:
|
||||||
|
execution_logs_app_id_created_at_idx: public.execution_logs USING btree (app_id, created_at DESC)
|
||||||
execution_logs_pkey: public.execution_logs USING btree (id)
|
execution_logs_pkey: public.execution_logs USING btree (id)
|
||||||
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
|
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
|
||||||
|
|
||||||
indexes on routes:
|
indexes on routes:
|
||||||
|
routes_app_id_idx: public.routes USING btree (app_id)
|
||||||
routes_lookup_idx: public.routes USING btree (host_kind, host)
|
routes_lookup_idx: public.routes USING btree (host_kind, host)
|
||||||
routes_pkey: public.routes USING btree (id)
|
routes_pkey: public.routes USING btree (id)
|
||||||
routes_script_id_idx: public.routes USING btree (script_id)
|
routes_script_id_idx: public.routes USING btree (script_id)
|
||||||
routes_unique_binding_idx: public.routes USING btree (host_kind, host, path_kind, path, COALESCE(method, ''::text))
|
routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text))
|
||||||
|
|
||||||
indexes on scripts:
|
indexes on scripts:
|
||||||
scripts_name_uidx: public.scripts USING btree (lower(name))
|
scripts_app_id_idx: public.scripts USING btree (app_id)
|
||||||
|
scripts_name_uidx: public.scripts USING btree (app_id, lower(name))
|
||||||
scripts_pkey: public.scripts USING btree (id)
|
scripts_pkey: public.scripts USING btree (id)
|
||||||
|
|
||||||
## constraints
|
## constraints
|
||||||
|
|
||||||
|
constraints on admin_sessions:
|
||||||
|
[FOREIGN KEY] admin_sessions_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] admin_sessions_pkey: PRIMARY KEY (token_hash)
|
||||||
|
|
||||||
|
constraints on admin_users:
|
||||||
|
[CHECK] admin_users_instance_role_check: CHECK ((instance_role = ANY (ARRAY['owner'::text, 'admin'::text, 'member'::text])))
|
||||||
|
[PRIMARY KEY] admin_users_pkey: PRIMARY KEY (id)
|
||||||
|
[UNIQUE] admin_users_email_key: UNIQUE (email)
|
||||||
|
[UNIQUE] admin_users_username_key: UNIQUE (username)
|
||||||
|
|
||||||
|
constraints on api_keys:
|
||||||
|
[FOREIGN KEY] api_keys_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[FOREIGN KEY] api_keys_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] api_keys_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
|
constraints on app_domains:
|
||||||
|
[CHECK] app_domains_shape_check: CHECK ((shape = ANY (ARRAY['exact'::text, 'wildcard'::text, 'parameterized'::text])))
|
||||||
|
[FOREIGN KEY] app_domains_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] app_domains_pkey: PRIMARY KEY (id)
|
||||||
|
[UNIQUE] app_domains_shape_key_key: UNIQUE (shape_key)
|
||||||
|
|
||||||
|
constraints on app_members:
|
||||||
|
[CHECK] app_members_role_check: CHECK ((role = ANY (ARRAY['app_admin'::text, 'editor'::text, 'viewer'::text])))
|
||||||
|
[FOREIGN KEY] app_members_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[FOREIGN KEY] app_members_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] app_members_pkey: PRIMARY KEY (app_id, user_id)
|
||||||
|
|
||||||
|
constraints on app_slug_history:
|
||||||
|
[FOREIGN KEY] app_slug_history_current_app_id_fkey: FOREIGN KEY (current_app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] app_slug_history_pkey: PRIMARY KEY (slug)
|
||||||
|
|
||||||
|
constraints on apps:
|
||||||
|
[PRIMARY KEY] apps_pkey: PRIMARY KEY (id)
|
||||||
|
[UNIQUE] apps_slug_key: UNIQUE (slug)
|
||||||
|
|
||||||
constraints on execution_logs:
|
constraints on execution_logs:
|
||||||
[CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text])))
|
[CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text])))
|
||||||
|
[FOREIGN KEY] execution_logs_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
constraints on routes:
|
constraints on routes:
|
||||||
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
||||||
[CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
|
[CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
|
||||||
|
[FOREIGN KEY] routes_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
[FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
[FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
constraints on scripts:
|
constraints on scripts:
|
||||||
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
|
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
|
||||||
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
|
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
|
||||||
|
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
|
||||||
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
## applied migrations
|
## applied migrations
|
||||||
0001: init
|
0001: init
|
||||||
0002: sandbox
|
0002: sandbox
|
||||||
0003: routes
|
0003: routes
|
||||||
|
0004: admin auth
|
||||||
|
0005: apps
|
||||||
|
0006: users authz
|
||||||
|
|||||||
@@ -12,27 +12,32 @@ 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::{
|
||||||
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;
|
||||||
|
|
||||||
use crate::client::ExecutorClient;
|
use crate::client::ExecutorClient;
|
||||||
use crate::resolver::{ResolverError, ScriptResolver};
|
use crate::resolver::{ResolverError, ScriptResolver};
|
||||||
use crate::routing::RouteTable;
|
use crate::routing::{AppDomainTable, RouteTable};
|
||||||
|
|
||||||
/// State shared by data-plane handlers.
|
/// State shared by data-plane handlers.
|
||||||
pub struct DataPlaneState<E, R> {
|
pub struct DataPlaneState<E, R> {
|
||||||
pub executor: Arc<E>,
|
pub executor: Arc<E>,
|
||||||
pub resolver: Arc<R>,
|
pub resolver: Arc<R>,
|
||||||
pub log_sink: Arc<dyn ExecutionLogSink>,
|
pub log_sink: Arc<dyn ExecutionLogSink>,
|
||||||
/// Routing table for user-defined paths. Shared with the manager
|
/// Host → app_id resolver. Run before `routes` to filter to the
|
||||||
/// (admin router writes; this side reads).
|
/// owning app's slice. Shared with the manager (writes invalidate
|
||||||
|
/// the cache by replacing the table).
|
||||||
|
pub app_domains: Arc<AppDomainTable>,
|
||||||
|
/// Routing table for user-defined paths, partitioned per app.
|
||||||
|
/// Shared with the manager (admin router writes; this side reads).
|
||||||
pub routes: Arc<RouteTable>,
|
pub routes: Arc<RouteTable>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +47,7 @@ impl<E, R> Clone for DataPlaneState<E, R> {
|
|||||||
executor: self.executor.clone(),
|
executor: self.executor.clone(),
|
||||||
resolver: self.resolver.clone(),
|
resolver: self.resolver.clone(),
|
||||||
log_sink: self.log_sink.clone(),
|
log_sink: self.log_sink.clone(),
|
||||||
|
app_domains: self.app_domains.clone(),
|
||||||
routes: self.routes.clone(),
|
routes: self.routes.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,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,
|
||||||
@@ -62,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,
|
||||||
@@ -79,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>
|
||||||
@@ -92,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();
|
||||||
@@ -109,6 +125,7 @@ where
|
|||||||
// audit-visible platform — but a sink failure must not mask the
|
// audit-visible platform — but a sink failure must not mask the
|
||||||
// user-facing result, so we only log a warning if it fails.
|
// user-facing result, so we only log a warning if it fails.
|
||||||
let log = build_execution_log(
|
let log = build_execution_log(
|
||||||
|
script.app_id,
|
||||||
id,
|
id,
|
||||||
request_id,
|
request_id,
|
||||||
request_path,
|
request_path,
|
||||||
@@ -127,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
|
||||||
@@ -145,7 +163,23 @@ where
|
|||||||
.to_string();
|
.to_string();
|
||||||
let headers = request.headers().clone();
|
let headers = request.headers().clone();
|
||||||
|
|
||||||
let Some(matched) = state.routes.match_request(&host, &method, &path) else {
|
// Two-phase dispatch (blueprint §11.5): first resolve Host → app_id,
|
||||||
|
// then run the existing matcher on that app's slice. No app claims
|
||||||
|
// this host → flat 404; the path doesn't get the chance to fire.
|
||||||
|
let Some(app_id) = state.app_domains.resolve_app(&host) else {
|
||||||
|
return Ok((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": format!("no app claims host {host:?}")
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(matched) = state
|
||||||
|
.routes
|
||||||
|
.match_request_for_app(app_id, &host, &method, &path)
|
||||||
|
else {
|
||||||
return Ok((
|
return Ok((
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
@@ -173,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;
|
||||||
@@ -191,6 +227,7 @@ where
|
|||||||
let finished = Utc::now();
|
let finished = Utc::now();
|
||||||
|
|
||||||
let log = build_execution_log(
|
let log = build_execution_log(
|
||||||
|
script.app_id,
|
||||||
matched.matched.script_id,
|
matched.matched.script_id,
|
||||||
request_id,
|
request_id,
|
||||||
request_path,
|
request_path,
|
||||||
@@ -241,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 {
|
||||||
@@ -256,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(),
|
||||||
@@ -270,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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +339,7 @@ fn exec_response_to_http(resp: ExecResponse) -> Response {
|
|||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn build_execution_log(
|
fn build_execution_log(
|
||||||
|
app_id: AppId,
|
||||||
script_id: ScriptId,
|
script_id: ScriptId,
|
||||||
request_id: RequestId,
|
request_id: RequestId,
|
||||||
request_path: String,
|
request_path: String,
|
||||||
@@ -336,6 +384,7 @@ fn build_execution_log(
|
|||||||
|
|
||||||
ExecutionLog {
|
ExecutionLog {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
|
app_id,
|
||||||
script_id,
|
script_id,
|
||||||
request_id,
|
request_id,
|
||||||
request_path,
|
request_path,
|
||||||
@@ -371,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()),
|
||||||
@@ -391,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};
|
||||||
|
|||||||
165
crates/orchestrator-core/src/routing/app_domains.rs
Normal file
165
crates/orchestrator-core/src/routing/app_domains.rs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
//! Host → app_id resolver. The first phase of the orchestrator's
|
||||||
|
//! two-phase dispatch (the second phase is the per-app route matcher
|
||||||
|
//! in `routing::table::RouteTable`).
|
||||||
|
//!
|
||||||
|
//! Cached in memory; the manager rebuilds the table after each
|
||||||
|
//! domain-claim CRUD operation (same pattern as `RouteTable`).
|
||||||
|
|
||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
use picloud_shared::AppId;
|
||||||
|
|
||||||
|
use super::pattern::{HostPattern, HostSpecificity};
|
||||||
|
|
||||||
|
/// A parsed domain claim ready for runtime matching.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CompiledAppDomain {
|
||||||
|
pub app_id: AppId,
|
||||||
|
pub pattern: HostPattern,
|
||||||
|
pub shape_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct AppDomainTable {
|
||||||
|
inner: RwLock<Vec<CompiledAppDomain>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppDomainTable {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic full replacement; called at startup and after every
|
||||||
|
/// domain CRUD operation.
|
||||||
|
pub fn replace(&self, domains: Vec<CompiledAppDomain>) {
|
||||||
|
let mut guard = self.inner.write().expect("app domain table poisoned");
|
||||||
|
*guard = domains;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a request's `Host` header to an `AppId`. Most-specific
|
||||||
|
/// claim wins: exact > longest wildcard > shorter wildcard. Returns
|
||||||
|
/// `None` when no claim covers `host` (orchestrator should 404).
|
||||||
|
#[must_use]
|
||||||
|
pub fn resolve_app(&self, host: &str) -> Option<AppId> {
|
||||||
|
let host = strip_port(host).to_ascii_lowercase();
|
||||||
|
let guard = self.inner.read().expect("app domain table poisoned");
|
||||||
|
let mut best: Option<(HostSpecificity, AppId)> = None;
|
||||||
|
for claim in guard.iter() {
|
||||||
|
if let Some(()) = host_matches(&claim.pattern, &host) {
|
||||||
|
let s = claim.pattern.specificity();
|
||||||
|
if best.is_none_or(|(prev, _)| s > prev) {
|
||||||
|
best = Some((s, claim.app_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(_, app_id)| app_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn snapshot(&self) -> Vec<CompiledAppDomain> {
|
||||||
|
self.inner
|
||||||
|
.read()
|
||||||
|
.expect("app domain table poisoned")
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_port(host: &str) -> &str {
|
||||||
|
host.split(':').next().unwrap_or(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_matches(pattern: &HostPattern, host: &str) -> Option<()> {
|
||||||
|
match pattern {
|
||||||
|
HostPattern::Any => Some(()),
|
||||||
|
HostPattern::Strict(s) => {
|
||||||
|
if s.eq_ignore_ascii_case(host) {
|
||||||
|
Some(())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HostPattern::Wildcard { suffix, .. } => {
|
||||||
|
let dotted = format!(".{}", suffix.to_ascii_lowercase());
|
||||||
|
host.strip_suffix(&dotted)
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::routing::pattern::parse_app_domain;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn id() -> AppId {
|
||||||
|
AppId::from(Uuid::new_v4())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile(app_id: AppId, raw: &str) -> CompiledAppDomain {
|
||||||
|
let d = parse_app_domain(raw).unwrap();
|
||||||
|
CompiledAppDomain {
|
||||||
|
app_id,
|
||||||
|
pattern: d.pattern,
|
||||||
|
shape_key: d.shape_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_exact_over_wildcard() {
|
||||||
|
let app_a = id();
|
||||||
|
let app_b = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![
|
||||||
|
compile(app_a, "foo.example.com"),
|
||||||
|
compile(app_b, "*.example.com"),
|
||||||
|
]);
|
||||||
|
assert_eq!(table.resolve_app("foo.example.com"), Some(app_a));
|
||||||
|
assert_eq!(table.resolve_app("bar.example.com"), Some(app_b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn longer_wildcard_beats_shorter() {
|
||||||
|
let inner = id();
|
||||||
|
let outer = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![
|
||||||
|
compile(inner, "*.api.example.com"),
|
||||||
|
compile(outer, "*.example.com"),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
table.resolve_app("v1.api.example.com"),
|
||||||
|
Some(inner),
|
||||||
|
"more-specific wildcard should win"
|
||||||
|
);
|
||||||
|
assert_eq!(table.resolve_app("v1.example.com"), Some(outer));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parameterized_resolves_like_wildcard() {
|
||||||
|
let app = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![compile(app, "{tenant}.example.com")]);
|
||||||
|
assert_eq!(table.resolve_app("acme.example.com"), Some(app));
|
||||||
|
assert!(table.resolve_app("example.com").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_none_when_no_claim() {
|
||||||
|
let app = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![compile(app, "foo.example.com")]);
|
||||||
|
assert!(table.resolve_app("nope.com").is_none());
|
||||||
|
assert!(table.resolve_app("").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_port() {
|
||||||
|
let app = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![compile(app, "localhost")]);
|
||||||
|
assert_eq!(table.resolve_app("localhost:18080"), Some(app));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,10 +40,13 @@ pub struct Matched {
|
|||||||
pub script_id: picloud_shared::ScriptId,
|
pub script_id: picloud_shared::ScriptId,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single route ready for matching.
|
/// A single route ready for matching. `app_id` is carried so the
|
||||||
|
/// caller (the orchestrator's `AppRouteTables`) can partition the
|
||||||
|
/// table; the matcher itself doesn't read it.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CompiledRoute {
|
pub struct CompiledRoute {
|
||||||
pub route_id: uuid::Uuid,
|
pub route_id: uuid::Uuid,
|
||||||
|
pub app_id: picloud_shared::AppId,
|
||||||
pub script_id: picloud_shared::ScriptId,
|
pub script_id: picloud_shared::ScriptId,
|
||||||
pub host: HostPattern,
|
pub host: HostPattern,
|
||||||
pub path: PathPattern,
|
pub path: PathPattern,
|
||||||
@@ -298,12 +301,13 @@ fn match_param(segs: &[PathSegment], request_path: &str) -> Option<BTreeMap<Stri
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::super::pattern::parse_path;
|
use super::super::pattern::parse_path;
|
||||||
use super::*;
|
use super::*;
|
||||||
use picloud_shared::{PathKind, ScriptId};
|
use picloud_shared::{AppId, PathKind, ScriptId};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn route(host: HostPattern, path_kind: PathKind, raw: &str) -> CompiledRoute {
|
fn route(host: HostPattern, path_kind: PathKind, raw: &str) -> CompiledRoute {
|
||||||
CompiledRoute {
|
CompiledRoute {
|
||||||
route_id: Uuid::new_v4(),
|
route_id: Uuid::new_v4(),
|
||||||
|
app_id: AppId::new(),
|
||||||
script_id: ScriptId::new(),
|
script_id: ScriptId::new(),
|
||||||
host,
|
host,
|
||||||
path: parse_path(path_kind, raw).unwrap(),
|
path: parse_path(path_kind, raw).unwrap(),
|
||||||
|
|||||||
@@ -17,12 +17,16 @@
|
|||||||
//! * **Host dispatch** — `strict > wildcard > any`; longest matching
|
//! * **Host dispatch** — `strict > wildcard > any`; longest matching
|
||||||
//! wildcard suffix breaks ties between wildcards.
|
//! wildcard suffix breaks ties between wildcards.
|
||||||
|
|
||||||
|
pub mod app_domains;
|
||||||
pub mod conflict;
|
pub mod conflict;
|
||||||
pub mod matcher;
|
pub mod matcher;
|
||||||
pub mod pattern;
|
pub mod pattern;
|
||||||
pub mod table;
|
pub mod table;
|
||||||
|
|
||||||
|
pub use app_domains::{AppDomainTable, CompiledAppDomain};
|
||||||
pub use conflict::{conflicts, ConflictReason};
|
pub use conflict::{conflicts, ConflictReason};
|
||||||
pub use matcher::{MatchResult, Matched};
|
pub use matcher::{MatchResult, Matched};
|
||||||
pub use pattern::{HostPattern, ParseError, PathPattern, PathSegment};
|
pub use pattern::{
|
||||||
|
parse_app_domain, HostPattern, ParseError, ParsedAppDomain, PathPattern, PathSegment,
|
||||||
|
};
|
||||||
pub use table::RouteTable;
|
pub use table::RouteTable;
|
||||||
|
|||||||
@@ -251,6 +251,106 @@ pub fn parse_host(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// App-domain patterns
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
use picloud_shared::DomainShape;
|
||||||
|
|
||||||
|
/// Result of parsing a user-supplied app domain claim. Carries the
|
||||||
|
/// host pattern (used at request time), the shape (used at write time
|
||||||
|
/// for collision checks), and the normalized shape_key.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ParsedAppDomain {
|
||||||
|
pub pattern: HostPattern,
|
||||||
|
pub shape: DomainShape,
|
||||||
|
/// Collision key: `"exact:<host>"` for exact; `"wildcard:<suffix>"`
|
||||||
|
/// for both wildcard AND parameterized — they share a shape per
|
||||||
|
/// blueprint §11.5 ("`{tenant}` has the same shape as `*` for this
|
||||||
|
/// check").
|
||||||
|
pub shape_key: String,
|
||||||
|
/// Captured binding name for parameterized claims, e.g., `Some("tenant")`
|
||||||
|
/// for `{tenant}.example.com`. Currently informational; the binding
|
||||||
|
/// is surfaced into request context in a future iteration.
|
||||||
|
pub binding: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a user-supplied app domain claim. Accepts:
|
||||||
|
/// * `app.example.com` — exact host
|
||||||
|
/// * `*.example.com` — wildcard suffix
|
||||||
|
/// * `{tenant}.example.com` — parameterized; same shape as wildcard
|
||||||
|
///
|
||||||
|
/// Distinct from `parse_host` (which is for route host fields): the
|
||||||
|
/// route parser still rejects `{...}` syntax — see
|
||||||
|
/// `ParseError::ReservedHostBraceSyntax`.
|
||||||
|
pub fn parse_app_domain(raw: &str) -> Result<ParsedAppDomain, ParseError> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(ParseError::EmptyHost);
|
||||||
|
}
|
||||||
|
let lowered = trimmed.to_ascii_lowercase();
|
||||||
|
|
||||||
|
// Wildcard: starts with "*."
|
||||||
|
if let Some(suffix) = lowered.strip_prefix("*.") {
|
||||||
|
if suffix.is_empty() {
|
||||||
|
return Err(ParseError::EmptyWildcardSuffix);
|
||||||
|
}
|
||||||
|
return Ok(ParsedAppDomain {
|
||||||
|
pattern: HostPattern::Wildcard {
|
||||||
|
suffix: suffix.to_string(),
|
||||||
|
capture: None,
|
||||||
|
},
|
||||||
|
shape: DomainShape::Wildcard,
|
||||||
|
shape_key: format!("wildcard:{suffix}"),
|
||||||
|
binding: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameterized: starts with "{name}." where `name` is an ident.
|
||||||
|
if let Some(stripped) = lowered.strip_prefix('{') {
|
||||||
|
let (binding, rest) = stripped
|
||||||
|
.split_once('}')
|
||||||
|
.ok_or(ParseError::ReservedHostBraceSyntax)?;
|
||||||
|
if binding.is_empty()
|
||||||
|
|| !binding
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||||
|
|| !binding.chars().next().unwrap().is_ascii_alphabetic()
|
||||||
|
{
|
||||||
|
return Err(ParseError::InvalidParamName(binding.to_string()));
|
||||||
|
}
|
||||||
|
let suffix = rest
|
||||||
|
.strip_prefix('.')
|
||||||
|
.ok_or(ParseError::ReservedHostBraceSyntax)?;
|
||||||
|
if suffix.is_empty() || suffix.contains('{') || suffix.contains('}') {
|
||||||
|
return Err(ParseError::ReservedHostBraceSyntax);
|
||||||
|
}
|
||||||
|
return Ok(ParsedAppDomain {
|
||||||
|
pattern: HostPattern::Wildcard {
|
||||||
|
suffix: suffix.to_string(),
|
||||||
|
capture: Some(binding.to_string()),
|
||||||
|
},
|
||||||
|
shape: DomainShape::Parameterized,
|
||||||
|
// Same shape_key as the equivalent wildcard — parameter
|
||||||
|
// name is a binding, not a discriminator.
|
||||||
|
shape_key: format!("wildcard:{suffix}"),
|
||||||
|
binding: Some(binding.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything else: exact host. Reject braces anywhere in the body
|
||||||
|
// (they'd be a malformed parameterized form).
|
||||||
|
if lowered.contains('{') || lowered.contains('}') {
|
||||||
|
return Err(ParseError::ReservedHostBraceSyntax);
|
||||||
|
}
|
||||||
|
Ok(ParsedAppDomain {
|
||||||
|
pattern: HostPattern::Strict(lowered.clone()),
|
||||||
|
shape: DomainShape::Exact,
|
||||||
|
shape_key: format!("exact:{lowered}"),
|
||||||
|
binding: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -393,6 +493,49 @@ mod tests {
|
|||||||
assert_eq!(e, ParseError::ReservedHostBraceSyntax);
|
assert_eq!(e, ParseError::ReservedHostBraceSyntax);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_app_domain_exact() {
|
||||||
|
let d = parse_app_domain("App.Example.COM").unwrap();
|
||||||
|
assert_eq!(d.shape, DomainShape::Exact);
|
||||||
|
assert_eq!(d.shape_key, "exact:app.example.com");
|
||||||
|
assert_eq!(d.pattern, HostPattern::Strict("app.example.com".into()));
|
||||||
|
assert!(d.binding.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_app_domain_wildcard_and_parameterized_share_shape_key() {
|
||||||
|
let w = parse_app_domain("*.example.com").unwrap();
|
||||||
|
let p = parse_app_domain("{tenant}.example.com").unwrap();
|
||||||
|
assert_eq!(w.shape, DomainShape::Wildcard);
|
||||||
|
assert_eq!(p.shape, DomainShape::Parameterized);
|
||||||
|
// Same shape_key — they collide at claim time (blueprint §11.5).
|
||||||
|
assert_eq!(w.shape_key, "wildcard:example.com");
|
||||||
|
assert_eq!(p.shape_key, "wildcard:example.com");
|
||||||
|
assert_eq!(p.binding.as_deref(), Some("tenant"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_app_domain_rejects_garbage() {
|
||||||
|
assert!(matches!(parse_app_domain(""), Err(ParseError::EmptyHost)));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_app_domain("*."),
|
||||||
|
Err(ParseError::EmptyWildcardSuffix)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_app_domain("{}.example.com"),
|
||||||
|
Err(ParseError::InvalidParamName(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_app_domain("{1tenant}.example.com"),
|
||||||
|
Err(ParseError::InvalidParamName(_))
|
||||||
|
));
|
||||||
|
// Mid-host braces — disallowed.
|
||||||
|
assert!(matches!(
|
||||||
|
parse_app_domain("foo.{tenant}.example.com"),
|
||||||
|
Err(ParseError::ReservedHostBraceSyntax)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn leading_literal_count_works() {
|
fn leading_literal_count_works() {
|
||||||
let exact = parse_path(PathKind::Exact, "/foo/users").unwrap();
|
let exact = parse_path(PathKind::Exact, "/foo/users").unwrap();
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
//! In-memory snapshot of compiled routes, shared by manager (writes)
|
//! In-memory snapshot of compiled routes, partitioned by `app_id`.
|
||||||
//! and orchestrator (reads).
|
|
||||||
//!
|
//!
|
||||||
//! Holds an `arc-swap`-style lock-free hand-off so the dispatcher can
|
//! The orchestrator looks up the app's slice by id after `AppDomainTable`
|
||||||
//! read without contending against the writer; in MVP-single-process
|
//! has resolved Host → app_id, then runs the existing matcher on that
|
||||||
//! we just use `RwLock` and accept the cheap contention.
|
//! slice. The matcher is unchanged; this type is just a per-app bucket.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
use picloud_shared::AppId;
|
||||||
|
|
||||||
use super::matcher::{r#match, CompiledRoute, MatchResult};
|
use super::matcher::{r#match, CompiledRoute, MatchResult};
|
||||||
|
|
||||||
|
/// Per-app compiled-route tables. Single MVP-mode writer (the manager,
|
||||||
|
/// via `replace_all`); contention against readers is minimal so a plain
|
||||||
|
/// `RwLock` is fine.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct RouteTable {
|
pub struct RouteTable {
|
||||||
inner: RwLock<Vec<CompiledRoute>>,
|
inner: RwLock<HashMap<AppId, Vec<CompiledRoute>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RouteTable {
|
impl RouteTable {
|
||||||
@@ -20,24 +25,54 @@ impl RouteTable {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the whole table atomically. The manager calls this after
|
/// Replace every per-app slice atomically. The manager calls this
|
||||||
/// each successful route CRUD operation (by re-reading from DB).
|
/// after each successful route CRUD operation; in cluster mode the
|
||||||
pub fn replace(&self, routes: Vec<CompiledRoute>) {
|
/// orchestrator's HTTP-fed receiver will too.
|
||||||
|
pub fn replace_all(&self, routes: Vec<CompiledRoute>) {
|
||||||
|
let mut by_app: HashMap<AppId, Vec<CompiledRoute>> = HashMap::new();
|
||||||
|
for r in routes {
|
||||||
|
by_app.entry(r.app_id).or_default().push(r);
|
||||||
|
}
|
||||||
let mut guard = self.inner.write().expect("route table poisoned");
|
let mut guard = self.inner.write().expect("route table poisoned");
|
||||||
*guard = routes;
|
*guard = by_app;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dispatch a request to a matching route, or `None`.
|
/// Dispatch a request to a matching route within `app_id`, or
|
||||||
|
/// `None`. Returns `None` when the app has no routes at all.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn match_request(&self, host: &str, method: &str, path: &str) -> Option<MatchResult> {
|
pub fn match_request_for_app(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
host: &str,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
) -> Option<MatchResult> {
|
||||||
let guard = self.inner.read().expect("route table poisoned");
|
let guard = self.inner.read().expect("route table poisoned");
|
||||||
r#match(guard.iter(), host, method, path)
|
let slice = guard.get(&app_id)?;
|
||||||
|
r#match(slice.iter(), host, method, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a clone of the currently compiled routes; intended for
|
/// Returns a clone of the currently compiled routes for `app_id`;
|
||||||
/// the dashboard's "list routes" admin endpoint.
|
/// intended for admin endpoints like "list this app's routes".
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn snapshot(&self) -> Vec<CompiledRoute> {
|
pub fn snapshot_for_app(&self, app_id: AppId) -> Vec<CompiledRoute> {
|
||||||
self.inner.read().expect("route table poisoned").clone()
|
self.inner
|
||||||
|
.read()
|
||||||
|
.expect("route table poisoned")
|
||||||
|
.get(&app_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All compiled routes across all apps. Used by tests and the
|
||||||
|
/// global admin "every route on this install" view.
|
||||||
|
#[must_use]
|
||||||
|
pub fn snapshot_all(&self) -> Vec<CompiledRoute> {
|
||||||
|
self.inner
|
||||||
|
.read()
|
||||||
|
.expect("route table poisoned")
|
||||||
|
.values()
|
||||||
|
.flat_map(|v| v.iter().cloned())
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,3 +39,5 @@ figment.workspace = true
|
|||||||
axum-test = "17"
|
axum-test = "17"
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
|||||||
@@ -6,24 +6,66 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::middleware::from_fn_with_state;
|
||||||
use axum::{routing::get, Json, Router};
|
use axum::{routing::get, Json, Router};
|
||||||
use picloud_executor_core::{Engine, Limits};
|
use picloud_executor_core::{Engine, Limits};
|
||||||
use picloud_manager_core::{
|
use picloud_manager_core::{
|
||||||
admin_router, compile_routes, migrations, route_admin_router, AdminState,
|
admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
|
||||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
attach_principal_if_present, auth_router, compile_routes, migrations, require_authenticated,
|
||||||
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
|
||||||
|
ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState,
|
||||||
|
AppRepository, AppsState, AuthState, AuthzRepo, PostgresAdminSessionRepository,
|
||||||
|
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
|
||||||
|
PostgresAppMembersRepository, PostgresAppRepository, PostgresExecutionLogRepository,
|
||||||
|
PostgresExecutionLogSink, PostgresRouteRepository, PostgresScriptRepository, RepoResolver,
|
||||||
|
RouteAdminState, RouteRepository, SandboxCeiling,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::routing::RouteTable;
|
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||||
use picloud_orchestrator_core::{
|
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;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
|
/// Default session TTL when `PICLOUD_SESSION_TTL_HOURS` isn't set.
|
||||||
|
const DEFAULT_SESSION_TTL_HOURS: u64 = 24;
|
||||||
|
|
||||||
|
/// Bundles the auth-related dependencies that both `build_app` and the
|
||||||
|
/// startup bootstrap need. Built once in `main.rs` from the shared pool.
|
||||||
|
pub struct AuthDeps {
|
||||||
|
pub users: Arc<dyn AdminUserRepository>,
|
||||||
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||||
|
pub keys: Arc<dyn ApiKeyRepository>,
|
||||||
|
pub ttl: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthDeps {
|
||||||
|
/// Construct from a pool with the binary's standard defaults.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_pool(pool: PgPool) -> Self {
|
||||||
|
Self {
|
||||||
|
users: Arc::new(PostgresAdminUserRepository::new(pool.clone())),
|
||||||
|
sessions: Arc::new(PostgresAdminSessionRepository::new(pool.clone())),
|
||||||
|
keys: Arc::new(PostgresApiKeyRepository::new(pool)),
|
||||||
|
ttl: read_session_ttl(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_session_ttl() -> Duration {
|
||||||
|
let hours = std::env::var("PICLOUD_SESSION_TTL_HOURS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
|
.filter(|h| *h > 0)
|
||||||
|
.unwrap_or(DEFAULT_SESSION_TTL_HOURS);
|
||||||
|
Duration::from_secs(hours * 3600)
|
||||||
|
}
|
||||||
|
|
||||||
/// Compose the manager + orchestrator routes on top of a shared
|
/// Compose the manager + orchestrator routes on top of a shared
|
||||||
/// Postgres pool, returning an Axum router ready to be served.
|
/// Postgres pool, returning an Axum router ready to be served.
|
||||||
///
|
///
|
||||||
@@ -31,53 +73,164 @@ use tower_http::trace::TraceLayer;
|
|||||||
/// is mounted by Caddy at `/admin/*` (its base path). Anything else
|
/// is mounted by Caddy at `/admin/*` (its base path). Anything else
|
||||||
/// falls through to the user-route table — user scripts can bind to
|
/// falls through to the user-route table — user scripts can bind to
|
||||||
/// arbitrary paths (subject to the reserved-prefix list).
|
/// arbitrary paths (subject to the reserved-prefix list).
|
||||||
pub async fn build_app(pool: PgPool) -> anyhow::Result<Router> {
|
///
|
||||||
let engine = Arc::new(Engine::new(Limits::default()));
|
/// `auth` carries the admin user/session repositories and the
|
||||||
|
/// configured session TTL. The manager-side admin endpoints
|
||||||
|
/// (`/api/v1/admin/scripts/*`, `/api/v1/admin/routes/*`,
|
||||||
|
/// `/api/v1/admin/admins/*`, `/api/v1/admin/auth/me`) are guarded by
|
||||||
|
/// the `require_admin` middleware. The data plane
|
||||||
|
/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`,
|
||||||
|
/// `/version`) stays open — it's the public ingress for user scripts.
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||||
|
// `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()));
|
||||||
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
|
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
|
||||||
let route_repo = Arc::new(PostgresRouteRepository::new(pool));
|
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
||||||
|
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
||||||
|
let domains_repo: Arc<dyn AppDomainRepository> =
|
||||||
|
Arc::new(PostgresAppDomainRepository::new(pool.clone()));
|
||||||
|
// The Postgres app_members repo implements both `AppMembersRepository`
|
||||||
|
// (CRUD over the table) and `AuthzRepo` (single-row membership lookup
|
||||||
|
// for capability checks). Construct it once and clone the Arc into
|
||||||
|
// both trait views — same allocation, two vtables.
|
||||||
|
let members_concrete = Arc::new(PostgresAppMembersRepository::new(pool));
|
||||||
|
let members: Arc<dyn AppMembersRepository> = members_concrete.clone();
|
||||||
|
let authz: Arc<dyn AuthzRepo> = members_concrete;
|
||||||
|
|
||||||
// Compile the routes table once at startup; admin writes refresh it.
|
// Compile the routes table once at startup; admin writes refresh it.
|
||||||
let route_table = Arc::new(RouteTable::new());
|
let route_table = Arc::new(RouteTable::new());
|
||||||
let initial = route_repo.list_all().await?;
|
let initial = route_repo.list_all().await?;
|
||||||
let compiled = compile_routes(&initial)
|
let compiled = compile_routes(&initial)
|
||||||
.map_err(|e| anyhow::anyhow!("failed to compile stored routes: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("failed to compile stored routes: {e}"))?;
|
||||||
route_table.replace(compiled);
|
route_table.replace_all(compiled);
|
||||||
|
|
||||||
|
// Same shape for app domains (Host → app_id cache).
|
||||||
|
let app_domain_table = Arc::new(AppDomainTable::new());
|
||||||
|
let initial_domains = domains_repo.list_all().await?;
|
||||||
|
let compiled_domains: Vec<_> = initial_domains
|
||||||
|
.iter()
|
||||||
|
.filter_map(|d| {
|
||||||
|
picloud_orchestrator_core::routing::parse_app_domain(&d.pattern)
|
||||||
|
.ok()
|
||||||
|
.map(|p| picloud_orchestrator_core::routing::CompiledAppDomain {
|
||||||
|
app_id: d.app_id,
|
||||||
|
pattern: p.pattern,
|
||||||
|
shape_key: p.shape_key,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
app_domain_table.replace(compiled_domains);
|
||||||
|
|
||||||
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
|
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
|
||||||
script_repo.clone(),
|
script_repo.clone(),
|
||||||
)));
|
)));
|
||||||
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)),
|
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
|
||||||
logs: log_repo,
|
logs: log_repo,
|
||||||
|
apps: apps_repo.clone(),
|
||||||
|
authz: authz.clone(),
|
||||||
validator: engine as Arc<dyn ScriptValidator>,
|
validator: engine as Arc<dyn ScriptValidator>,
|
||||||
sandbox_ceiling: SandboxCeiling::from_env(),
|
sandbox_ceiling: SandboxCeiling::from_env(),
|
||||||
};
|
};
|
||||||
let route_admin = RouteAdminState {
|
let route_admin = RouteAdminState {
|
||||||
routes: route_repo,
|
routes: route_repo.clone(),
|
||||||
|
scripts: Arc::new(PostgresScriptRepoHandle(script_repo)),
|
||||||
|
domains: domains_repo.clone(),
|
||||||
table: route_table.clone(),
|
table: route_table.clone(),
|
||||||
|
authz: authz.clone(),
|
||||||
};
|
};
|
||||||
let data_plane = DataPlaneState {
|
let data_plane = DataPlaneState {
|
||||||
executor,
|
executor,
|
||||||
resolver,
|
resolver,
|
||||||
log_sink,
|
log_sink,
|
||||||
|
app_domains: app_domain_table.clone(),
|
||||||
routes: route_table,
|
routes: route_table,
|
||||||
};
|
};
|
||||||
|
let apps_state = AppsState {
|
||||||
|
apps: apps_repo,
|
||||||
|
domains: domains_repo,
|
||||||
|
routes: route_repo,
|
||||||
|
domain_table: app_domain_table,
|
||||||
|
authz: authz.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let auth_state = AuthState {
|
||||||
|
users: auth.users.clone(),
|
||||||
|
sessions: auth.sessions.clone(),
|
||||||
|
keys: auth.keys.clone(),
|
||||||
|
ttl: auth.ttl,
|
||||||
|
};
|
||||||
|
let admins_state = AdminsState {
|
||||||
|
users: auth.users.clone(),
|
||||||
|
sessions: auth.sessions,
|
||||||
|
keys: auth.keys.clone(),
|
||||||
|
authz: authz.clone(),
|
||||||
|
};
|
||||||
|
let app_members_state = AppMembersState {
|
||||||
|
apps: apps_state.apps.clone(),
|
||||||
|
users: auth.users,
|
||||||
|
members,
|
||||||
|
authz,
|
||||||
|
};
|
||||||
|
let api_keys_state = ApiKeysState { keys: auth.keys };
|
||||||
|
|
||||||
|
// /admin/auth/login + /logout are unguarded by design (login is how
|
||||||
|
// you get in). /admin/auth/me applies the middleware internally so
|
||||||
|
// the same Router::with_state machinery composes cleanly. Everything
|
||||||
|
// else under /admin gets the require_authenticated layer; capability
|
||||||
|
// checks live in each handler (after the resource is loaded so the
|
||||||
|
// capability binds to the resource's actual app_id).
|
||||||
|
let guarded_admin = Router::new()
|
||||||
|
.merge(admin_router(admin))
|
||||||
|
.merge(route_admin_router(route_admin))
|
||||||
|
.merge(admins_router(admins_state))
|
||||||
|
.merge(apps_router(apps_state))
|
||||||
|
.merge(app_members_router(app_members_state))
|
||||||
|
.merge(api_keys_router(api_keys_state))
|
||||||
|
.layer(from_fn_with_state(
|
||||||
|
auth_state.clone(),
|
||||||
|
require_authenticated,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Silence "unused import" lint on `apps_api` — we re-export via the
|
||||||
|
// facade above; the bare module path is retained so it's discoverable.
|
||||||
|
let _ = apps_api::AppsState::clone;
|
||||||
|
|
||||||
|
// 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", admin_router(admin))
|
.nest("/admin", auth_router(auth_state))
|
||||||
.nest("/admin", route_admin_router(route_admin))
|
.nest("/admin", guarded_admin)
|
||||||
.merge(data_plane_router(data_plane.clone()));
|
.merge(data_plane_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()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +291,18 @@ impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
|
|||||||
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||||
self.0.list().await
|
self.0.list().await
|
||||||
}
|
}
|
||||||
|
async fn list_for_app(
|
||||||
|
&self,
|
||||||
|
app_id: picloud_shared::AppId,
|
||||||
|
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||||
|
self.0.list_for_app(app_id).await
|
||||||
|
}
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: picloud_shared::AdminUserId,
|
||||||
|
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||||
|
self.0.list_for_user(user_id).await
|
||||||
|
}
|
||||||
async fn create(
|
async fn create(
|
||||||
&self,
|
&self,
|
||||||
input: picloud_manager_core::NewScript,
|
input: picloud_manager_core::NewScript,
|
||||||
|
|||||||
@@ -1,17 +1,38 @@
|
|||||||
//! PiCloud all-in-one binary — see `lib.rs` for the actual app
|
//! PiCloud all-in-one binary — see `lib.rs` for the actual app
|
||||||
//! composition; this file is only the runtime shell (env config,
|
//! composition; this file is only the runtime shell (env config,
|
||||||
//! logger, migrations, listener).
|
//! logger, migrations, listener) plus the small `admin` CLI subcommand
|
||||||
|
//! used for out-of-band password recovery.
|
||||||
|
|
||||||
|
use std::io::{BufRead, Write};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use picloud::{build_app, init_db};
|
use picloud::{build_app, init_db, AuthDeps};
|
||||||
use picloud_manager_core::migrations;
|
use picloud_manager_core::{
|
||||||
|
auth::{hash_password, validate_password_hash},
|
||||||
|
bootstrap_first_admin, migrations, seed_hello_world_if_fresh, AdminSessionRepository,
|
||||||
|
AdminUserRepository, HelloWorldOutcome, PostgresAppRepository, PostgresRouteRepository,
|
||||||
|
PostgresScriptRepository,
|
||||||
|
};
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
init_tracing();
|
init_tracing();
|
||||||
|
|
||||||
|
// Subcommand dispatch — `picloud admin reset-password <username>`.
|
||||||
|
// Kept handwritten to avoid pulling clap in just for one verb. Falls
|
||||||
|
// through to the server when no subcommand is given.
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
if args.get(1).map(String::as_str) == Some("admin") {
|
||||||
|
return run_admin_cli(&args[2..]).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
run_server().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_server() -> anyhow::Result<()> {
|
||||||
let addr: SocketAddr = std::env::var("PICLOUD_BIND")
|
let addr: SocketAddr = std::env::var("PICLOUD_BIND")
|
||||||
.unwrap_or_else(|_| "0.0.0.0:8080".into())
|
.unwrap_or_else(|_| "0.0.0.0:8080".into())
|
||||||
.parse()?;
|
.parse()?;
|
||||||
@@ -22,7 +43,33 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
migrations::run(&pool).await?;
|
migrations::run(&pool).await?;
|
||||||
tracing::info!("migrations applied");
|
tracing::info!("migrations applied");
|
||||||
|
|
||||||
let app = build_app(pool).await?;
|
let auth = AuthDeps::from_pool(pool.clone());
|
||||||
|
bootstrap_first_admin(&*auth.users).await?;
|
||||||
|
warn_on_multi_owner_install(&*auth.users).await;
|
||||||
|
|
||||||
|
// Seed Hello World into the default app when this is a fresh
|
||||||
|
// install (no scripts and no routes). Idempotent on upgrades.
|
||||||
|
let apps = Arc::new(PostgresAppRepository::new(pool.clone()));
|
||||||
|
let scripts = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
||||||
|
let routes = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
||||||
|
match seed_hello_world_if_fresh(apps, scripts, routes).await {
|
||||||
|
Ok(HelloWorldOutcome::Seeded) => {
|
||||||
|
tracing::info!("hello-world seed inserted into the default app");
|
||||||
|
}
|
||||||
|
Ok(HelloWorldOutcome::SkippedExisting) => {
|
||||||
|
tracing::debug!("hello-world seed skipped (default app already populated)");
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(?err, "hello-world seed failed (continuing startup)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background session-prune sweep. Cheap; keeps the table from
|
||||||
|
// growing unbounded. Expired rows are also rejected at lookup time,
|
||||||
|
// so a delayed sweep can't extend session lifetimes.
|
||||||
|
spawn_session_pruner(auth.sessions.clone());
|
||||||
|
|
||||||
|
let app = build_app(pool, auth).await?;
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
tracing::info!(%addr, "picloud all-in-one listening");
|
tracing::info!(%addr, "picloud all-in-one listening");
|
||||||
@@ -33,6 +80,140 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Multi-owner startup warning — Phase 3.5 migration upgraded every
|
||||||
|
/// pre-existing admin_users row to `Owner` via DEFAULT, which for
|
||||||
|
/// installs with several Phase 3a admins means several co-owners.
|
||||||
|
/// Surface this once at boot so the operator can demote extras via
|
||||||
|
/// `PATCH /api/v1/admin/admins/{id}` with `instance_role: "admin"`.
|
||||||
|
/// Soft-fail: a DB blip should not block startup.
|
||||||
|
async fn warn_on_multi_owner_install(users: &dyn AdminUserRepository) {
|
||||||
|
match users.list_active_owners().await {
|
||||||
|
Ok(owners) if owners.len() > 1 => {
|
||||||
|
let names: Vec<String> = owners.into_iter().map(|u| u.username).collect();
|
||||||
|
tracing::warn!(
|
||||||
|
count = names.len(),
|
||||||
|
owners = ?names,
|
||||||
|
"multiple active owners detected — Phase 3.5 promoted every \
|
||||||
|
pre-existing admin to owner. Demote extras via \
|
||||||
|
PATCH /api/v1/admin/admins/{{id}} with instance_role."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(
|
||||||
|
?err,
|
||||||
|
"could not count active owners for multi-owner startup check"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_session_pruner(sessions: Arc<dyn AdminSessionRepository>) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut ticker = tokio::time::interval(Duration::from_secs(600));
|
||||||
|
// First tick fires immediately; skip it so we don't race startup.
|
||||||
|
ticker.tick().await;
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
match sessions.prune_expired().await {
|
||||||
|
Ok(n) if n > 0 => tracing::debug!(pruned = n, "expired admin sessions pruned"),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) => tracing::warn!(?err, "admin session prune failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// `admin` subcommand
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn run_admin_cli(args: &[String]) -> anyhow::Result<()> {
|
||||||
|
match args.first().map(String::as_str) {
|
||||||
|
Some("reset-password") => {
|
||||||
|
let username = args.get(1).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"usage: picloud admin reset-password <username> [--password-hash <hash>]"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
// Optional inline hash via --password-hash <hash>; otherwise
|
||||||
|
// read a raw password from stdin.
|
||||||
|
let hash_arg = parse_flag(&args[2..], "--password-hash");
|
||||||
|
cmd_reset_password(username, hash_arg).await
|
||||||
|
}
|
||||||
|
Some(other) => Err(anyhow::anyhow!("unknown admin subcommand: {other}")),
|
||||||
|
None => Err(anyhow::anyhow!(
|
||||||
|
"usage: picloud admin reset-password <username>"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_flag(args: &[String], name: &str) -> Option<String> {
|
||||||
|
let mut it = args.iter();
|
||||||
|
while let Some(a) = it.next() {
|
||||||
|
if a == name {
|
||||||
|
return it.next().cloned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cmd_reset_password(username: &str, password_hash: Option<String>) -> anyhow::Result<()> {
|
||||||
|
let database_url =
|
||||||
|
std::env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL is required"))?;
|
||||||
|
let pool = init_db(&database_url).await?;
|
||||||
|
migrations::run(&pool).await?;
|
||||||
|
|
||||||
|
let users = picloud_manager_core::PostgresAdminUserRepository::new(pool.clone());
|
||||||
|
let sessions = picloud_manager_core::PostgresAdminSessionRepository::new(pool);
|
||||||
|
|
||||||
|
let target = users
|
||||||
|
.get_by_username(username)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no admin user named {username:?}"))?;
|
||||||
|
|
||||||
|
let hash = if let Some(h) = password_hash {
|
||||||
|
validate_password_hash(&h)
|
||||||
|
.map_err(|_| anyhow::anyhow!("--password-hash is not a valid Argon2id PHC string"))?;
|
||||||
|
h
|
||||||
|
} else {
|
||||||
|
let raw = prompt_password_from_stdin()?;
|
||||||
|
hash_password(&raw).map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
users.update_password_hash(target.id, &hash).await?;
|
||||||
|
// Recovery implies the operator already lost control of the account;
|
||||||
|
// re-activate it (so a deactivated admin can also recover) and wipe
|
||||||
|
// any pre-existing sessions in case the original holder is still
|
||||||
|
// signed in elsewhere.
|
||||||
|
if !target.is_active {
|
||||||
|
users.set_active(target.id, true).await?;
|
||||||
|
}
|
||||||
|
let dropped = sessions.delete_for_user(target.id).await?;
|
||||||
|
|
||||||
|
println!("Password reset for {username}. Sessions dropped: {dropped}. Active: true.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_password_from_stdin() -> anyhow::Result<String> {
|
||||||
|
eprint!("New password (will be read from stdin, no echo): ");
|
||||||
|
std::io::stderr().flush().ok();
|
||||||
|
let mut line = String::new();
|
||||||
|
std::io::stdin()
|
||||||
|
.lock()
|
||||||
|
.read_line(&mut line)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to read stdin: {e}"))?;
|
||||||
|
let pw = line.trim_end_matches(['\n', '\r']).to_string();
|
||||||
|
if pw.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("password must not be empty"));
|
||||||
|
}
|
||||||
|
Ok(pw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Misc
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
fn init_tracing() {
|
fn init_tracing() {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||||
|
|||||||
@@ -17,9 +17,68 @@ use axum_test::TestServer;
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
/// Build the all-in-one app over the test pool, seed a single admin
|
||||||
|
/// directly through the repo (bypassing the env-var bootstrap path so
|
||||||
|
/// tests don't contaminate the process environment), log in, and bake
|
||||||
|
/// the bearer token into the TestServer as a default header so every
|
||||||
|
/// request in the test passes the `require_admin` middleware.
|
||||||
async fn server(pool: PgPool) -> TestServer {
|
async fn server(pool: PgPool) -> TestServer {
|
||||||
let app = picloud::build_app(pool).await.expect("build_app");
|
let (server, _app_id) = server_with_app(pool).await;
|
||||||
TestServer::new(app).expect("TestServer should build")
|
server
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `server`, but also returns the default app's id — needed by
|
||||||
|
/// any test that creates scripts (every script now requires `app_id`).
|
||||||
|
async fn server_with_app(pool: PgPool) -> (TestServer, String) {
|
||||||
|
use picloud_manager_core::auth::hash_password;
|
||||||
|
use picloud_shared::InstanceRole;
|
||||||
|
|
||||||
|
let auth = picloud::AuthDeps::from_pool(pool.clone());
|
||||||
|
let hash = hash_password("test-pw").expect("hash");
|
||||||
|
auth.users
|
||||||
|
.create("test-admin", &hash, InstanceRole::Owner, None)
|
||||||
|
.await
|
||||||
|
.expect("seed admin");
|
||||||
|
|
||||||
|
let app = picloud::build_app(pool, auth).await.expect("build_app");
|
||||||
|
let mut server = TestServer::new(app).expect("TestServer should build");
|
||||||
|
|
||||||
|
let resp = server
|
||||||
|
.post("/api/v1/admin/auth/login")
|
||||||
|
.json(&json!({ "username": "test-admin", "password": "test-pw" }))
|
||||||
|
.await;
|
||||||
|
resp.assert_status_ok();
|
||||||
|
let token = resp.json::<Value>()["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("login should return token")
|
||||||
|
.to_string();
|
||||||
|
server.add_header("authorization", format!("Bearer {token}"));
|
||||||
|
// Note: user-route dispatch needs an explicit `host: <claim>` header
|
||||||
|
// on each request (the axum_test client doesn't default to a real
|
||||||
|
// host). The default app claims `localhost`; user-route tests below
|
||||||
|
// add the header per request via `.add_header("host", "localhost")`
|
||||||
|
// so per-test overrides for other apps cleanly replace it.
|
||||||
|
|
||||||
|
// The 0005 migration unconditionally inserts a `default` app; fetch
|
||||||
|
// its id so tests can attach scripts to it without re-running the
|
||||||
|
// Rust-side hello-world seed (which only fires from main.rs).
|
||||||
|
// The get-app handler returns `{ ...App, redirect_to?: ... }` —
|
||||||
|
// the app fields are flattened at the response root.
|
||||||
|
let app: Value = server.get("/api/v1/admin/apps/default").await.json();
|
||||||
|
let app_id = app["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_else(|| panic!("default app id missing from response: {app}"))
|
||||||
|
.to_string();
|
||||||
|
(server, app_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge `{ "app_id": <default> }` into a create-script body. Saves
|
||||||
|
/// repeating the same field in 25+ tests.
|
||||||
|
fn with_app(app_id: &str, mut body: Value) -> Value {
|
||||||
|
body.as_object_mut()
|
||||||
|
.expect("script body must be a JSON object")
|
||||||
|
.insert("app_id".into(), Value::String(app_id.to_string()));
|
||||||
|
body
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -34,6 +93,68 @@ async fn healthz_responds_ok(pool: PgPool) {
|
|||||||
assert_eq!(r.text(), "ok");
|
assert_eq!(r.text(), "ok");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn auth_me_returns_principal_with_role_and_email(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let r = s.get("/api/v1/admin/auth/me").await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
let body: Value = r.json();
|
||||||
|
assert_eq!(body["username"], "test-admin");
|
||||||
|
assert_eq!(body["instance_role"], "owner");
|
||||||
|
// Seeded admin has no email — must round-trip as null, not be missing.
|
||||||
|
assert!(
|
||||||
|
body.get("email").is_some_and(Value::is_null),
|
||||||
|
"email should be present and null, got: {body}"
|
||||||
|
);
|
||||||
|
assert!(body["id"].as_str().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn create_admin_accepts_email_and_patch_clears_it(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
// Create with email set.
|
||||||
|
let created = s
|
||||||
|
.post("/api/v1/admin/admins")
|
||||||
|
.json(&json!({
|
||||||
|
"username": "alice",
|
||||||
|
"password": "correct-horse-battery",
|
||||||
|
"instance_role": "member",
|
||||||
|
"email": "alice@example.com",
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
created.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let body: Value = created.json();
|
||||||
|
let alice_id = body["id"].as_str().expect("id").to_string();
|
||||||
|
assert_eq!(body["email"], "alice@example.com");
|
||||||
|
|
||||||
|
// Patch with email present-and-null clears it.
|
||||||
|
let cleared = s
|
||||||
|
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
|
||||||
|
.json(&json!({ "email": null }))
|
||||||
|
.await;
|
||||||
|
cleared.assert_status_ok();
|
||||||
|
assert!(cleared.json::<Value>()["email"].is_null());
|
||||||
|
|
||||||
|
// Patch with email omitted is a no-op (doesn't clobber a re-set).
|
||||||
|
let reset = s
|
||||||
|
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
|
||||||
|
.json(&json!({ "email": "alice2@example.com" }))
|
||||||
|
.await;
|
||||||
|
reset.assert_status_ok();
|
||||||
|
let omit = s
|
||||||
|
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
|
||||||
|
.json(&json!({ "username": "alice" })) // no email key
|
||||||
|
.await;
|
||||||
|
omit.assert_status_ok();
|
||||||
|
assert_eq!(omit.json::<Value>()["email"], "alice2@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Script CRUD
|
// Script CRUD
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -41,30 +162,37 @@ async fn healthz_responds_ok(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn create_script_returns_201_with_full_record(pool: PgPool) {
|
async fn create_script_returns_201_with_full_record(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "echo",
|
"name": "echo",
|
||||||
"description": "test",
|
"description": "test",
|
||||||
"source": "#{ statusCode: 200, body: 42 }",
|
"source": "#{ statusCode: 200, body: 42 }",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status(axum::http::StatusCode::CREATED);
|
r.assert_status(axum::http::StatusCode::CREATED);
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["name"], "echo");
|
assert_eq!(body["name"], "echo");
|
||||||
assert_eq!(body["version"], 1);
|
assert_eq!(body["version"], 1);
|
||||||
assert_eq!(body["timeout_seconds"], 30);
|
assert_eq!(body["timeout_seconds"], 30);
|
||||||
|
assert_eq!(body["app_id"], app_id);
|
||||||
assert!(body["id"].as_str().is_some());
|
assert!(body["id"].as_str().is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
|
async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
|
||||||
let r = server(pool)
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
.await
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "broken", "source": "@@@ not rhai @@@" }))
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({ "name": "broken", "source": "@@@ not rhai @@@" }),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
@@ -74,14 +202,14 @@ async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn duplicate_name_returns_409(pool: PgPool) {
|
async fn duplicate_name_returns_409(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
s.post("/api/v1/admin/scripts")
|
s.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "dup", "source": "42" }))
|
.json(&with_app(&app_id, json!({ "name": "dup", "source": "42" })))
|
||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "dup", "source": "43" }))
|
.json(&with_app(&app_id, json!({ "name": "dup", "source": "43" })))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status(axum::http::StatusCode::CONFLICT);
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
}
|
}
|
||||||
@@ -89,10 +217,10 @@ async fn duplicate_name_returns_409(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn list_returns_all_scripts(pool: PgPool) {
|
async fn list_returns_all_scripts(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
for name in ["alpha", "bravo", "charlie"] {
|
for name in ["alpha", "bravo", "charlie"] {
|
||||||
s.post("/api/v1/admin/scripts")
|
s.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": name, "source": "1" }))
|
.json(&with_app(&app_id, json!({ "name": name, "source": "1" })))
|
||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
}
|
}
|
||||||
@@ -107,10 +235,10 @@ async fn list_returns_all_scripts(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn update_bumps_version_and_persists_changes(pool: PgPool) {
|
async fn update_bumps_version_and_persists_changes(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "u", "source": "1" }))
|
.json(&with_app(&app_id, json!({ "name": "u", "source": "1" })))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -129,10 +257,10 @@ async fn update_bumps_version_and_persists_changes(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn update_with_invalid_source_returns_422(pool: PgPool) {
|
async fn update_with_invalid_source_returns_422(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "u", "source": "1" }))
|
.json(&with_app(&app_id, json!({ "name": "u", "source": "1" })))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -147,10 +275,10 @@ async fn update_with_invalid_source_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn delete_then_get_returns_404(pool: PgPool) {
|
async fn delete_then_get_returns_404(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "d", "source": "1" }))
|
.json(&with_app(&app_id, json!({ "name": "d", "source": "1" })))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -181,13 +309,16 @@ async fn get_nonexistent_returns_404(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn execute_echoes_body_back(pool: PgPool) {
|
async fn execute_echoes_body_back(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "echo",
|
"name": "echo",
|
||||||
"source": "#{ statusCode: 200, body: ctx.request.body }",
|
"source": "#{ statusCode: 200, body: ctx.request.body }",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -204,13 +335,16 @@ async fn execute_echoes_body_back(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn execute_passes_through_status_and_headers(pool: PgPool) {
|
async fn execute_passes_through_status_and_headers(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "header-test",
|
"name": "header-test",
|
||||||
"source": "#{ statusCode: 201, headers: #{ \"x-tag\": \"on\" }, body: 1 }",
|
"source": "#{ statusCode: 201, headers: #{ \"x-tag\": \"on\" }, body: 1 }",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -237,13 +371,16 @@ async fn execute_nonexistent_returns_404(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn execution_logs_capture_invocations(pool: PgPool) {
|
async fn execution_logs_capture_invocations(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "logger",
|
"name": "logger",
|
||||||
"source": "log::info(\"called\", #{ marker: 7 }); #{ statusCode: 200, body: \"done\" }",
|
"source": "log::info(\"called\", #{ marker: 7 }); #{ statusCode: 200, body: \"done\" }",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -294,10 +431,13 @@ async fn execution_logs_capture_invocations(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
|
async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "no-sandbox", "source": "1" }))
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({ "name": "no-sandbox", "source": "1" }),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
assert_eq!(created["sandbox"], json!({}));
|
assert_eq!(created["sandbox"], json!({}));
|
||||||
@@ -306,14 +446,17 @@ async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
|
async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "tight",
|
"name": "tight",
|
||||||
"source": "1",
|
"source": "1",
|
||||||
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -333,14 +476,17 @@ async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
|
|||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
|
async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
|
||||||
// Default conservative ceiling caps max_operations at 10_000_000.
|
// Default conservative ceiling caps max_operations at 10_000_000.
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "too-loose",
|
"name": "too-loose",
|
||||||
"source": "1",
|
"source": "1",
|
||||||
"sandbox": { "max_operations": 100_000_000 }
|
"sandbox": { "max_operations": 100_000_000 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
@@ -350,14 +496,17 @@ async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn sandbox_unknown_field_returns_422(pool: PgPool) {
|
async fn sandbox_unknown_field_returns_422(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "typo",
|
"name": "typo",
|
||||||
"source": "1",
|
"source": "1",
|
||||||
"sandbox": { "max_operashuns": 500 }
|
"sandbox": { "max_operashuns": 500 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
// serde's deny_unknown_fields causes axum to reject with 422 or
|
// serde's deny_unknown_fields causes axum to reject with 422 or
|
||||||
// 400 depending on extractor; the routing is irrelevant here, just
|
// 400 depending on extractor; the routing is irrelevant here, just
|
||||||
@@ -371,15 +520,18 @@ async fn sandbox_unknown_field_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
|
async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
// Tight max_operations on a loop the default would happily run.
|
// Tight max_operations on a loop the default would happily run.
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "tight-exec",
|
"name": "tight-exec",
|
||||||
"source": "let n = 0; for i in 0..10000 { n += 1; } n",
|
"source": "let n = 0; for i in 0..10000 { n += 1; } n",
|
||||||
"sandbox": { "max_operations": 500 }
|
"sandbox": { "max_operations": 500 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -396,14 +548,17 @@ async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn update_replaces_sandbox_wholesale(pool: PgPool) {
|
async fn update_replaces_sandbox_wholesale(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "patch-target",
|
"name": "patch-target",
|
||||||
"source": "1",
|
"source": "1",
|
||||||
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -429,10 +584,10 @@ async fn update_replaces_sandbox_wholesale(pool: PgPool) {
|
|||||||
// Custom routing
|
// Custom routing
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
async fn create_basic_script(s: &TestServer, name: &str, source: &str) -> String {
|
async fn create_basic_script(s: &TestServer, app_id: &str, name: &str, source: &str) -> String {
|
||||||
let v: Value = s
|
let v: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": name, "source": source }))
|
.json(&with_app(app_id, json!({ "name": name, "source": source })))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
v["id"].as_str().unwrap().to_string()
|
v["id"].as_str().unwrap().to_string()
|
||||||
@@ -441,9 +596,10 @@ async fn create_basic_script(s: &TestServer, name: &str, source: &str) -> String
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_exact_dispatches_to_script(pool: PgPool) {
|
async fn route_exact_dispatches_to_script(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(
|
let id = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"greet",
|
"greet",
|
||||||
"#{ statusCode: 200, body: #{ msg: \"hi\", path: ctx.request.path } }",
|
"#{ statusCode: 200, body: #{ msg: \"hi\", path: ctx.request.path } }",
|
||||||
)
|
)
|
||||||
@@ -457,7 +613,7 @@ async fn route_exact_dispatches_to_script(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
let r = s.get("/greet").await;
|
let r = s.get("/greet").add_header("host", "localhost").await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["msg"], "hi");
|
assert_eq!(body["msg"], "hi");
|
||||||
@@ -467,9 +623,10 @@ async fn route_exact_dispatches_to_script(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_param_captures_path_vars(pool: PgPool) {
|
async fn route_param_captures_path_vars(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(
|
let id = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"greet-name",
|
"greet-name",
|
||||||
"#{ statusCode: 200, body: #{ name: ctx.request.params.name } }",
|
"#{ statusCode: 200, body: #{ name: ctx.request.params.name } }",
|
||||||
)
|
)
|
||||||
@@ -483,7 +640,7 @@ async fn route_param_captures_path_vars(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
let r = s.get("/greet/alice").await;
|
let r = s.get("/greet/alice").add_header("host", "localhost").await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["name"], "alice");
|
assert_eq!(body["name"], "alice");
|
||||||
@@ -492,9 +649,10 @@ async fn route_param_captures_path_vars(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_prefix_captures_rest(pool: PgPool) {
|
async fn route_prefix_captures_rest(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(
|
let id = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"echo-prefix",
|
"echo-prefix",
|
||||||
"#{ statusCode: 200, body: #{ rest: ctx.request.rest } }",
|
"#{ statusCode: 200, body: #{ rest: ctx.request.rest } }",
|
||||||
)
|
)
|
||||||
@@ -508,19 +666,28 @@ async fn route_prefix_captures_rest(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
let r = s.get("/echo/foo/bar").await;
|
let r = s.get("/echo/foo/bar").add_header("host", "localhost").await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["rest"], "foo/bar");
|
assert_eq!(body["rest"], "foo/bar");
|
||||||
|
|
||||||
s.get("/echo").await.assert_status_not_found();
|
s.get("/echo")
|
||||||
|
.add_header("host", "localhost")
|
||||||
|
.await
|
||||||
|
.assert_status_not_found();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_query_string_exposed_to_script(pool: PgPool) {
|
async fn route_query_string_exposed_to_script(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "qs", "#{ statusCode: 200, body: ctx.request.query }").await;
|
let id = create_basic_script(
|
||||||
|
&s,
|
||||||
|
&app_id,
|
||||||
|
"qs",
|
||||||
|
"#{ statusCode: 200, body: ctx.request.query }",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"host_kind": "any",
|
"host_kind": "any",
|
||||||
@@ -530,7 +697,7 @@ async fn route_query_string_exposed_to_script(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
let r = s.get("/qs?a=1&b=two").await;
|
let r = s.get("/qs?a=1&b=two").add_header("host", "localhost").await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body, json!({ "a": "1", "b": "two" }));
|
assert_eq!(body, json!({ "a": "1", "b": "two" }));
|
||||||
@@ -539,8 +706,8 @@ async fn route_query_string_exposed_to_script(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_invalid_pattern_returns_422(pool: PgPool) {
|
async fn route_invalid_pattern_returns_422(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "x", "1").await;
|
let id = create_basic_script(&s, &app_id, "x", "1").await;
|
||||||
let r = s
|
let r = s
|
||||||
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
@@ -555,8 +722,8 @@ async fn route_invalid_pattern_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_conflict_returns_409(pool: PgPool) {
|
async fn route_conflict_returns_409(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "x", "1").await;
|
let id = create_basic_script(&s, &app_id, "x", "1").await;
|
||||||
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"host_kind": "any",
|
"host_kind": "any",
|
||||||
@@ -582,8 +749,8 @@ async fn route_conflict_returns_409(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_reserved_path_returns_422(pool: PgPool) {
|
async fn route_reserved_path_returns_422(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "x", "1").await;
|
let id = create_basic_script(&s, &app_id, "x", "1").await;
|
||||||
let r = s
|
let r = s
|
||||||
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
@@ -598,8 +765,8 @@ async fn route_reserved_path_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_match_preview_endpoint(pool: PgPool) {
|
async fn route_match_preview_endpoint(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "g", "1").await;
|
let id = create_basic_script(&s, &app_id, "g", "1").await;
|
||||||
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"host_kind": "any",
|
"host_kind": "any",
|
||||||
@@ -611,7 +778,11 @@ async fn route_match_preview_endpoint(pool: PgPool) {
|
|||||||
|
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/routes:match")
|
.post("/api/v1/admin/routes:match")
|
||||||
.json(&json!({ "url": "http://localhost:8000/greet/alice", "method": "GET" }))
|
.json(&json!({
|
||||||
|
"app_id": app_id,
|
||||||
|
"url": "http://localhost:8000/greet/alice",
|
||||||
|
"method": "GET"
|
||||||
|
}))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
@@ -622,8 +793,8 @@ async fn route_match_preview_endpoint(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_delete_removes_dispatch(pool: PgPool) {
|
async fn route_delete_removes_dispatch(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "g", "#{ statusCode: 200, body: 1 }").await;
|
let id = create_basic_script(&s, &app_id, "g", "#{ statusCode: 200, body: 1 }").await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
@@ -635,27 +806,35 @@ async fn route_delete_removes_dispatch(pool: PgPool) {
|
|||||||
.json();
|
.json();
|
||||||
let route_id = created["id"].as_str().unwrap();
|
let route_id = created["id"].as_str().unwrap();
|
||||||
|
|
||||||
s.get("/g").await.assert_status_ok();
|
s.get("/g")
|
||||||
|
.add_header("host", "localhost")
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
s.delete(&format!("/api/v1/admin/routes/{route_id}"))
|
s.delete(&format!("/api/v1/admin/routes/{route_id}"))
|
||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::NO_CONTENT);
|
.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
s.get("/g").await.assert_status_not_found();
|
s.get("/g")
|
||||||
|
.add_header("host", "localhost")
|
||||||
|
.await
|
||||||
|
.assert_status_not_found();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id_p = create_basic_script(
|
let id_p = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"by-param",
|
"by-param",
|
||||||
"#{ statusCode: 200, body: #{ tag: \"param\" } }",
|
"#{ statusCode: 200, body: #{ tag: \"param\" } }",
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let id_pr = create_basic_script(
|
let id_pr = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"by-prefix",
|
"by-prefix",
|
||||||
"#{ statusCode: 200, body: #{ tag: \"prefix\" } }",
|
"#{ statusCode: 200, body: #{ tag: \"prefix\" } }",
|
||||||
)
|
)
|
||||||
@@ -678,12 +857,12 @@ async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
|||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
// Single segment under /foo/ — both match; param wins by spec.
|
// Single segment under /foo/ — both match; param wins by spec.
|
||||||
let r = s.get("/foo/x").await;
|
let r = s.get("/foo/x").add_header("host", "localhost").await;
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["tag"], "param");
|
assert_eq!(body["tag"], "param");
|
||||||
|
|
||||||
// Two segments — only prefix matches.
|
// Two segments — only prefix matches.
|
||||||
let r2 = s.get("/foo/x/y").await;
|
let r2 = s.get("/foo/x/y").add_header("host", "localhost").await;
|
||||||
let body2: Value = r2.json();
|
let body2: Value = r2.json();
|
||||||
assert_eq!(body2["tag"], "prefix");
|
assert_eq!(body2["tag"], "prefix");
|
||||||
}
|
}
|
||||||
@@ -692,7 +871,7 @@ async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
|||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn root_returns_404_when_no_route(pool: PgPool) {
|
async fn root_returns_404_when_no_route(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let s = server(pool).await;
|
||||||
let r = s.get("/").await;
|
let r = s.get("/").add_header("host", "localhost").await;
|
||||||
r.assert_status_not_found();
|
r.assert_status_not_found();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,22 +884,325 @@ async fn version_includes_public_base_url(pool: PgPool) {
|
|||||||
let v: Value = r.json();
|
let v: Value = r.json();
|
||||||
assert!(v["public_base_url"].is_string());
|
assert!(v["public_base_url"].is_string());
|
||||||
assert_eq!(v["api"], 1);
|
assert_eq!(v["api"], 1);
|
||||||
assert_eq!(v["schema"], 3);
|
assert_eq!(v["schema"], 6);
|
||||||
assert_eq!(v["sdk"], "1.1");
|
assert_eq!(v["sdk"], "1.1");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// App scoping (Phase 3b)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn default_app_is_seeded_by_migration(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let r = s.get("/api/v1/admin/apps").await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
let apps: Vec<Value> = r.json();
|
||||||
|
let default = apps
|
||||||
|
.iter()
|
||||||
|
.find(|a| a["slug"] == "default")
|
||||||
|
.expect("default app must exist");
|
||||||
|
assert_eq!(default["name"], "Default");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn cross_app_isolation_at_dispatch(pool: PgPool) {
|
||||||
|
let (s, default_id) = server_with_app(pool).await;
|
||||||
|
|
||||||
|
// Two apps each create a script with the same name (per-app
|
||||||
|
// uniqueness — would have collided pre-3b).
|
||||||
|
let app_b: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "tenant-b", "name": "Tenant B" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let b_id = app_b["id"].as_str().unwrap();
|
||||||
|
s.post(&format!("/api/v1/admin/apps/{b_id}/domains"))
|
||||||
|
.json(&json!({ "pattern": "b.localhost" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
let id_default: String = s
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
&default_id,
|
||||||
|
json!({
|
||||||
|
"name": "echo",
|
||||||
|
"source": "#{ statusCode: 200, body: #{ from: \"default\" } }"
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.json::<Value>()["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
let id_b: String = s
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
b_id,
|
||||||
|
json!({
|
||||||
|
"name": "echo",
|
||||||
|
"source": "#{ statusCode: 200, body: #{ from: \"b\" } }"
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.json::<Value>()["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
s.post(&format!("/api/v1/admin/scripts/{id_default}/routes"))
|
||||||
|
.json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/echo" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
s.post(&format!("/api/v1/admin/scripts/{id_b}/routes"))
|
||||||
|
.json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/echo" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
// Same path, different host — routes land in different apps.
|
||||||
|
let from_default: Value = s.get("/echo").add_header("host", "localhost").await.json();
|
||||||
|
assert_eq!(from_default["from"], "default");
|
||||||
|
|
||||||
|
let from_b: Value = s
|
||||||
|
.get("/echo")
|
||||||
|
.add_header("host", "b.localhost")
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
assert_eq!(from_b["from"], "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn unknown_host_returns_404(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let r = s.get("/whatever").add_header("host", "nope.invalid").await;
|
||||||
|
r.assert_status_not_found();
|
||||||
|
let body: Value = r.json();
|
||||||
|
assert!(body["error"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("no app claims host"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn execute_by_id_works_without_host_claim(pool: PgPool) {
|
||||||
|
// The /api/v1/execute/{id} bypass is the implicit __internal__
|
||||||
|
// claim of every app — it MUST keep working for an app with zero
|
||||||
|
// public domain claims.
|
||||||
|
let (s, _) = server_with_app(pool).await;
|
||||||
|
let app: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "internal-only", "name": "Internal Only" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let app_id = app["id"].as_str().unwrap();
|
||||||
|
let script: Value = s
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
app_id,
|
||||||
|
json!({ "name": "x", "source": "#{ statusCode: 200, body: \"ok\" }" }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let id = script["id"].as_str().unwrap();
|
||||||
|
let r = s
|
||||||
|
.post(&format!("/api/v1/execute/{id}"))
|
||||||
|
.json(&json!({}))
|
||||||
|
.await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn duplicate_slug_creates_a_409(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
s.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "alpha", "name": "First" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let r = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "alpha", "name": "Second" }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn reserved_slug_rejected(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
for bad in ["new", "api", "admin", "login"] {
|
||||||
|
let r = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": bad, "name": "x" }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn slug_rename_keeps_old_as_redirect(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let app: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "old-slug", "name": "x" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let id = app["id"].as_str().unwrap();
|
||||||
|
s.patch(&format!("/api/v1/admin/apps/{id}"))
|
||||||
|
.json(&json!({ "slug": "new-slug" }))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
let resp: Value = s.get("/api/v1/admin/apps/old-slug").await.json();
|
||||||
|
// The old slug resolves via history and surfaces `redirect_to`.
|
||||||
|
assert_eq!(resp["redirect_to"], "new-slug");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn claiming_historical_slug_needs_force_takeover(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
// Set up a history row.
|
||||||
|
let first: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "soon-retired", "name": "x" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
s.patch(&format!(
|
||||||
|
"/api/v1/admin/apps/{}",
|
||||||
|
first["id"].as_str().unwrap()
|
||||||
|
))
|
||||||
|
.json(&json!({ "slug": "kept" }))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
|
// Plain create against the retired slug → 409.
|
||||||
|
let r = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "soon-retired", "name": "y" }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
|
|
||||||
|
// With force_takeover → 201.
|
||||||
|
let r = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "soon-retired", "name": "y", "force_takeover": true }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn shape_key_collision_rejected(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let a: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "a", "name": "A" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let b: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "b", "name": "B" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
s.post(&format!(
|
||||||
|
"/api/v1/admin/apps/{}/domains",
|
||||||
|
a["id"].as_str().unwrap()
|
||||||
|
))
|
||||||
|
.json(&json!({ "pattern": "*.example.com" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
// Parameterized form should collide with wildcard form.
|
||||||
|
let r = s
|
||||||
|
.post(&format!(
|
||||||
|
"/api/v1/admin/apps/{}/domains",
|
||||||
|
b["id"].as_str().unwrap()
|
||||||
|
))
|
||||||
|
.json(&json!({ "pattern": "{tenant}.example.com" }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn delete_app_with_scripts_returns_409(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let app: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "with-scripts", "name": "x" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let id = app["id"].as_str().unwrap();
|
||||||
|
s.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(id, json!({ "name": "s", "source": "1" })))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let r = s.delete(&format!("/api/v1/admin/apps/{id}")).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn list_scripts_filtered_by_app(pool: PgPool) {
|
||||||
|
let (s, default_id) = server_with_app(pool).await;
|
||||||
|
let other: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "filter-target", "name": "x" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let other_id = other["id"].as_str().unwrap();
|
||||||
|
|
||||||
|
s.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
&default_id,
|
||||||
|
json!({ "name": "in-default", "source": "1" }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
s.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
other_id,
|
||||||
|
json!({ "name": "in-other", "source": "1" }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
// Filter by id.
|
||||||
|
let filtered: Vec<Value> = s
|
||||||
|
.get(&format!("/api/v1/admin/scripts?app={other_id}"))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
assert_eq!(filtered.len(), 1);
|
||||||
|
assert_eq!(filtered[0]["name"], "in-other");
|
||||||
|
|
||||||
|
// Filter by slug.
|
||||||
|
let filtered_by_slug: Vec<Value> = s
|
||||||
|
.get("/api/v1/admin/scripts?app=filter-target")
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
assert_eq!(filtered_by_slug.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn execution_errors_are_still_logged(pool: PgPool) {
|
async fn execution_errors_are_still_logged(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "boom",
|
"name": "boom",
|
||||||
"source": "1 / 0",
|
"source": "1 / 0",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
|
|||||||
1109
crates/picloud/tests/authz.rs
Normal file
1109
crates/picloud/tests/authz.rs
Normal file
File diff suppressed because it is too large
Load Diff
53
crates/shared/src/app.rs
Normal file
53
crates/shared/src/app.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
//! App scoping: top-level isolation boundary for scripts, routes,
|
||||||
|
//! domains, and (forward) data. Every script and route belongs to
|
||||||
|
//! exactly one app; cross-app references are not allowed.
|
||||||
|
//!
|
||||||
|
//! See blueprint §11.5. The orchestrator dispatches via two-phase
|
||||||
|
//! lookup: `Host → app_id → route trie`.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::AppId;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct App {
|
||||||
|
pub id: AppId,
|
||||||
|
/// URL-safe identifier; appears in dashboard paths. Mutable via the
|
||||||
|
/// slug-rename flow which preserves the old slug as a permanent 301
|
||||||
|
/// in `app_slug_history`.
|
||||||
|
pub slug: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum DomainShape {
|
||||||
|
/// Exact host: `app.example.com`.
|
||||||
|
Exact,
|
||||||
|
/// Wildcard suffix: `*.example.com` matches any subdomain.
|
||||||
|
Wildcard,
|
||||||
|
/// Parameterized wildcard: `{tenant}.example.com`. Same shape as
|
||||||
|
/// `Wildcard` for collision purposes; the binding name surfaces in
|
||||||
|
/// request context (future).
|
||||||
|
Parameterized,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AppDomain {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub app_id: AppId,
|
||||||
|
/// As the user typed it: `app.example.com`, `*.example.com`, or
|
||||||
|
/// `{tenant}.example.com`.
|
||||||
|
pub pattern: String,
|
||||||
|
pub shape: DomainShape,
|
||||||
|
/// Normalized collision key. `exact:<host>` for exact; `wildcard:<suffix>`
|
||||||
|
/// for both wildcard and parameterized (parameter name is a binding,
|
||||||
|
/// not a discriminator — per blueprint §11.5).
|
||||||
|
pub shape_key: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
242
crates/shared/src/auth.rs
Normal file
242
crates/shared/src/auth.rs
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
//! Cross-crate authn/authz types — Phase 3.5, see blueprint §11.6.
|
||||||
|
//!
|
||||||
|
//! The `Principal` extracted by `manager-core::auth_middleware` lives
|
||||||
|
//! here so handlers in every crate (and, later, the v1.1 SDKs in
|
||||||
|
//! `executor-core`) can refer to the same shape without pulling in the
|
||||||
|
//! manager crate. The authorization rules themselves live in
|
||||||
|
//! `manager-core::authz` — this module is data only.
|
||||||
|
//!
|
||||||
|
//! `UserId` is a transitional alias for `AdminUserId`. Phase 3a named
|
||||||
|
//! the table `admin_users` to leave room for the v1.1 script-level
|
||||||
|
//! `users` SDK feature (see blueprint §11.4 "Naming"); from the
|
||||||
|
//! authorization layer's perspective an admin row is the principal
|
||||||
|
//! identity, so we expose the alias rather than renaming the existing
|
||||||
|
//! id type.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{AdminUserId, AppId};
|
||||||
|
|
||||||
|
/// Transitional alias — see module docs.
|
||||||
|
pub type UserId = AdminUserId;
|
||||||
|
|
||||||
|
/// Instance-wide role carried by every `admin_users` row. The DB
|
||||||
|
/// representation is `text` (`'owner'|'admin'|'member'`), checked via
|
||||||
|
/// a CHECK constraint in migration `0006_users_authz.sql`; this enum
|
||||||
|
/// is the Rust mirror.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum InstanceRole {
|
||||||
|
/// Full instance control, manage other owners, implicit `app_admin`
|
||||||
|
/// on every app. Multiple allowed.
|
||||||
|
Owner,
|
||||||
|
/// Create apps, invite users, implicit `editor` on every app. No
|
||||||
|
/// instance-settings authority and no owner-management.
|
||||||
|
Admin,
|
||||||
|
/// Invited into specific apps via `app_members` only. No app
|
||||||
|
/// creation, no invite authority. List endpoints filter strictly
|
||||||
|
/// by membership at SQL.
|
||||||
|
Member,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InstanceRole {
|
||||||
|
/// Stable string form — matches the DB CHECK constraint values
|
||||||
|
/// exactly. Used by repos and the seed/audit paths.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Owner => "owner",
|
||||||
|
Self::Admin => "admin",
|
||||||
|
Self::Member => "member",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inverse of `as_str` — used when reading a row out of Postgres.
|
||||||
|
/// Returns `None` for unknown values so the caller can decide
|
||||||
|
/// between failing loudly or skipping a bad row.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_db_str(s: &str) -> Option<Self> {
|
||||||
|
match s {
|
||||||
|
"owner" => Some(Self::Owner),
|
||||||
|
"admin" => Some(Self::Admin),
|
||||||
|
"member" => Some(Self::Member),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-app role recorded in `app_members`. Members hold zero-or-one row
|
||||||
|
/// per (user, app); owners and admins are not represented in the table
|
||||||
|
/// (their app authority is implicit via `InstanceRole`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AppRole {
|
||||||
|
/// App settings, domain claims, delete.
|
||||||
|
AppAdmin,
|
||||||
|
/// CRUD on scripts, routes, sandbox config.
|
||||||
|
Editor,
|
||||||
|
/// Read scripts + execution logs.
|
||||||
|
Viewer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppRole {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::AppAdmin => "app_admin",
|
||||||
|
Self::Editor => "editor",
|
||||||
|
Self::Viewer => "viewer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_db_str(s: &str) -> Option<Self> {
|
||||||
|
match s {
|
||||||
|
"app_admin" => Some(Self::AppAdmin),
|
||||||
|
"editor" => Some(Self::Editor),
|
||||||
|
"viewer" => Some(Self::Viewer),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API-key scope. Exactly seven values; new scopes need a blueprint
|
||||||
|
/// edit before they're added here. Wire form is the colon-separated
|
||||||
|
/// string (`"script:read"`, etc.) — matches the `text[]` stored in
|
||||||
|
/// `api_keys.scopes` and the strings shown to operators.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Scope {
|
||||||
|
ScriptRead,
|
||||||
|
ScriptWrite,
|
||||||
|
RouteWrite,
|
||||||
|
DomainManage,
|
||||||
|
LogRead,
|
||||||
|
AppAdmin,
|
||||||
|
InstanceAdmin,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scope {
|
||||||
|
pub const ALL: &'static [Scope] = &[
|
||||||
|
Scope::ScriptRead,
|
||||||
|
Scope::ScriptWrite,
|
||||||
|
Scope::RouteWrite,
|
||||||
|
Scope::DomainManage,
|
||||||
|
Scope::LogRead,
|
||||||
|
Scope::AppAdmin,
|
||||||
|
Scope::InstanceAdmin,
|
||||||
|
];
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::ScriptRead => "script:read",
|
||||||
|
Self::ScriptWrite => "script:write",
|
||||||
|
Self::RouteWrite => "route:write",
|
||||||
|
Self::DomainManage => "domain:manage",
|
||||||
|
Self::LogRead => "log:read",
|
||||||
|
Self::AppAdmin => "app:admin",
|
||||||
|
Self::InstanceAdmin => "instance:admin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_wire(s: &str) -> Option<Self> {
|
||||||
|
Self::ALL.iter().copied().find(|sc| sc.as_str() == s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True for scopes that only make sense on an unbound key — bound
|
||||||
|
/// keys (api_keys.app_id IS NOT NULL) cannot claim instance-wide
|
||||||
|
/// authority and the mint handler rejects the combination at 422.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_instance(self) -> bool {
|
||||||
|
matches!(self, Self::InstanceAdmin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom serde so the wire form is the colon-separated string. The
|
||||||
|
// stored DB value lives in a `text[]`, so the repo converts between
|
||||||
|
// `Vec<String>` and `Vec<Scope>` using `as_str`/`from_wire`.
|
||||||
|
impl Serialize for Scope {
|
||||||
|
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||||
|
s.serialize_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Scope {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||||
|
let s = String::deserialize(d)?;
|
||||||
|
Self::from_wire(&s).ok_or_else(|| serde::de::Error::custom(format!("unknown scope: {s}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved caller identity. Produced by `manager-core::auth_middleware`
|
||||||
|
/// for both the cookie-session path (then `scopes`/`app_binding` are
|
||||||
|
/// `None`) and the bearer-API-key path (then both fields carry the
|
||||||
|
/// key's constraints).
|
||||||
|
///
|
||||||
|
/// The capability check in `manager-core::authz::can` intersects
|
||||||
|
/// `instance_role` with `scopes` and `app_binding` to decide whether
|
||||||
|
/// a given `Capability` is granted.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Principal {
|
||||||
|
pub user_id: UserId,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
/// `None` for cookie sessions (no scope restriction beyond the
|
||||||
|
/// role itself); `Some` for API keys, in which case the effective
|
||||||
|
/// authority is `role ∩ scopes`.
|
||||||
|
pub scopes: Option<Vec<Scope>>,
|
||||||
|
/// `Some(app)` for keys bound to a single app at mint time. Every
|
||||||
|
/// `App*(other)` capability is denied regardless of role.
|
||||||
|
pub app_binding: Option<AppId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instance_role_round_trip() {
|
||||||
|
for role in [
|
||||||
|
InstanceRole::Owner,
|
||||||
|
InstanceRole::Admin,
|
||||||
|
InstanceRole::Member,
|
||||||
|
] {
|
||||||
|
assert_eq!(InstanceRole::from_db_str(role.as_str()), Some(role));
|
||||||
|
}
|
||||||
|
assert_eq!(InstanceRole::from_db_str("bogus"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_role_round_trip() {
|
||||||
|
for role in [AppRole::AppAdmin, AppRole::Editor, AppRole::Viewer] {
|
||||||
|
assert_eq!(AppRole::from_db_str(role.as_str()), Some(role));
|
||||||
|
}
|
||||||
|
assert_eq!(AppRole::from_db_str("bogus"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_round_trip_covers_all() {
|
||||||
|
for &scope in Scope::ALL {
|
||||||
|
assert_eq!(Scope::from_wire(scope.as_str()), Some(scope));
|
||||||
|
}
|
||||||
|
assert_eq!(Scope::from_wire("script:nope"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_is_instance_flags_only_instance_admin() {
|
||||||
|
for &scope in Scope::ALL {
|
||||||
|
let expected = scope == Scope::InstanceAdmin;
|
||||||
|
assert_eq!(scope.is_instance(), expected, "scope {}", scope.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_serde_uses_wire_form() {
|
||||||
|
let s = serde_json::to_string(&Scope::ScriptWrite).unwrap();
|
||||||
|
assert_eq!(s, "\"script:write\"");
|
||||||
|
let back: Scope = serde_json::from_str(&s).unwrap();
|
||||||
|
assert_eq!(back, Scope::ScriptWrite);
|
||||||
|
let err = serde_json::from_str::<Scope>("\"unknown\"").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("unknown scope"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,4 +11,10 @@ pub enum Error {
|
|||||||
|
|
||||||
#[error("invalid script source: {0}")]
|
#[error("invalid script source: {0}")]
|
||||||
InvalidScript(String),
|
InvalidScript(String),
|
||||||
|
|
||||||
|
#[error("app not found: {0}")]
|
||||||
|
AppNotFound(crate::AppId),
|
||||||
|
|
||||||
|
#[error("domain claim conflict: {0}")]
|
||||||
|
DomainConflict(String),
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,16 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{RequestId, ScriptId};
|
use crate::{AppId, RequestId, ScriptId};
|
||||||
|
|
||||||
/// One row in the `execution_logs` table. Same shape flows through the
|
/// One row in the `execution_logs` table. Same shape flows through the
|
||||||
/// `ExecutionLogSink` trait and the `GET /scripts/{id}/logs` response.
|
/// `ExecutionLogSink` trait and the `GET /scripts/{id}/logs` response.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ExecutionLog {
|
pub struct ExecutionLog {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
/// Owning app at the time of execution. Materialized at write time
|
||||||
|
/// so a future "move script to another app" doesn't retag history.
|
||||||
|
pub app_id: AppId,
|
||||||
pub script_id: ScriptId,
|
pub script_id: ScriptId,
|
||||||
pub request_id: RequestId,
|
pub request_id: RequestId,
|
||||||
|
|
||||||
|
|||||||
@@ -50,3 +50,6 @@ macro_rules! id_type {
|
|||||||
id_type!(ScriptId);
|
id_type!(ScriptId);
|
||||||
id_type!(ExecutionId);
|
id_type!(ExecutionId);
|
||||||
id_type!(RequestId);
|
id_type!(RequestId);
|
||||||
|
id_type!(AdminUserId);
|
||||||
|
id_type!(AppId);
|
||||||
|
id_type!(ApiKeyId);
|
||||||
|
|||||||
@@ -4,22 +4,32 @@
|
|||||||
//! that core's crate. Things here must be genuinely shared (IDs, the Script
|
//! that core's crate. Things here must be genuinely shared (IDs, the Script
|
||||||
//! entity, error roots, transport DTOs).
|
//! entity, error roots, transport DTOs).
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
|
pub mod 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 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::{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};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::ScriptId;
|
use crate::{AppId, ScriptId};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -40,6 +40,10 @@ pub enum PathKind {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Route {
|
pub struct Route {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
/// Owning app. Always equals `scripts.app_id` for the bound script.
|
||||||
|
/// Carried on the route row so the orchestrator can partition the
|
||||||
|
/// route table without joining back to scripts on every refresh.
|
||||||
|
pub app_id: AppId,
|
||||||
pub script_id: ScriptId,
|
pub script_id: ScriptId,
|
||||||
|
|
||||||
pub host_kind: HostKind,
|
pub host_kind: HostKind,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{ScriptId, ScriptSandbox};
|
use crate::{AppId, ScriptId, ScriptSandbox};
|
||||||
|
|
||||||
/// A user-uploaded Rhai script and its execution configuration.
|
/// A user-uploaded Rhai script and its execution configuration.
|
||||||
///
|
///
|
||||||
@@ -11,6 +11,10 @@ use crate::{ScriptId, ScriptSandbox};
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Script {
|
pub struct Script {
|
||||||
pub id: ScriptId,
|
pub id: ScriptId,
|
||||||
|
/// Owning app. Set on create, immutable thereafter — a "move to
|
||||||
|
/// another app" is a copy+delete, not an in-place edit (snapshot
|
||||||
|
/// semantics — see blueprint §11.5).
|
||||||
|
pub app_id: AppId,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub version: i32,
|
pub version: i32,
|
||||||
|
|||||||
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
|
||||||
|
|||||||
699
dashboard/package-lock.json
generated
699
dashboard/package-lock.json
generated
@@ -1,14 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.1.0",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.1.0",
|
"version": "0.6.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.20.2",
|
||||||
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/search": "^6.7.0",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/view": "^6.43.0",
|
||||||
|
"@lezer/highlight": "^1.2.3",
|
||||||
|
"codemirror": "^6.0.2"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"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",
|
||||||
@@ -23,7 +35,99 @@
|
|||||||
"svelte-check": "^4.1.4",
|
"svelte-check": "^4.1.4",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.20.0",
|
"typescript-eslint": "^8.20.0",
|
||||||
"vite": "^6.0.7"
|
"vite": "^6.0.7",
|
||||||
|
"vitest": "^3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/autocomplete": {
|
||||||
|
"version": "6.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz",
|
||||||
|
"integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/commands": {
|
||||||
|
"version": "6.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
|
||||||
|
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/view": "^6.27.0",
|
||||||
|
"@lezer/common": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lang-json": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@lezer/json": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/language": {
|
||||||
|
"version": "6.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||||
|
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.23.0",
|
||||||
|
"@lezer/common": "^1.5.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0",
|
||||||
|
"style-mod": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lint": {
|
||||||
|
"version": "6.9.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz",
|
||||||
|
"integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.42.0",
|
||||||
|
"crelt": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/search": {
|
||||||
|
"version": "6.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz",
|
||||||
|
"integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.37.0",
|
||||||
|
"crelt": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/state": {
|
||||||
|
"version": "6.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
||||||
|
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@marijn/find-cluster-break": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/view": {
|
||||||
|
"version": "6.43.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
|
||||||
|
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"crelt": "^1.0.6",
|
||||||
|
"style-mod": "^4.1.0",
|
||||||
|
"w3c-keyname": "^2.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
@@ -741,6 +845,63 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@lezer/common": {
|
||||||
|
"version": "1.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
||||||
|
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/highlight": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/json": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/lr": {
|
||||||
|
"version": "1.4.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||||
|
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@marijn/find-cluster-break": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@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",
|
||||||
@@ -1131,7 +1292,6 @@
|
|||||||
"integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
|
"integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||||
@@ -1174,7 +1334,6 @@
|
|||||||
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
|
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
|
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
|
||||||
"debug": "^4.4.1",
|
"debug": "^4.4.1",
|
||||||
@@ -1209,6 +1368,17 @@
|
|||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chai": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/deep-eql": "*",
|
||||||
|
"assertion-error": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/cookie": {
|
"node_modules/@types/cookie": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||||
@@ -1216,6 +1386,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/deep-eql": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -1236,7 +1413,6 @@
|
|||||||
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -1293,7 +1469,6 @@
|
|||||||
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.59.4",
|
"@typescript-eslint/scope-manager": "8.59.4",
|
||||||
"@typescript-eslint/types": "8.59.4",
|
"@typescript-eslint/types": "8.59.4",
|
||||||
@@ -1401,7 +1576,6 @@
|
|||||||
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
|
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
},
|
},
|
||||||
@@ -1532,13 +1706,127 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/expect": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/spy": "3.2.4",
|
||||||
|
"@vitest/utils": "3.2.4",
|
||||||
|
"chai": "^5.2.0",
|
||||||
|
"tinyrainbow": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/spy": "3.2.4",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^0.30.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"msw": "^2.4.9",
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"msw": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/pretty-format": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyrainbow": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/runner": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/utils": "3.2.4",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"strip-literal": "^3.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/snapshot": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "3.2.4",
|
||||||
|
"magic-string": "^0.30.17",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/spy": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyspy": "^4.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "3.2.4",
|
||||||
|
"loupe": "^3.1.4",
|
||||||
|
"tinyrainbow": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1606,6 +1894,16 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/assertion-error": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axobject-query": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -1634,6 +1932,16 @@
|
|||||||
"concat-map": "0.0.1"
|
"concat-map": "0.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cac": {
|
||||||
|
"version": "6.7.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||||
|
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/callsites": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
@@ -1644,6 +1952,23 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chai": {
|
||||||
|
"version": "5.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||||
|
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"assertion-error": "^2.0.1",
|
||||||
|
"check-error": "^2.1.1",
|
||||||
|
"deep-eql": "^5.0.1",
|
||||||
|
"loupe": "^3.1.0",
|
||||||
|
"pathval": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -1661,6 +1986,16 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/check-error": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
@@ -1687,6 +2022,21 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codemirror": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/commands": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/lint": "^6.0.0",
|
||||||
|
"@codemirror/search": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -1724,6 +2074,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crelt": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -1770,6 +2126,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/deep-eql": {
|
||||||
|
"version": "5.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||||
|
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -1794,6 +2160,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/es-module-lexer": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -1855,7 +2228,6 @@
|
|||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -2082,6 +2454,16 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esutils": {
|
"node_modules/esutils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||||
@@ -2092,6 +2474,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expect-type": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -2310,6 +2702,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/js-tokens": {
|
||||||
|
"version": "9.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||||
|
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
@@ -2425,6 +2824,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/loupe": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -2584,6 +2990,23 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pathval": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2597,7 +3020,6 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -2605,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",
|
||||||
@@ -2625,7 +3094,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -2759,7 +3227,6 @@
|
|||||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -2923,6 +3390,13 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/siginfo": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/sirv": {
|
"node_modules/sirv": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||||
@@ -2948,6 +3422,20 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackback": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/std-env": {
|
||||||
|
"version": "3.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
|
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/strip-json-comments": {
|
"node_modules/strip-json-comments": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||||
@@ -2961,6 +3449,25 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-literal": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"js-tokens": "^9.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/style-mod": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
@@ -2980,7 +3487,6 @@
|
|||||||
"integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==",
|
"integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
@@ -3058,6 +3564,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinybench": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
@@ -3075,6 +3595,36 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinypool": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || >=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyrainbow": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyspy": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/totalist": {
|
"node_modules/totalist": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||||
@@ -3117,7 +3667,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -3180,7 +3729,6 @@
|
|||||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
@@ -3250,6 +3798,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-node": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cac": "^6.7.14",
|
||||||
|
"debug": "^4.4.1",
|
||||||
|
"es-module-lexer": "^1.7.0",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vite-node": "vite-node.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vitefu": {
|
"node_modules/vitefu": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
|
||||||
@@ -3270,6 +3841,85 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/expect": "3.2.4",
|
||||||
|
"@vitest/mocker": "3.2.4",
|
||||||
|
"@vitest/pretty-format": "^3.2.4",
|
||||||
|
"@vitest/runner": "3.2.4",
|
||||||
|
"@vitest/snapshot": "3.2.4",
|
||||||
|
"@vitest/spy": "3.2.4",
|
||||||
|
"@vitest/utils": "3.2.4",
|
||||||
|
"chai": "^5.2.0",
|
||||||
|
"debug": "^4.4.1",
|
||||||
|
"expect-type": "^1.2.1",
|
||||||
|
"magic-string": "^0.30.17",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"picomatch": "^4.0.2",
|
||||||
|
"std-env": "^3.9.0",
|
||||||
|
"tinybench": "^2.9.0",
|
||||||
|
"tinyexec": "^0.3.2",
|
||||||
|
"tinyglobby": "^0.2.14",
|
||||||
|
"tinypool": "^1.1.1",
|
||||||
|
"tinyrainbow": "^2.0.0",
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
|
||||||
|
"vite-node": "3.2.4",
|
||||||
|
"why-is-node-running": "^2.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vitest": "vitest.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@edge-runtime/vm": "*",
|
||||||
|
"@types/debug": "^4.1.12",
|
||||||
|
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||||
|
"@vitest/browser": "3.2.4",
|
||||||
|
"@vitest/ui": "3.2.4",
|
||||||
|
"happy-dom": "*",
|
||||||
|
"jsdom": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@edge-runtime/vm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/debug": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/ui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"happy-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsdom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -3286,6 +3936,23 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/why-is-node-running": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"siginfo": "^2.0.0",
|
||||||
|
"stackback": "0.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"why-is-node-running": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -10,14 +10,19 @@
|
|||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lint": "prettier --check . && eslint ."
|
"lint": "prettier --check . && eslint .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"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",
|
||||||
"@types/node": "^22.10.5",
|
|
||||||
"@sveltejs/kit": "^2.17.0",
|
"@sveltejs/kit": "^2.17.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
|
"@types/node": "^22.10.5",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-svelte": "^3.0.0",
|
"eslint-plugin-svelte": "^3.0.0",
|
||||||
@@ -28,9 +33,21 @@
|
|||||||
"svelte-check": "^4.1.4",
|
"svelte-check": "^4.1.4",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.20.0",
|
"typescript-eslint": "^8.20.0",
|
||||||
"vite": "^6.0.7"
|
"vite": "^6.0.7",
|
||||||
|
"vitest": "^3.0.5"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"cookie": "^0.7.2"
|
"cookie": "^0.7.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.20.2",
|
||||||
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/search": "^6.7.0",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/view": "^6.43.0",
|
||||||
|
"@lezer/highlight": "^1.2.3",
|
||||||
|
"codemirror": "^6.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user