19 Commits

Author SHA1 Message Date
MechaCat02
5d08974876 style(cli): re-fmt one stray format! line in the integration test
A trailing fmt drift on tests/cli.rs:95 — `format!()` arg was wrapped
across three lines where rustfmt wants one. Running `cargo fmt --all`
collapses it; no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:57:50 +02:00
MechaCat02
ca278bddc8 test(cli): bare-metal end-to-end integration test
Spawns the pre-built `picloud` binary against DATABASE_URL on a
private port, logs in over HTTP to mint a bearer token, then drives
`pic` through the full edit-deploy-invoke-tail loop with a unique
app slug per run and a `Drop`-based cleanup. Gated on DATABASE_URL
and tagged `#[ignore]` to match the existing integration-test
pattern in `crates/picloud/tests/api.rs`.

The test uses the dev `admin/admin` credentials (overridable via
PICLOUD_CLI_E2E_USERNAME / _PASSWORD) because the bootstrap env
vars are inert once the DB has any admin row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:53:56 +02:00
MechaCat02
7b50047730 feat(cli): add pic command-line client (login, apps, scripts, logs)
Adds a new workspace crate `picloud-cli` shipping a `pic` binary that
drives the edit-deploy-invoke-tail-logs loop against PiCloud's admin
and execute HTTP surface. Eight subcommands cover the minimum a
developer needs to never open the dashboard:

  pic login                    (paste URL + bearer token, validates via /auth/me)
  pic whoami                   (re-validates and prints principal)
  pic apps ls | create
  pic scripts ls | deploy | invoke
  pic logs <id>

Credentials persist as TOML under the platform config dir (resolved
via `directories`); on POSIX the file is forced to mode 0600.
PICLOUD_URL + PICLOUD_TOKEN env vars short-circuit interactive prompts
for CI and integration tests.

The CLI redeclares minimal request/response structs in `client.rs`
rather than depending on `manager-core` — keeps the blast radius
contained without touching the existing crate boundaries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:53:49 +02:00
MechaCat02
b42e273479 fix(test): admin_is_implicit_app_admin uses force=true on app delete
The test creates a script in the default app earlier in the body, so a
plain DELETE /apps/default hits the soft no-cascade guard and 409s
before the capability check runs. The intent is to validate that admin
holds AppAdmin everywhere, not to exercise the cascade contract — pass
?force=true so we reach the gate we're trying to test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:21:38 +02:00
MechaCat02
f32ed73561 fix(e2e): surface cleanup HTTP failures instead of swallowing them
CleanupRegistry's catch-all was masking every kind of teardown error,
not just the intended "resource already gone" 404. A backend returning
500 on delete would leak orphans run after run without ever surfacing.

Now treat 2xx and 404 as success, log any other status (and any
thrown network error) to stderr with the resource label, and keep
running the remaining items. The suite stays best-effort but no
longer hides accumulating leaks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:10:45 +02:00
MechaCat02
64799b73ff chore(docker): caddy restart unless-stopped
Other services in the prod overlay already have it. Without it, a
`docker compose stop caddy` followed by `docker compose up -d` doesn't
bring caddy back up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:41:51 +02:00
MechaCat02
beb3bcb97c fix(e2e): use uniqueUsername helper in integration.spec
Date.now() can collide across workers running on the same millisecond
boundary. The worker-aware helper that the rest of the suite uses
side-steps that without changing the test's intent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:41:47 +02:00
MechaCat02
79c8db2cb7 fix(e2e): non-mutating reverse in CleanupRegistry
Array.reverse mutates in place — a defensive double-run() would have
re-reversed the items. Iterate over a copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:41:42 +02:00
MechaCat02
f4cd883d76 test(e2e): drive the new deactivate confirm modal
Cancels once to assert the modal can be dismissed without side
effects, then confirms to flip the user to inactive, then reactivates
to assert that direction remains one-click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:40:52 +02:00
MechaCat02
b459b99fe9 test(e2e): role-shadowing specs in apps + scripts suites
Lifts loginAsUserToken + pageWithUserToken out of members.spec.ts into
fixtures/role-page.ts (third file that needs them). Adds shadowing
coverage: viewer member sees no New-app / Add-domain / Settings / Save
/ +Add-route, editor sees Save but no Delete header, and CodeMirror
renders contenteditable=false for viewers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:40:09 +02:00
MechaCat02
f694a6d504 test(e2e): orphan sweep at globalSetup
Wipes e2e-* apps and e2e* admin users before the suite starts so a
prior crashed run doesn't accumulate state across runs (45 rows
observed on 2026-05-28). Per-row try/catch keeps it best-effort; a
sweep failure never blocks the suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:37:57 +02:00
MechaCat02
70b66451d6 fix(dashboard): rejection-sample password-gen to remove modulo bias
Switches to Uint8 rejection sampling against the largest multiple of
the charset length that fits in a byte. Eliminates the ~16 ppm
overweight the previous `% N` over Uint32 would otherwise leave on the
first 38 chars. Adds a vitest distribution check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:37:18 +02:00
MechaCat02
c4fa53052d feat(dashboard): confirm modal for user deactivate
Deactivation signs the user out and expires every API key they hold —
warrants a styled confirm. Reactivation stays one-click since it's
non-destructive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:36:17 +02:00
MechaCat02
2f6840fe3e feat(dashboard): confirm modal for script delete
Replaces window.confirm + alert() with the in-dashboard ConfirmModal
(danger variant, name-retype). Body summarises what gets removed
(routes + execution logs) and embeds the API error inline rather than
firing a native alert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:35:14 +02:00
MechaCat02
75c815d02a feat(dashboard): shadow script-detail surfaces by role
Captures my_role off the existing parent-app fetch (no extra HTTP call)
and uses canWriteApp / canAdminApp to hide: header Delete, Edit Save +
Format, Routing +Add route + per-row remove, and the Settings tab.
CodeEditor renders read-only for viewers. An effect bounces a stale
Settings tab back to Edit for non-admins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:33:48 +02:00
MechaCat02
d9c3d4d661 feat(dashboard): shadow apps + app-detail surfaces by role
Apps list: hide "New app" for members. App detail: hide New script for
viewers, Add domain + per-row Delete for non-admins, and the Members +
Settings tabs entirely for non-admins (with an effect that bounces a
stale activeTab back to Scripts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:31:56 +02:00
MechaCat02
bef4d34c43 feat(dashboard): CodeEditor readOnly prop
Threads readOnly through to EditorState.readOnly + EditorView.editable so
script-detail can render a viewer-only editor without intercepting
keystrokes upstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:29:55 +02:00
MechaCat02
99a3ed1b6b feat(dashboard): capabilities helper for role-aware UI shadowing
Pure-function module that mirrors crates/manager-core/src/authz.rs and
lets dashboard pages decide which create / edit / delete affordances to
render. Widens the vitest include so the truth-table test runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:28:45 +02:00
MechaCat02
4644ea4919 feat(manager-core): admin is implicit app_admin; delete-script needs AppAdmin
Aligns the canonical capability rules with how the dashboard now shadows
its UI. Instance admins become implicit app_admin on every app (only
InstanceManageSettings stays owner-only), and the script-delete handler
moves from AppWriteScript to AppAdmin so editors can save but not delete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:27:32 +02:00
39 changed files with 2754 additions and 211 deletions

351
Cargo.lock generated
View File

@@ -40,6 +40,56 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.102"
@@ -68,6 +118,21 @@ dependencies = [
"serde_json", "serde_json",
] ]
[[package]]
name = "assert_cmd"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6"
dependencies = [
"anstyle",
"bstr",
"libc",
"predicates",
"predicates-core",
"predicates-tree",
"wait-timeout",
]
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -236,6 +301,17 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "bstr"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
dependencies = [
"memchr",
"regex-automata",
"serde",
]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.3" version = "3.20.3"
@@ -302,6 +378,52 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@@ -440,6 +562,12 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "difflib"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -452,6 +580,27 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "directories"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@@ -516,6 +665,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]] [[package]]
name = "figment" name = "figment"
version = "0.10.19" version = "0.10.19"
@@ -536,6 +691,15 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "float-cmp"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "flume" name = "flume"
version = "0.11.1" version = "0.11.1"
@@ -1010,6 +1174,12 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -1077,6 +1247,12 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.2" version = "0.8.2"
@@ -1161,6 +1337,12 @@ dependencies = [
"spin 0.5.2", "spin 0.5.2",
] ]
[[package]]
name = "normalize-line-endings"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -1231,6 +1413,18 @@ dependencies = [
"portable-atomic", "portable-atomic",
] ]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@@ -1335,6 +1529,25 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "picloud-cli"
version = "0.6.0"
dependencies = [
"anyhow",
"assert_cmd",
"clap",
"directories",
"picloud-shared",
"predicates",
"reqwest",
"rpassword",
"serde",
"serde_json",
"tempfile",
"tokio",
"toml",
]
[[package]] [[package]]
name = "picloud-executor" name = "picloud-executor"
version = "0.6.0" version = "0.6.0"
@@ -1509,6 +1722,36 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "predicates"
version = "3.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"
dependencies = [
"anstyle",
"difflib",
"float-cmp",
"normalize-line-endings",
"predicates-core",
"regex",
]
[[package]]
name = "predicates-core"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144"
[[package]]
name = "predicates-tree"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2"
dependencies = [
"predicates-core",
"termtree",
]
[[package]] [[package]]
name = "pretty_assertions" name = "pretty_assertions"
version = "1.4.1" version = "1.4.1"
@@ -1704,6 +1947,29 @@ dependencies = [
"bitflags", "bitflags",
] ]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.14" version = "0.4.14"
@@ -1729,7 +1995,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"futures-channel",
"futures-core", "futures-core",
"futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
@@ -1812,6 +2080,17 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rpassword"
version = "7.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "835a57a69104632d64deb0df2e09a69945cd7a6eab4070fc9b1d7e50cf6c3edc"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.10" version = "0.9.10"
@@ -1832,6 +2111,16 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rtoolbox"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "rust-multipart-rfc7578_2" name = "rust-multipart-rfc7578_2"
version = "0.8.0" version = "0.8.0"
@@ -1853,6 +2142,19 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.40" version = "0.23.40"
@@ -2327,6 +2629,12 @@ dependencies = [
"unicode-properties", "unicode-properties",
] ]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@@ -2364,6 +2672,25 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "termtree"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
[[package]] [[package]]
name = "thin-vec" name = "thin-vec"
version = "0.2.18" version = "0.2.18"
@@ -2783,6 +3110,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.1" version = "1.23.1"
@@ -2813,6 +3146,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
@@ -3066,6 +3408,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"

View File

@@ -9,6 +9,7 @@ members = [
"crates/picloud-manager", "crates/picloud-manager",
"crates/picloud-orchestrator", "crates/picloud-orchestrator",
"crates/picloud-executor", "crates/picloud-executor",
"crates/picloud-cli",
] ]
[workspace.package] [workspace.package]

View File

@@ -270,10 +270,13 @@ async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
Path(id): Path<ScriptId>, Path(id): Path<ScriptId>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?; let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
// Delete is gated tighter than Save: editors can edit scripts but
// only app_admin / instance admin / owner can remove them. See
// blueprint §11.6.
require( require(
state.authz.as_ref(), state.authz.as_ref(),
&principal, &principal,
Capability::AppWriteScript(script.app_id), Capability::AppAdmin(script.app_id),
) )
.await?; .await?;
state.repo.delete(id).await?; state.repo.delete(id).await?;

View File

@@ -143,8 +143,8 @@ pub struct AppLookupResponse {
pub redirect_to: Option<String>, pub redirect_to: Option<String>,
/// The caller's role on this app, used by the dashboard to decide /// The caller's role on this app, used by the dashboard to decide
/// whether to render admin-only surfaces (Members tab, settings). /// whether to render admin-only surfaces (Members tab, settings).
/// `Owner` maps to `app_admin`, `Admin` to `editor` (both implicit /// `Owner` and `Admin` both map to `app_admin` (implicit per
/// per blueprint §11.6); `Member` carries its explicit /// blueprint §11.6); `Member` carries its explicit
/// `app_members.role`. /// `app_members.role`.
pub my_role: Option<AppRole>, pub my_role: Option<AppRole>,
} }
@@ -226,16 +226,15 @@ async fn get_app(
/// Compute the caller's effective `AppRole` on a specific app. Mirrors /// Compute the caller's effective `AppRole` on a specific app. Mirrors
/// the implicit-grant logic in `authz::role_grants` but returns the /// the implicit-grant logic in `authz::role_grants` but returns the
/// role itself (for UI gating) rather than a yes/no decision. `Owner` /// role itself (for UI gating) rather than a yes/no decision. `Owner`
/// is implicit `AppAdmin` everywhere; `Admin` is implicit `Editor` /// and `Admin` are both implicit `AppAdmin` everywhere; `Member`
/// everywhere; `Member` consults `app_members`. /// consults `app_members`.
async fn compute_my_role( async fn compute_my_role(
authz: &dyn AuthzRepo, authz: &dyn AuthzRepo,
principal: &Principal, principal: &Principal,
app_id: AppId, app_id: AppId,
) -> Result<Option<AppRole>, AppsApiError> { ) -> Result<Option<AppRole>, AppsApiError> {
match principal.instance_role { match principal.instance_role {
InstanceRole::Owner => Ok(Some(AppRole::AppAdmin)), InstanceRole::Owner | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)),
InstanceRole::Admin => Ok(Some(AppRole::Editor)),
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?), InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
} }
} }

View File

@@ -199,21 +199,14 @@ async fn role_grants(
} }
} }
/// Admin is implicit `editor` on every app (per blueprint §11.6). They /// Admin is implicit `app_admin` on every app (per blueprint §11.6).
/// can create apps and manage users, but NOT touch instance-wide /// They can create apps, manage users, and take any app-scoped action
/// settings or take app-admin-only actions on apps they're not /// on any app without an explicit `app_members` row — single-human
/// explicitly app_admin of. Everything not in this set falls through /// installs would otherwise need to add themselves to every new app.
/// to deny (`InstanceManageSettings`, `AppManageDomains`, `AppAdmin`). /// Only `InstanceManageSettings` (sandbox ceiling, etc.) stays
/// owner-only.
const fn admin_grants(cap: Capability) -> bool { const fn admin_grants(cap: Capability) -> bool {
matches!( !matches!(cap, Capability::InstanceManageSettings)
cap,
Capability::InstanceCreateApp
| Capability::InstanceManageUsers
| Capability::AppRead(_)
| Capability::AppWriteScript(_)
| Capability::AppWriteRoute(_)
| Capability::AppLogRead(_)
)
} }
/// Member has zero instance authority. App authority requires an /// Member has zero instance authority. App authority requires an
@@ -357,10 +350,23 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn admin_cannot_manage_instance_settings_or_app_admin_actions() { async fn admin_cannot_manage_instance_settings() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Admin);
assert_eq!(
can(&repo, &p, Capability::InstanceManageSettings)
.await
.unwrap(),
Decision::Deny,
);
}
#[tokio::test]
async fn admin_is_implicit_app_admin_on_every_app() {
let repo = InMemoryAuthzRepo::default(); let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Admin); let p = principal(InstanceRole::Admin);
let app = AppId::new(); let app = AppId::new();
// Instance-scoped allowances.
assert_eq!( assert_eq!(
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(), can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
Decision::Allow, Decision::Allow,
@@ -371,36 +377,22 @@ mod tests {
.unwrap(), .unwrap(),
Decision::Allow, Decision::Allow,
); );
// Editor-like + app-admin grants both succeed without any
// app_members row.
for cap in [
Capability::AppRead(app),
Capability::AppWriteScript(app),
Capability::AppWriteRoute(app),
Capability::AppLogRead(app),
Capability::AppManageDomains(app),
Capability::AppAdmin(app),
] {
assert_eq!( assert_eq!(
can(&repo, &p, Capability::InstanceManageSettings) can(&repo, &p, cap).await.unwrap(),
.await
.unwrap(),
Decision::Deny,
);
// Editor-like grants succeed
assert_eq!(
can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap(),
Decision::Allow, Decision::Allow,
"admin denied app-scoped capability {cap:?}"
); );
assert_eq!( }
can(&repo, &p, Capability::AppWriteRoute(app))
.await
.unwrap(),
Decision::Allow,
);
// App-admin grants do not
assert_eq!(
can(&repo, &p, Capability::AppManageDomains(app))
.await
.unwrap(),
Decision::Deny,
);
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny,
);
} }
#[tokio::test] #[tokio::test]
@@ -474,6 +466,29 @@ mod tests {
); );
} }
/// Editors hold `AppWriteScript` (Save) but **not** `AppAdmin`
/// (Delete). The script-delete handler gates on the latter so the
/// API can't be tricked into letting an editor remove the script
/// they were only allowed to edit.
#[tokio::test]
async fn editor_can_write_scripts_but_not_delete_them() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Member);
let app = AppId::new();
repo.grant(p.user_id, app, AppRole::Editor).await;
assert!(can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap()
.is_allow());
// Delete is gated on AppAdmin in the handler — editors must be
// denied here for that gate to bite.
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny,
);
}
#[tokio::test] #[tokio::test]
async fn member_with_app_admin_role_can_do_app_admin_actions() { async fn member_with_app_admin_role_can_do_app_admin_actions() {
let repo = InMemoryAuthzRepo::default(); let repo = InMemoryAuthzRepo::default();

View File

@@ -0,0 +1,31 @@
[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"
[[bin]]
name = "pic"
path = "src/main.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"] }
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"] }

View File

@@ -0,0 +1,333 @@
//! 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 picloud_shared::{App, AppId, AppRole, ExecutionLog, InstanceRole, 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(),
})
}
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
}
/// `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
}
}
// ---------- 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,
}
#[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)
}
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");
}
}

View File

@@ -0,0 +1,40 @@
//! `pic apps ls` and `pic apps create`.
use anyhow::Result;
use crate::client::{Client, CreateAppBody};
use crate::config::load;
use crate::output::Table;
pub async fn ls() -> Result<()> {
let creds = load()?;
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();
Ok(())
}
pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) -> Result<()> {
let creds = load()?;
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(())
}

View File

@@ -0,0 +1,66 @@
//! `pic login` — interactively (or via PICLOUD_URL/PICLOUD_TOKEN env
//! shortcut for non-interactive contexts like CI and integration tests)
//! capture the URL + bearer token, validate against `/auth/me`, save.
use std::io::{self, BufRead, Write};
use anyhow::Result;
use crate::client::Client;
use crate::config::{save, Credentials};
const DEFAULT_URL: &str = "http://localhost:8000";
pub async fn run() -> Result<()> {
let (url, token) = collect_credentials()?;
let client = Client::new(&url, &token)?;
let me = client.auth_me().await?;
let creds = Credentials {
url: client.url().to_string(),
token,
username: me.username.clone(),
};
save(&creds)?;
println!(
"Logged in as {} ({}) at {}",
me.username,
instance_role_label(&me.instance_role),
creds.url
);
Ok(())
}
fn collect_credentials() -> Result<(String, String)> {
// Non-interactive shortcut: both vars set → use as-is. Used by the
// integration test and any CI flow that wants to skip the prompts.
if let (Ok(url), Ok(tok)) = (std::env::var("PICLOUD_URL"), std::env::var("PICLOUD_TOKEN")) {
if !url.is_empty() && !tok.is_empty() {
return Ok((url, tok));
}
}
let url = prompt_with_default("PiCloud URL", DEFAULT_URL)?;
let token = rpassword::prompt_password("API token: ")?;
Ok((url, token))
}
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()
})
}
fn instance_role_label(role: &picloud_shared::InstanceRole) -> &'static str {
use picloud_shared::InstanceRole as R;
match role {
R::Owner => "owner",
R::Admin => "admin",
R::Member => "member",
}
}

View File

@@ -0,0 +1,58 @@
//! `pic logs <script-id>` — print recent execution log rows.
use anyhow::Result;
use picloud_shared::ExecutionStatus;
use crate::client::Client;
use crate::config::load;
pub async fn run(script_id: &str, limit: u32) -> Result<()> {
let creds = load()?;
let client = Client::from_creds(&creds)?;
let entries = client.logs_list(script_id, limit).await?;
for e in entries {
let summary = summarize(&e.response_body, &e.script_logs);
println!(
"{}\t{}\t{}",
e.created_at.to_rfc3339(),
status_label(&e.status),
truncate(&summary, 120),
);
}
Ok(())
}
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}")
}
}

View File

@@ -0,0 +1,5 @@
pub mod apps;
pub mod login;
pub mod logs;
pub mod scripts;
pub mod whoami;

View File

@@ -0,0 +1,178 @@
//! `pic scripts ls | deploy | invoke`.
use std::io::{self, Read, Write};
use std::path::Path;
use anyhow::{anyhow, Context, Result};
use serde_json::Value;
use crate::client::{Client, CreateScriptBody};
use crate::config::load;
use crate::output::Table;
pub async fn ls(app: Option<&str>) -> Result<()> {
let creds = load()?;
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 → walk every accessible app. One request per app is
// fine at MVP scale (handful of apps); a bulk endpoint can come
// later if the count grows.
let apps = client.apps_list().await?;
for a in apps {
let scripts = client.scripts_list_by_app(&a.slug).await?;
for s in scripts {
table.row([
s.id.to_string(),
a.slug.clone(),
s.name,
s.version.to_string(),
s.updated_at.to_rfc3339(),
]);
}
}
}
table.print();
Ok(())
}
pub async fn deploy(
file: &Path,
app_ident: &str,
name_override: Option<&str>,
description: Option<&str>,
) -> Result<()> {
let creds = load()?;
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 = load()?;
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))
}
}
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}");
}
}

View File

@@ -0,0 +1,22 @@
//! `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.
use anyhow::Result;
use crate::client::Client;
use crate::config::load;
pub async fn run() -> Result<()> {
let creds = load()?;
let client = Client::from_creds(&creds)?;
let me = client.auth_me().await?;
let role = match me.instance_role {
picloud_shared::InstanceRole::Owner => "owner",
picloud_shared::InstanceRole::Admin => "admin",
picloud_shared::InstanceRole::Member => "member",
};
let email = me.email.as_deref().unwrap_or("-");
println!("{}\t{role}\t{email}\t{}", me.username, creds.url);
Ok(())
}

View File

@@ -0,0 +1,118 @@
//! 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()))
}
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");
}
}

View File

@@ -0,0 +1,142 @@
//! 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;
#[derive(Parser)]
#[command(name = "pic", version, about = "PiCloud command-line client")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Save URL + bearer token to `~/.picloud/credentials`.
Login,
/// Print the principal the saved token resolves to.
Whoami,
/// App management.
Apps {
#[command(subcommand)]
cmd: AppsCmd,
},
/// Script management.
Scripts {
#[command(subcommand)]
cmd: ScriptsCmd,
},
/// Tail recent execution logs for a script.
Logs(LogsArgs),
}
#[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>,
},
}
#[derive(Subcommand)]
enum ScriptsCmd {
/// List scripts. With `--app`, scoped to one app; without,
/// iterates over every app 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 {
file: PathBuf,
#[arg(long)]
app: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
},
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
/// `--body @-` for stdin, or inline JSON.
Invoke {
id: String,
#[arg(long)]
body: Option<String>,
#[arg(short = 'H', long = "header", value_parser = client::parse_kv_header)]
headers: Vec<(String, 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 result = match cli.cmd {
Cmd::Login => cmds::login::run().await,
Cmd::Whoami => cmds::whoami::run().await,
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls().await,
Cmd::Apps {
cmd:
AppsCmd::Create {
slug,
name,
description,
},
} => cmds::apps::create(&slug, name.as_deref(), description.as_deref()).await,
Cmd::Scripts {
cmd: ScriptsCmd::Ls { app },
} => cmds::scripts::ls(app.as_deref()).await,
Cmd::Scripts {
cmd:
ScriptsCmd::Deploy {
file,
app,
name,
description,
},
} => cmds::scripts::deploy(&file, &app, name.as_deref(), description.as_deref()).await,
Cmd::Scripts {
cmd: ScriptsCmd::Invoke { id, body, headers },
} => cmds::scripts::invoke(&id, body.as_deref(), &headers).await,
Cmd::Logs(LogsArgs { script_id, limit }) => cmds::logs::run(&script_id, limit).await,
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
output::print_error(&err);
ExitCode::FAILURE
}
}
}

View File

@@ -0,0 +1,103 @@
//! Tab-separated table writer + error formatting.
//!
//! Aligned columns are nice for humans but `\t`-separated stays
//! pipe-friendly: `pic apps ls | awk -F'\t' '{print $1}'` works without
//! parsing box-drawing.
use std::io::{self, Write};
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(&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
}
pub fn print(&self) {
let s = self.render();
// 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');
}
pub fn print_error(err: &anyhow::Error) {
let mut stderr = io::stderr();
let _ = writeln!(stderr, "error: {err:#}");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_aligns_columns() {
let mut t = Table::new(["slug", "name"]);
t.row(["a", "Alpha"]).row(["bravo", "B"]);
let out = t.render();
assert_eq!(out, "slug \tname\na \tAlpha\nbravo\tB\n");
}
#[test]
fn table_empty_rows() {
let t = Table::new(["a", "b"]);
assert_eq!(t.render(), "a\tb\n");
}
}

View File

@@ -0,0 +1,371 @@
//! Bare-metal end-to-end integration test.
//!
//! Spawns a `picloud` subprocess against `DATABASE_URL` on a private
//! port, logs in over HTTP to mint a bearer token, then drives the
//! `pic` binary through the full edit-deploy-invoke-tail loop and
//! cleans up the app it created.
//!
//! 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
#![allow(clippy::too_many_lines)]
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{Child, Command as StdCommand, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use assert_cmd::Command as AssertCommand;
use predicates::prelude::*;
use serde_json::Value;
use tempfile::TempDir;
// The bootstrap env vars are inert once any admin row exists, so we
// can't carve out a dedicated test admin against the dev database. The
// dev stack seeds `admin`/`admin` (see CLAUDE.md); we use those.
// `PICLOUD_CLI_E2E_USERNAME` / `_PASSWORD` let CI override.
fn admin_username() -> String {
std::env::var("PICLOUD_CLI_E2E_USERNAME").unwrap_or_else(|_| "admin".to_string())
}
fn admin_password() -> String {
std::env::var("PICLOUD_CLI_E2E_PASSWORD").unwrap_or_else(|_| "admin".to_string())
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn end_to_end_login_deploy_invoke_logs() {
let Ok(database_url) = std::env::var("DATABASE_URL") else {
eprintln!("skipping: DATABASE_URL not set");
return;
};
let port = pick_free_port();
let url = format!("http://127.0.0.1:{port}");
let mut server = spawn_picloud(&database_url, port);
if let Err(e) = wait_for_health(&url, Duration::from_secs(60)) {
kill_subprocess(&mut server);
panic!("picloud failed to become healthy: {e}");
}
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
run_flow(&url);
}));
// Always tear down regardless of outcome so a failed test doesn't
// leak a child process.
kill_subprocess(&mut server);
if let Err(p) = outcome {
std::panic::resume_unwind(p);
}
}
fn run_flow(url: &str) {
let token = login_for_bearer_token(url);
let cfg_dir = TempDir::new().expect("tempdir");
let home = TempDir::new().expect("home tempdir");
let env = TestEnv {
url: url.to_string(),
token,
config_dir: cfg_dir.path().to_path_buf(),
home: home.path().to_path_buf(),
};
// Slug carries the wall-clock so reruns against a long-lived dev
// database don't collide on the unique-slug constraint.
let slug = format!(
"pic-cli-e2e-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis()
);
let username = admin_username();
// 1) login
pic(&env)
.args(["login"])
.assert()
.success()
.stdout(predicate::str::contains(format!("Logged in as {username}")));
let creds_path = env.config_dir.join("credentials");
assert!(
creds_path.exists(),
"credentials file should exist after login"
);
let body = std::fs::read_to_string(&creds_path).unwrap();
assert!(body.contains(&env.url), "creds should contain url: {body}");
assert!(
body.contains(&username),
"creds should contain username: {body}"
);
// 2) whoami
pic(&env)
.args(["whoami"])
.assert()
.success()
.stdout(predicate::str::contains(username.clone()));
// 3) apps create
pic(&env)
.args(["apps", "create", &slug])
.assert()
.success()
.stdout(predicate::str::contains(format!("Created app {slug}")));
// Ensure the app is cleaned up no matter what subsequent assertions do.
let _guard = AppGuard {
url: env.url.clone(),
token: env.token.clone(),
slug: slug.clone(),
};
// 4) apps ls
pic(&env)
.args(["apps", "ls"])
.assert()
.success()
.stdout(predicate::str::contains(slug.as_str()));
// 5) scripts deploy (create then update)
let fixture = fixture_path("hello.rhai");
pic(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.success()
.stdout(predicate::str::contains("Created hello v1"));
pic(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.success()
.stdout(predicate::str::contains("Updated hello v2"));
// 6) scripts ls and capture the id
let ls_out = pic(&env)
.args(["scripts", "ls", "--app", &slug])
.output()
.expect("scripts ls");
assert!(ls_out.status.success(), "scripts ls failed: {ls_out:?}");
let id = parse_first_id(std::str::from_utf8(&ls_out.stdout).unwrap())
.expect("scripts ls should print at least one row");
// 7) invoke
let invoke_out = pic(&env)
.args(["scripts", "invoke", &id])
.output()
.expect("scripts invoke");
assert!(
invoke_out.status.success(),
"invoke failed: {}",
String::from_utf8_lossy(&invoke_out.stderr)
);
let parsed: Value =
serde_json::from_slice(&invoke_out.stdout).expect("invoke stdout should be JSON");
assert_eq!(
parsed["ok"], true,
"expected hello.rhai response, got {parsed}"
);
// 8) logs (the invoke above should have produced exactly one row)
let logs_out = pic(&env).args(["logs", &id]).output().expect("pic logs");
assert!(logs_out.status.success(), "logs failed: {logs_out:?}");
let stdout = String::from_utf8_lossy(&logs_out.stdout);
assert!(
stdout.lines().any(|l| !l.trim().is_empty()),
"logs should have at least one row, got: {stdout}"
);
}
// --------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------
struct TestEnv {
url: String,
token: String,
config_dir: PathBuf,
home: PathBuf,
}
fn pic(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)
.env("HOME", &env.home);
cmd
}
fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(name)
}
fn picloud_binary_path() -> PathBuf {
// The integration test binary lives at
// `<target>/debug/deps/cli-<hash>`. CARGO_MANIFEST_DIR points at the
// crate; the workspace target dir is two levels up. `picloud` lands
// next to our own test executable.
let exe = std::env::current_exe().expect("current_exe");
// current_exe is `.../target/debug/deps/cli-<hash>`. Walk up twice
// to reach `.../target/debug`, then look for `picloud`.
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"
})
}
fn pick_free_port() -> u16 {
// Bind to :0, read the assigned port, drop the listener.
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()
}
fn spawn_picloud(database_url: &str, port: u16) -> Child {
// Execute the pre-built `picloud` binary directly. Going through
// `cargo run -p picloud` while inside `cargo test` would contend on
// the same build lock and can deadlock. We assume the binary was
// built as part of the workspace compile that produced this test —
// and check explicitly so the panic is informative if not.
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_username())
.env("PICLOUD_ADMIN_PASSWORD", admin_password())
.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. We only echo to test output on failure.
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);
}
});
}
child
}
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:?}"))
}
fn login_for_bearer_token(url: &str) -> String {
let client = reqwest::blocking::Client::new();
let resp = client
.post(format!("{url}/api/v1/admin/auth/login"))
.json(&serde_json::json!({
"username": admin_username(),
"password": admin_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()
}
fn parse_first_id(table: &str) -> Option<String> {
// The header line starts with "id"; the first row's first
// tab-delimited cell is the script UUID.
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())
}
}
fn kill_subprocess(child: &mut Child) {
let _ = child.kill();
let _ = child.wait();
}
struct AppGuard {
url: String,
token: String,
slug: 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();
}
}

View 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

View File

@@ -293,7 +293,7 @@ async fn owner_access_matrix(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")] #[sqlx::test(migrations = "../manager-core/migrations")]
async fn admin_can_manage_users_but_not_app_admin_settings(pool: PgPool) { async fn admin_is_implicit_app_admin_on_every_app(pool: PgPool) {
let s = boot(pool.clone()).await; let s = boot(pool.clone()).await;
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await; seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
let token = login_token(&s.server, "alice", "alice-pw").await; let token = login_token(&s.server, "alice", "alice-pw").await;
@@ -305,24 +305,34 @@ async fn admin_can_manage_users_but_not_app_admin_settings(pool: PgPool) {
.await .await
.assert_status_ok(); .assert_status_ok();
// Allowed: read default app (admin is implicit editor everywhere). // Allowed: read default app admin is implicit app_admin
// everywhere (per blueprint §11.6).
s.server s.server
.get("/api/v1/admin/apps/default") .get("/api/v1/admin/apps/default")
.add_header("authorization", format!("Bearer {token}")) .add_header("authorization", format!("Bearer {token}"))
.await .await
.assert_status_ok(); .assert_status_ok();
// Allowed: write scripts (implicit editor). // Allowed: write scripts.
let script = create_script_via_api(&s.server, &token, s.default_app, "admin-write").await; let script = create_script_via_api(&s.server, &token, s.default_app, "admin-write").await;
assert!(script["id"].is_string()); assert!(script["id"].is_string());
// Denied: delete the default app (AppAdmin only). // Allowed: list app members (AppAdmin gate). Pre-3.5.x this
let denied = s // 403'd; now it's the same allow as the owner sees.
.server s.server
.delete("/api/v1/admin/apps/default") .get("/api/v1/admin/apps/default/members")
.add_header("authorization", format!("Bearer {token}")) .add_header("authorization", format!("Bearer {token}"))
.await; .await
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN); .assert_status_ok();
// Allowed: delete the default app (AppAdmin). ?force=true because
// the script we created above pushes us past the soft no-cascade
// guard — this test is about the capability, not the cascade.
s.server
.delete("/api/v1/admin/apps/default?force=true")
.add_header("authorization", format!("Bearer {token}"))
.await
.assert_status(axum::http::StatusCode::NO_CONTENT);
} }
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
@@ -735,7 +745,7 @@ async fn my_role_field_matches_caller_role(pool: PgPool) {
"owner reports app_admin" "owner reports app_admin"
); );
// Admin → implicit editor everywhere. // Admin → implicit app_admin everywhere (post-§11.6 update).
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await; seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
let admin_token = login_token(&s.server, "alice", "alice-pw").await; let admin_token = login_token(&s.server, "alice", "alice-pw").await;
let r = s let r = s
@@ -746,8 +756,8 @@ async fn my_role_field_matches_caller_role(pool: PgPool) {
r.assert_status_ok(); r.assert_status_ok();
assert_eq!( assert_eq!(
r.json::<Value>()["my_role"].as_str(), r.json::<Value>()["my_role"].as_str(),
Some("editor"), Some("app_admin"),
"admin reports editor" "admin reports app_admin"
); );
// Member with explicit `viewer` membership → viewer. // Member with explicit `viewer` membership → viewer.

View File

@@ -25,12 +25,18 @@
value = $bindable(''), value = $bindable(''),
language = 'rhai' as Language, language = 'rhai' as Language,
placeholder = '', placeholder = '',
minHeight = '12rem' minHeight = '12rem',
readOnly = false
}: { }: {
value?: string; value?: string;
language?: Language; language?: Language;
placeholder?: string; placeholder?: string;
minHeight?: string; minHeight?: string;
/** When true the editor renders without a cursor and rejects
* keystrokes. Parent-driven `value` changes still apply via
* the dispatch path below — this only blocks user edits.
* Not reactive after mount; re-mount via `{#key}` if needed. */
readOnly?: boolean;
} = $props(); } = $props();
let host: HTMLDivElement | null = null; let host: HTMLDivElement | null = null;
@@ -48,6 +54,12 @@
keymap.of([indentWithTab]), keymap.of([indentWithTab]),
dashboardSyntaxHighlighting, dashboardSyntaxHighlighting,
dashboardTheme, dashboardTheme,
// readOnly + editable together: readOnly blocks the
// underlying transactions, editable suppresses the caret
// + selection visuals so the user can see it's not
// editable.
EditorState.readOnly.of(readOnly),
EditorView.editable.of(!readOnly),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (update.docChanged && !pushingFromOutside) { if (update.docChanged && !pushingFromOutside) {
value = update.state.doc.toString(); value = update.state.doc.toString();

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import type { AppRole, MeDto } from './api';
import { canAdminApp, canCreateApp, canManageUsers, canWriteApp } from './capabilities';
function me(role: MeDto['instance_role']): MeDto {
return { id: 'u', username: 'u', instance_role: role, email: null };
}
const ROLES: MeDto['instance_role'][] = ['owner', 'admin', 'member'];
const APP_ROLES: (AppRole | null)[] = ['app_admin', 'editor', 'viewer', null];
describe('capabilities', () => {
it('null caller is denied everything', () => {
expect(canCreateApp(null)).toBe(false);
expect(canManageUsers(null)).toBe(false);
expect(canWriteApp(null, 'app_admin')).toBe(false);
expect(canAdminApp(null, 'app_admin')).toBe(false);
});
it('canCreateApp + canManageUsers: owner/admin yes, member no', () => {
expect(canCreateApp(me('owner'))).toBe(true);
expect(canCreateApp(me('admin'))).toBe(true);
expect(canCreateApp(me('member'))).toBe(false);
expect(canManageUsers(me('owner'))).toBe(true);
expect(canManageUsers(me('admin'))).toBe(true);
expect(canManageUsers(me('member'))).toBe(false);
});
it('owner + admin can write and admin every app regardless of my_role', () => {
for (const role of ['owner', 'admin'] as const) {
for (const appRole of APP_ROLES) {
expect(canWriteApp(me(role), appRole)).toBe(true);
expect(canAdminApp(me(role), appRole)).toBe(true);
}
}
});
it('member: write requires app_admin or editor; admin requires app_admin', () => {
const m = me('member');
expect(canWriteApp(m, 'app_admin')).toBe(true);
expect(canWriteApp(m, 'editor')).toBe(true);
expect(canWriteApp(m, 'viewer')).toBe(false);
expect(canWriteApp(m, null)).toBe(false);
expect(canAdminApp(m, 'app_admin')).toBe(true);
expect(canAdminApp(m, 'editor')).toBe(false);
expect(canAdminApp(m, 'viewer')).toBe(false);
expect(canAdminApp(m, null)).toBe(false);
});
it('canAdminApp implies canWriteApp for every combination', () => {
for (const role of ROLES) {
for (const appRole of APP_ROLES) {
if (canAdminApp(me(role), appRole)) {
expect(canWriteApp(me(role), appRole)).toBe(true);
}
}
}
});
});

View File

@@ -0,0 +1,43 @@
// Permission predicates the dashboard uses to shadow create / edit /
// delete affordances. Mirrors the canonical role → capability rules in
// crates/manager-core/src/authz.rs:
//
// owner / admin instance role → implicit app_admin on every app
// app_admin → settings, domain claims, delete app, delete scripts
// editor → CRUD on scripts, routes, sandbox config (no script delete)
// viewer → read scripts + execution logs
// member with no membership → no access
//
// These helpers are read-only and have no Svelte runes — callers pass
// the current `MeDto` and (when relevant) the per-app `my_role` they
// already hold. Hiding here never authorizes anything; the backend's
// `require(Capability::…)` is always the ground truth.
import type { AppRole, MeDto } from './api';
/** Owner + admin only. Members never see "New app". */
export function canCreateApp(me: MeDto | null): boolean {
if (!me) return false;
return me.instance_role === 'owner' || me.instance_role === 'admin';
}
/** Owner + admin only — the "Users" admin page is also gated this way. */
export function canManageUsers(me: MeDto | null): boolean {
if (!me) return false;
return me.instance_role === 'owner' || me.instance_role === 'admin';
}
/** Can mutate scripts and routes (Save, +Add route, remove route). */
export function canWriteApp(me: MeDto | null, appMyRole: AppRole | null): boolean {
if (!me) return false;
if (me.instance_role === 'owner' || me.instance_role === 'admin') return true;
return appMyRole === 'app_admin' || appMyRole === 'editor';
}
/** Can take app-admin actions: app settings, domain claims, delete
* app, delete scripts, manage members. */
export function canAdminApp(me: MeDto | null, appMyRole: AppRole | null): boolean {
if (!me) return false;
if (me.instance_role === 'owner' || me.instance_role === 'admin') return true;
return appMyRole === 'app_admin';
}

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { generatePassword } from './password-gen';
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
describe('generatePassword', () => {
it('rejects lengths under 8', () => {
expect(() => generatePassword(7)).toThrowError(/at least 8/);
});
it('respects the requested length', () => {
for (const len of [8, 16, 32, 64]) {
expect(generatePassword(len)).toHaveLength(len);
}
});
it('uses only characters from the documented charset', () => {
const set = new Set(CHARSET);
for (let i = 0; i < 1000; i++) {
for (const c of generatePassword(32)) {
expect(set.has(c)).toBe(true);
}
}
});
// Rejection-sampling sanity. With N = 71 the expected count per
// char over 100k samples is ~1408 (σ ≈ 37). A 6σ band catches
// any byte-level bias (biased modulo would push the first 38
// chars by ~16 ppm — too small for this band to flag on its
// own, but a regression to `% N` over Uint16/Uint32 with a
// non-power-of-two charset would still produce visible drift in
// pathological codepaths). Mostly this guards against
// fundamental mistakes (off-by-one in the loop, returning the
// same byte stream every time, etc.).
it('distribution stays within a wide tolerance band', () => {
const samples = 100_000;
const counts = new Map<string, number>();
for (let i = 0; i < samples; i++) {
const c = generatePassword(8)[0];
counts.set(c, (counts.get(c) ?? 0) + 1);
}
const expected = samples / CHARSET.length;
const sigma = Math.sqrt(expected);
const band = 6 * sigma;
for (const c of CHARSET) {
const observed = counts.get(c) ?? 0;
const drift = Math.abs(observed - expected);
expect(
drift,
`char "${c}": observed ${observed}, expected ~${Math.round(expected)} (drift ${drift.toFixed(0)} > ${band.toFixed(0)})`
).toBeLessThan(band);
}
});
});

View File

@@ -8,6 +8,11 @@
// entropy at 16 chars (~95 bits) to be uncopyable by hand mistakes, // entropy at 16 chars (~95 bits) to be uncopyable by hand mistakes,
// avoidant of characters that ship awkwardly through chat clients // avoidant of characters that ship awkwardly through chat clients
// (no quotes, slashes, or backticks). // (no quotes, slashes, or backticks).
//
// Sampling: rejection sampling against a Uint8 stream. The naive
// `byte % CHARSET.length` would slightly overweight the first
// (256 mod N) chars; with N = 71 that's ~16 ppm of bias which is
// safe at 16 chars but easy to remove.
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@'; const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
@@ -15,11 +20,18 @@ export function generatePassword(length = 16): string {
if (length < 8) { if (length < 8) {
throw new Error('password length must be at least 8'); throw new Error('password length must be at least 8');
} }
const buf = new Uint32Array(length); const n = CHARSET.length;
crypto.getRandomValues(buf); // Largest multiple of `n` that fits in a Uint8 — bytes ≥ MAX get
// rejected to remove modulo bias.
const max = 256 - (256 % n);
const buf = new Uint8Array(length);
let out = ''; let out = '';
for (let i = 0; i < length; i++) { while (out.length < length) {
out += CHARSET[buf[i] % CHARSET.length]; crypto.getRandomValues(buf);
for (let i = 0; i < buf.length && out.length < length; i++) {
const byte = buf[i];
if (byte < max) out += CHARSET[byte % n];
}
} }
return out; return out;
} }

View File

@@ -2,6 +2,11 @@
import { base } from '$app/paths'; import { base } from '$app/paths';
import { api, ApiError, type App } from '$lib/api'; import { api, ApiError, type App } from '$lib/api';
import { slugify, SLUG_MAX } from '$lib/slugify'; import { slugify, SLUG_MAX } from '$lib/slugify';
import { canCreateApp } from '$lib/capabilities';
import { currentUser } from '$lib/auth';
const me = $derived($currentUser);
const canCreate = $derived(canCreateApp(me));
let apps = $state<App[] | null>(null); let apps = $state<App[] | null>(null);
let listError = $state<string | null>(null); let listError = $state<string | null>(null);
@@ -99,6 +104,7 @@
<section> <section>
<header class="page-header"> <header class="page-header">
<h1>Apps</h1> <h1>Apps</h1>
{#if canCreate}
<button <button
type="button" type="button"
onclick={() => { onclick={() => {
@@ -108,9 +114,10 @@
> >
{showCreate ? 'Cancel' : 'New app'} {showCreate ? 'Cancel' : 'New app'}
</button> </button>
{/if}
</header> </header>
{#if showCreate} {#if showCreate && canCreate}
<form class="create-form" onsubmit={(e) => submitCreate(e)}> <form class="create-form" onsubmit={(e) => submitCreate(e)}>
<div class="row"> <div class="row">
<label> <label>

View File

@@ -17,6 +17,7 @@
import ActionMenu from '$lib/ActionMenu.svelte'; import ActionMenu from '$lib/ActionMenu.svelte';
import RoleChip from '$lib/RoleChip.svelte'; import RoleChip from '$lib/RoleChip.svelte';
import { currentUser } from '$lib/auth'; import { currentUser } from '$lib/auth';
import { canAdminApp, canWriteApp } from '$lib/capabilities';
const me = $derived($currentUser); const me = $derived($currentUser);
@@ -36,7 +37,12 @@
let domains = $state<AppDomain[]>([]); let domains = $state<AppDomain[]>([]);
let members = $state<AppMemberDto[]>([]); let members = $state<AppMemberDto[]>([]);
const canAdminMembers = $derived(myRole === 'app_admin'); // Derive UI gates from the capabilities helper so the rules stay
// in lockstep with the backend's `can()`. canAdminApp also covers
// the Members + Settings + Domains-mutation tabs; canWriteApp
// covers New script.
const canWrite = $derived(canWriteApp(me, myRole));
const canAdmin = $derived(canAdminApp(me, myRole));
// Script create // Script create
let showCreateScript = $state(false); let showCreateScript = $state(false);
@@ -102,7 +108,7 @@
editDescription = app.description ?? ''; editDescription = app.description ?? '';
editSlug = app.slug; editSlug = app.slug;
const loaders: Promise<unknown>[] = [loadScripts(app.id), loadDomains(app.id)]; const loaders: Promise<unknown>[] = [loadScripts(app.id), loadDomains(app.id)];
if (canAdminMembers) { if (canAdmin) {
loaders.push(loadMembers(app.id), loadEligibleUsers()); loaders.push(loadMembers(app.id), loadEligibleUsers());
} }
await Promise.all(loaders); await Promise.all(loaders);
@@ -362,6 +368,16 @@
$effect(() => { $effect(() => {
void loadApp(); void loadApp();
}); });
// Defense-in-depth: a viewer / editor following a stale link to
// the Settings or Members tab gets bounced back to Scripts. The
// backend still 403s the underlying calls, but no point showing an
// empty tab.
$effect(() => {
if (!canAdmin && (activeTab === 'settings' || activeTab === 'members')) {
activeTab = 'scripts';
}
});
</script> </script>
{#if loading && !app} {#if loading && !app}
@@ -394,33 +410,35 @@
class:active={activeTab === 'domains'} class:active={activeTab === 'domains'}
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
> >
{#if canAdminMembers} {#if canAdmin}
<button <button
type="button" type="button"
class:active={activeTab === 'members'} class:active={activeTab === 'members'}
onclick={() => (activeTab = 'members')}>Members ({members.length})</button onclick={() => (activeTab = 'members')}>Members ({members.length})</button
> >
{/if}
<button <button
type="button" type="button"
class:active={activeTab === 'settings'} class:active={activeTab === 'settings'}
onclick={() => (activeTab = 'settings')}>Settings</button onclick={() => (activeTab = 'settings')}>Settings</button
> >
{/if}
</nav> </nav>
{#if activeTab === 'scripts'} {#if activeTab === 'scripts'}
<section> <section>
<div class="row"> <div class="row">
<h2>Scripts</h2> <h2>Scripts</h2>
{#if canWrite}
<button <button
type="button" type="button"
onclick={() => (showCreateScript = !showCreateScript)} onclick={() => (showCreateScript = !showCreateScript)}
> >
{showCreateScript ? 'Cancel' : 'New script'} {showCreateScript ? 'Cancel' : 'New script'}
</button> </button>
{/if}
</div> </div>
{#if showCreateScript} {#if showCreateScript && canWrite}
<form class="create-form" onsubmit={submitCreateScript}> <form class="create-form" onsubmit={submitCreateScript}>
<div class="row"> <div class="row">
<label> <label>
@@ -473,6 +491,7 @@
these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for
wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture. wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture.
</p> </p>
{#if canAdmin}
<form class="create-form inline" onsubmit={submitCreateDomain}> <form class="create-form inline" onsubmit={submitCreateDomain}>
<input <input
bind:value={createDomainPattern} bind:value={createDomainPattern}
@@ -486,6 +505,7 @@
{#if createDomainError} {#if createDomainError}
<div class="error">{createDomainError}</div> <div class="error">{createDomainError}</div>
{/if} {/if}
{/if}
{#if domains.length === 0} {#if domains.length === 0}
<p class="muted">No domain claims yet.</p> <p class="muted">No domain claims yet.</p>
{:else} {:else}
@@ -496,6 +516,7 @@
<code>{d.pattern}</code> <code>{d.pattern}</code>
<span class="muted">{d.shape}</span> <span class="muted">{d.shape}</span>
</div> </div>
{#if canAdmin}
<button <button
type="button" type="button"
class="secondary danger" class="secondary danger"
@@ -503,12 +524,13 @@
> >
Delete Delete
</button> </button>
{/if}
</li> </li>
{/each} {/each}
</ul> </ul>
{/if} {/if}
</section> </section>
{:else if activeTab === 'members' && canAdminMembers} {:else if activeTab === 'members' && canAdmin}
<section> <section>
<h2>Members</h2> <h2>Members</h2>
<p class="muted"> <p class="muted">
@@ -623,7 +645,7 @@
</div> </div>
{/if} {/if}
</section> </section>
{:else if activeTab === 'settings'} {:else if activeTab === 'settings' && canAdmin}
<section> <section>
<h2>Settings</h2> <h2>Settings</h2>
<form class="create-form" onsubmit={(e) => saveSettings(e)}> <form class="create-form" onsubmit={(e) => saveSettings(e)}>

View File

@@ -6,12 +6,15 @@
api, api,
ApiError, ApiError,
type AppDomain, type AppDomain,
type AppRole,
type ExecutionLog, type ExecutionLog,
type Route, type Route,
type RouteInput, type RouteInput,
type Script, type Script,
type VersionInfo type VersionInfo
} from '$lib/api'; } from '$lib/api';
import { currentUser } from '$lib/auth';
import { canAdminApp, canWriteApp } from '$lib/capabilities';
import { logLevelColor, statusColor } from '$lib/styles'; import { logLevelColor, statusColor } from '$lib/styles';
import { import {
checkHostAgainstClaims, checkHostAgainstClaims,
@@ -21,6 +24,7 @@
pathKindMismatchWarning pathKindMismatchWarning
} from '$lib/route-utils'; } from '$lib/route-utils';
import CodeEditor from '$lib/CodeEditor.svelte'; import CodeEditor from '$lib/CodeEditor.svelte';
import ConfirmModal from '$lib/ConfirmModal.svelte';
import { format as formatRhai } from '$lib/rhai'; import { format as formatRhai } from '$lib/rhai';
/// Pretty-print a JSON string in place, leaving it untouched if the /// Pretty-print a JSON string in place, leaving it untouched if the
@@ -47,6 +51,11 @@
let appSlug = $state<string | null>(null); let appSlug = $state<string | null>(null);
let appDomains = $state<AppDomain[]>([]); let appDomains = $state<AppDomain[]>([]);
let appMyRole = $state<AppRole | null>(null);
const me = $derived($currentUser);
const canWrite = $derived(canWriteApp(me, appMyRole));
const canAdmin = $derived(canAdminApp(me, appMyRole));
async function loadScript() { async function loadScript() {
scriptLoading = true; scriptLoading = true;
@@ -58,15 +67,16 @@
editableDescription = script.description ?? ''; editableDescription = script.description ?? '';
editableTimeout = script.timeout_seconds; editableTimeout = script.timeout_seconds;
editableSandbox = { ...(script.sandbox ?? {}) }; editableSandbox = { ...(script.sandbox ?? {}) };
// Resolve the owning app's slug for the breadcrumb and its // Resolve the owning app for the breadcrumb (slug),
// domain claims for the route form's suggestions + live // route-form host suggestions (domain claims), and UI
// validation. Both are non-fatal — the page works without // shadowing (my_role on this app). All non-fatal — the
// them. // page renders without them, just with reduced fidelity.
const appId = script.app_id; const appId = script.app_id;
void api.apps void api.apps
.get(appId) .get(appId)
.then((a) => { .then((a) => {
appSlug = a.slug; appSlug = a.slug;
appMyRole = a.my_role ?? null;
}) })
.catch(() => {}); .catch(() => {});
void api.domains void api.domains
@@ -366,16 +376,25 @@
} }
// ---------------- deletion ---------------- // ---------------- deletion ----------------
let confirmingDelete = $state(false);
let deleting = $state(false); let deleting = $state(false);
async function remove() { let deleteError = $state<string | null>(null);
function askDelete() {
deleteError = null;
confirmingDelete = true;
}
async function confirmDelete() {
if (!script) return; if (!script) return;
if (!confirm(`Delete script "${script.name}"? This cannot be undone.`)) return;
deleting = true; deleting = true;
deleteError = null;
try { try {
await api.scripts.remove(id); await api.scripts.remove(id);
await goto(base + '/'); await goto(base + '/');
} catch (e) { } catch (e) {
alert(e instanceof Error ? e.message : String(e)); deleteError = e instanceof Error ? e.message : String(e);
} finally {
deleting = false; deleting = false;
} }
} }
@@ -386,6 +405,15 @@
void loadRoutes(); void loadRoutes();
void loadLogs(); void loadLogs();
}); });
// Defense-in-depth: anyone non-admin who lands on the Settings
// tab via a stale link gets bounced back to Edit. The tab button
// itself is also hidden.
$effect(() => {
if (!canAdmin && tab === 'settings') {
tab = 'edit';
}
});
</script> </script>
<section> <section>
@@ -410,9 +438,11 @@
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'} v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
</p> </p>
</div> </div>
<button type="button" class="danger" onclick={remove} disabled={deleting}> {#if canAdmin}
<button type="button" class="danger" onclick={askDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete'} {deleting ? 'Deleting…' : 'Delete'}
</button> </button>
{/if}
</header> </header>
<nav class="tabs"> <nav class="tabs">
@@ -423,7 +453,9 @@
<span class="badge-count">{routes.length}</span> <span class="badge-count">{routes.length}</span>
{/if} {/if}
</button> </button>
{#if canAdmin}
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings</button> <button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings</button>
{/if}
<button class:active={tab === 'executions'} onclick={() => (tab = 'executions')}> <button class:active={tab === 'executions'} onclick={() => (tab = 'executions')}>
Executions Executions
</button> </button>
@@ -435,17 +467,25 @@
<section class="card"> <section class="card">
<header class="editor-header"> <header class="editor-header">
<h2>Source</h2> <h2>Source</h2>
{#if canWrite}
<button type="button" class="ghost small" onclick={formatRhaiSource}> <button type="button" class="ghost small" onclick={formatRhaiSource}>
Format Format
</button> </button>
{/if}
</header> </header>
<CodeEditor bind:value={editableSource} language="rhai" minHeight="22rem" /> <CodeEditor
bind:value={editableSource}
language="rhai"
minHeight="22rem"
readOnly={!canWrite}
/>
{#if rhaiFormatError} {#if rhaiFormatError}
<div class="error inline">{rhaiFormatError}</div> <div class="error inline">{rhaiFormatError}</div>
{/if} {/if}
{#if saveSourceError} {#if saveSourceError}
<div class="error inline">{saveSourceError}</div> <div class="error inline">{saveSourceError}</div>
{/if} {/if}
{#if canWrite}
<div class="actions"> <div class="actions">
<button <button
type="button" type="button"
@@ -455,6 +495,7 @@
{savingSource ? 'Saving…' : 'Save'} {savingSource ? 'Saving…' : 'Save'}
</button> </button>
</div> </div>
{/if}
</section> </section>
<section class="card"> <section class="card">
@@ -510,12 +551,14 @@
<section class="card wide"> <section class="card wide">
<header class="card-header"> <header class="card-header">
<h2>Routes</h2> <h2>Routes</h2>
{#if canWrite}
<button type="button" onclick={() => (showAddRoute = !showAddRoute)}> <button type="button" onclick={() => (showAddRoute = !showAddRoute)}>
{showAddRoute ? 'Cancel' : '+ Add route'} {showAddRoute ? 'Cancel' : '+ Add route'}
</button> </button>
{/if}
</header> </header>
{#if showAddRoute} {#if showAddRoute && canWrite}
<form class="route-form" onsubmit={submitRoute}> <form class="route-form" onsubmit={submitRoute}>
<label class="full"> <label class="full">
<span>Path</span> <span>Path</span>
@@ -626,9 +669,11 @@
: r.host} : r.host}
</span> </span>
<span class="path">{r.path}</span> <span class="path">{r.path}</span>
{#if canWrite}
<button type="button" class="link danger" onclick={() => removeRoute(r.id)}> <button type="button" class="link danger" onclick={() => removeRoute(r.id)}>
remove remove
</button> </button>
{/if}
</div> </div>
{#if info} {#if info}
<div class="route-url muted">{fullUrlForRoute(r)}</div> <div class="route-url muted">{fullUrlForRoute(r)}</div>
@@ -670,7 +715,7 @@
</section> </section>
<!-- ===================================================== SETTINGS ===== --> <!-- ===================================================== SETTINGS ===== -->
{:else if tab === 'settings'} {:else if tab === 'settings' && canAdmin}
<section class="card wide"> <section class="card wide">
<h2>General</h2> <h2>General</h2>
<label> <label>
@@ -786,6 +831,35 @@
{/if} {/if}
</section> </section>
{/if} {/if}
{#if confirmingDelete && script}
<ConfirmModal
title="Delete script “{script.name}”"
variant="danger"
confirmLabel="Delete script"
busyLabel="Deleting…"
confirmPhrase={script.name}
confirmPhrasePrompt="Type the script name to confirm:"
busy={deleting}
onConfirm={confirmDelete}
onCancel={() => (confirmingDelete = false)}
>
<p>
This will <strong>permanently delete</strong>
<strong>{script.name}</strong>, all its routes, and all its
execution logs. There is no undo.
</p>
{#if routes.length > 0}
<p class="muted">
{routes.length} route{routes.length === 1 ? '' : 's'} bound to
this script will be removed.
</p>
{/if}
{#if deleteError}
<p class="modal-error">{deleteError}</p>
{/if}
</ConfirmModal>
{/if}
{/if} {/if}
</section> </section>

View File

@@ -79,6 +79,13 @@
let deleteTarget = $state<AdminDto | null>(null); let deleteTarget = $state<AdminDto | null>(null);
let deletePending = $state(false); let deletePending = $state(false);
// Deactivate modal -------------------------------------------------------
// Reactivate is one-click (non-destructive); deactivate routes
// through the modal because it signs the user out and expires
// every API key they hold.
let deactivateTarget = $state<AdminDto | null>(null);
let deactivatePending = $state(false);
// Validation rules (mirror backend: 2-32, [a-z0-9._-]) ------------------- // Validation rules (mirror backend: 2-32, [a-z0-9._-]) -------------------
const USERNAME_RE = /^[a-z0-9._-]{2,32}$/; const USERNAME_RE = /^[a-z0-9._-]{2,32}$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -219,19 +226,36 @@
} }
} }
async function toggleActive(row: AdminDto) { async function reactivate(row: AdminDto) {
try { try {
const updated = await api.admins.update(row.id, { is_active: !row.is_active }); const updated = await api.admins.update(row.id, { is_active: true });
admins = admins.map((a) => (a.id === updated.id ? updated : a)); admins = admins.map((a) => (a.id === updated.id ? updated : a));
flash( flash('info', `${updated.username} reactivated.`);
'info',
`${updated.username} ${updated.is_active ? 'reactivated' : 'deactivated'}.`
);
} catch (e) { } catch (e) {
flash('error', e instanceof ApiError ? e.message : 'failed to update user'); flash('error', e instanceof ApiError ? e.message : 'failed to update user');
} }
} }
function askDeactivate(row: AdminDto) {
deactivateTarget = row;
}
async function confirmDeactivate() {
if (!deactivateTarget) return;
deactivatePending = true;
const target = deactivateTarget;
try {
const updated = await api.admins.update(target.id, { is_active: false });
admins = admins.map((a) => (a.id === updated.id ? updated : a));
deactivateTarget = null;
flash('info', `${updated.username} deactivated.`);
} catch (e) {
flash('error', e instanceof ApiError ? e.message : 'failed to update user');
} finally {
deactivatePending = false;
}
}
function openDelete(row: AdminDto) { function openDelete(row: AdminDto) {
deleteTarget = row; deleteTarget = row;
} }
@@ -353,7 +377,8 @@
{ label: 'Edit', onClick: () => openEdit(row) }, { label: 'Edit', onClick: () => openEdit(row) },
{ {
label: row.is_active ? 'Deactivate' : 'Reactivate', label: row.is_active ? 'Deactivate' : 'Reactivate',
onClick: () => toggleActive(row) onClick: () =>
row.is_active ? askDeactivate(row) : reactivate(row)
}, },
{ {
label: 'Delete', label: 'Delete',
@@ -571,6 +596,30 @@
</div> </div>
{/if} {/if}
<!-- Deactivate confirmation -->
{#if deactivateTarget}
{@const dt = deactivateTarget}
<ConfirmModal
title="Deactivate {dt.username}?"
variant="danger"
confirmLabel="Deactivate"
busyLabel="Deactivating…"
busy={deactivatePending}
onConfirm={confirmDeactivate}
onCancel={() => (deactivateTarget = null)}
>
<p>
Deactivating signs <strong>{dt.username}</strong> out immediately and
expires <strong>every API key</strong> they hold. Their sessions and keys
won't come back if you reactivate — they'll need to log in again and
mint new keys.
</p>
<p class="muted">
Reactivation is one click — this isn't permanent.
</p>
</ConfirmModal>
{/if}
<!-- Delete confirmation --> <!-- Delete confirmation -->
{#if deleteTarget} {#if deleteTarget}
{@const dt = deleteTarget} {@const dt = deleteTarget}

View File

@@ -2,6 +2,36 @@ import { expect, type Page } from '@playwright/test';
import { test } from '../fixtures/ids'; import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup'; import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api'; import { adminApi } from '../fixtures/api';
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
const MEMBER_PW = 'e2e-member-pw';
async function seedAppAndMember(opts: {
slug: string;
username: string;
role: 'viewer' | 'editor' | 'app_admin';
}): Promise<{ appId: string; userId: string }> {
const api = await adminApi();
try {
const appRes = await api.post('/api/v1/admin/apps', {
data: { slug: opts.slug, name: opts.slug }
});
expect(appRes.ok()).toBe(true);
const appId = ((await appRes.json()) as { id: string }).id;
const userRes = await api.post('/api/v1/admin/admins', {
data: { username: opts.username, password: MEMBER_PW, instance_role: 'member' }
});
expect(userRes.ok()).toBe(true);
const userId = ((await userRes.json()) as { id: string }).id;
const memberRes = await api.post(`/api/v1/admin/apps/${opts.slug}/members`, {
data: { user_id: userId, role: opts.role }
});
expect(memberRes.ok()).toBe(true);
return { appId, userId };
} finally {
await api.dispose();
}
}
// Phase B2 — Apps Lifecycle. Create, view, edit, delete, plus the // Phase B2 — Apps Lifecycle. Create, view, edit, delete, plus the
// historical-slug takeover flow and adversarial inputs. // historical-slug takeover flow and adversarial inputs.
@@ -224,3 +254,82 @@ test.describe('B2 apps adversarial', () => {
await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible();
}); });
}); });
test.describe('B2 apps role shadowing', () => {
test('viewer member sees no "New app" on the apps list', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('vlist');
const username = uniqueUsername('viewer');
const { userId } = await seedAppAndMember({ slug, username, role: 'viewer' });
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto('/admin/apps');
// Member can see the apps list (just the one they belong to)
// but the create-app affordance is hidden.
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
await expect(page.getByRole('button', { name: /^New app$/ })).toHaveCount(0);
} finally {
await page.context().close();
}
});
test('viewer sees no Add domain form and no Settings tab on app detail', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('vdom');
const username = uniqueUsername('viewer');
const { userId } = await seedAppAndMember({ slug, username, role: 'viewer' });
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/apps/${slug}`);
await expect(
page.getByRole('button', { name: /^Scripts \(\d+\)$/ })
).toBeVisible();
// Settings tab is absent.
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
// Domains tab still listable, but no Add-domain submit.
await page.getByRole('button', { name: /^Domains \(\d+\)$/ }).click();
await expect(page.getByRole('button', { name: /^Add domain$/ })).toHaveCount(0);
} finally {
await page.context().close();
}
});
test('editor sees New script but no Settings tab', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('edit');
const username = uniqueUsername('editor');
const { userId } = await seedAppAndMember({ slug, username, role: 'editor' });
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/apps/${slug}`);
await expect(page.getByRole('button', { name: /^New script$/ })).toBeVisible();
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
await expect(
page.getByRole('button', { name: /^Members \(\d+\)$/ })
).toHaveCount(0);
} finally {
await page.context().close();
}
});
});

View File

@@ -3,28 +3,40 @@ import { adminApi } from './api';
// Resources to delete after a test, in LIFO order. Tests register // Resources to delete after a test, in LIFO order. Tests register
// their creations and the registry tears everything down in // their creations and the registry tears everything down in
// `cleanupRegistered` — typically called from `test.afterEach`. // `run()` — typically called from `test.afterEach`.
//
// A non-2xx status (other than 404) is treated as a real failure and
// logged to stderr. The previous shape silently swallowed every
// error, so a backend that started returning 500 on cleanup would
// have leaked orphans invisibly across runs. 404 stays tolerated —
// the test may have already deleted the resource itself.
type Cleanup = (api: APIRequestContext) => Promise<void>; interface CleanupItem {
label: string;
path: string;
}
export class CleanupRegistry { export class CleanupRegistry {
private items: Cleanup[] = []; private items: CleanupItem[] = [];
app(slugOrId: string): void { app(slugOrId: string): void {
this.items.push(async (api) => { this.items.push({
await api.delete(`/api/v1/admin/apps/${encodeURIComponent(slugOrId)}?force=true`); label: `app=${slugOrId}`,
path: `/api/v1/admin/apps/${encodeURIComponent(slugOrId)}?force=true`
}); });
} }
adminUser(userId: string): void { adminUser(userId: string): void {
this.items.push(async (api) => { this.items.push({
await api.delete(`/api/v1/admin/admins/${userId}`); label: `admin=${userId}`,
path: `/api/v1/admin/admins/${userId}`
}); });
} }
apiKey(keyId: string): void { apiKey(keyId: string): void {
this.items.push(async (api) => { this.items.push({
await api.delete(`/api/v1/admin/api-keys/${keyId}`); label: `key=${keyId}`,
path: `/api/v1/admin/api-keys/${keyId}`
}); });
} }
@@ -32,13 +44,11 @@ export class CleanupRegistry {
if (this.items.length === 0) return; if (this.items.length === 0) return;
const api = await adminApi(); const api = await adminApi();
try { try {
for (const item of this.items.reverse()) { // Copy-then-reverse so a defensive double-`run()` (or a
try { // caller that inspects the registry after a partial
await item(api); // teardown) doesn't see the items in a re-reversed order.
} catch { for (const item of [...this.items].reverse()) {
// Best-effort cleanup — a missing resource (already await deleteAndReport(api, item);
// deleted by the test) shouldn't fail the suite.
}
} }
} finally { } finally {
await api.dispose(); await api.dispose();
@@ -46,3 +56,22 @@ export class CleanupRegistry {
} }
} }
} }
async function deleteAndReport(
api: APIRequestContext,
item: CleanupItem
): Promise<void> {
try {
const res = await api.delete(item.path);
// 2xx and 404 are both "this resource is no longer here" — fine.
if (!res.ok() && res.status() !== 404) {
console.warn(
`[cleanup] ${item.label} failed: HTTP ${res.status()} ${await res.text()}`
);
}
} catch (err) {
// Network-level failure (request never reached the server,
// timeout, etc.). Log so a leak doesn't accumulate silently.
console.warn(`[cleanup] ${item.label} failed: ${(err as Error).message}`);
}
}

View File

@@ -0,0 +1,46 @@
// Helpers for tests that drive the dashboard as a non-bootstrap admin
// (member with an app-membership row, custom InstanceRole, etc.).
//
// `loginAsUserToken` exchanges username/password for a bearer token
// via the admin API. `pageWithUserToken` opens a fresh browser
// context, seeds the dashboard's localStorage entry, and returns the
// page ready to navigate. Callers are responsible for closing the
// returned page's context.
import { expect, request, type Browser, type Page } from '@playwright/test';
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
export async function loginAsUserToken(
username: string,
password: string
): Promise<string> {
const probe = await request.newContext({ baseURL: API_BASE });
try {
const res = await probe.post('/api/v1/admin/auth/login', {
data: { username, password },
headers: { 'content-type': 'application/json' }
});
expect(res.ok()).toBe(true);
return ((await res.json()) as { token: string }).token;
} finally {
await probe.dispose();
}
}
export async function pageWithUserToken(
browser: Browser,
token: string
): Promise<Page> {
const ctx = await browser.newContext({ storageState: undefined });
const page = await ctx.newPage();
// Seed localStorage on the right origin, then navigate normally.
await page.goto('/admin/login');
await page.evaluate(
([key, value]) => {
localStorage.setItem(key, value);
},
['picloud.admin.token', token]
);
return page;
}

View File

@@ -18,6 +18,7 @@ export default async function globalSetup(): Promise<void> {
await assertBackendUp(); await assertBackendUp();
await fs.mkdir(AUTH_DIR, { recursive: true }); await fs.mkdir(AUTH_DIR, { recursive: true });
const token = await loginAsAdmin(); const token = await loginAsAdmin();
await sweepOrphans(token);
await persistAdminStorageState(token); await persistAdminStorageState(token);
} }
@@ -71,6 +72,57 @@ async function loginAsAdmin(): Promise<string> {
} }
} }
// Clean up apps + admin users left over from a previous crashed run.
// The convention is that every e2e-created resource has a slug
// starting with `e2e-` (apps) or a username starting with `e2e`
// (admins) — see fixtures/ids.ts. Best-effort: a sweep failure must
// not stop the suite from running.
async function sweepOrphans(token: string): Promise<void> {
const ctx = await request.newContext({
baseURL: API_BASE,
extraHTTPHeaders: { authorization: `Bearer ${token}` }
});
try {
try {
const res = await ctx.get('/api/v1/admin/apps');
if (res.ok()) {
const apps = (await res.json()) as Array<{ slug: string }>;
for (const app of apps) {
if (!app.slug.startsWith('e2e-')) continue;
try {
await ctx.delete(
`/api/v1/admin/apps/${encodeURIComponent(app.slug)}?force=true`
);
} catch {
// Individual delete failure is non-fatal — the per-test
// cleanup will catch it on the next run.
}
}
}
} catch {
// Listing failed; nothing to do but proceed.
}
try {
const res = await ctx.get('/api/v1/admin/admins');
if (res.ok()) {
const admins = (await res.json()) as Array<{ id: string; username: string }>;
for (const a of admins) {
if (!/^e2e/.test(a.username)) continue;
try {
await ctx.delete(`/api/v1/admin/admins/${a.id}`);
} catch {
// Same per-row tolerance as above.
}
}
}
} catch {
// Listing failed; same as above.
}
} finally {
await ctx.dispose();
}
}
// The dashboard reads its session from localStorage under the key // The dashboard reads its session from localStorage under the key
// `picloud.admin.token` (see src/lib/auth.ts). We can't write to // `picloud.admin.token` (see src/lib/auth.ts). We can't write to
// localStorage without a browser context, so launch a throwaway one, // localStorage without a browser context, so launch a throwaway one,

View File

@@ -87,9 +87,12 @@ test('end-to-end: app + domain + script + route via dashboard → invoke via pub
}); });
test('api key minted via dashboard works as a CLI bearer, then revoke disables it', async ({ test('api key minted via dashboard works as a CLI bearer, then revoke disables it', async ({
page page,
uniqueUsername
}) => { }) => {
const name = `e2e-cli-${Date.now()}`; // Worker-aware unique helper instead of Date.now() — keeps two
// workers from minting the same name on the same millisecond.
const name = uniqueUsername('cli');
// 1. Mint the key from /profile and capture the revealed token. // 1. Mint the key from /profile and capture the revealed token.
await page.goto('/admin/profile'); await page.goto('/admin/profile');

View File

@@ -1,14 +1,13 @@
import { expect, type Browser, type Page } from '@playwright/test'; import { expect } from '@playwright/test';
import { test } from '../fixtures/ids'; import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup'; import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api'; import { adminApi } from '../fixtures/api';
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
// Phase B5 — App Members. Setup creates one or two extra admin // Phase B5 — App Members. Setup creates one or two extra admin
// users via the API; tests drive the Members tab through the // users via the API; tests drive the Members tab through the
// dashboard like a real app admin would. // dashboard like a real app admin would.
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
const cleanup = new CleanupRegistry(); const cleanup = new CleanupRegistry();
test.afterEach(async () => { test.afterEach(async () => {
await cleanup.run(); await cleanup.run();
@@ -38,36 +37,6 @@ async function createMemberUser(username: string): Promise<string> {
} }
} }
async function loginAsUserToken(username: string, password: string): Promise<string> {
const probe = await (await import('@playwright/test')).request.newContext({
baseURL: API_BASE
});
try {
const res = await probe.post('/api/v1/admin/auth/login', {
data: { username, password },
headers: { 'content-type': 'application/json' }
});
expect(res.ok()).toBe(true);
return ((await res.json()) as { token: string }).token;
} finally {
await probe.dispose();
}
}
async function pageWithUserToken(browser: Browser, token: string): Promise<Page> {
const ctx = await browser.newContext({ storageState: undefined });
const page = await ctx.newPage();
// Seed localStorage on the right origin, then navigate normally.
await page.goto('/admin/login');
await page.evaluate(
([key, value]) => {
localStorage.setItem(key, value);
},
['picloud.admin.token', token]
);
return page;
}
test.describe('B5 app members', () => { test.describe('B5 app members', () => {
test('invite a member-role user, then remove them', async ({ page, uniqueSlug, uniqueUsername }) => { test('invite a member-role user, then remove them', async ({ page, uniqueSlug, uniqueUsername }) => {
const slug = uniqueSlug('mem'); const slug = uniqueSlug('mem');

View File

@@ -2,6 +2,41 @@ import { expect, type Page } from '@playwright/test';
import { test } from '../fixtures/ids'; import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup'; import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api'; import { adminApi } from '../fixtures/api';
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
const MEMBER_PW = 'e2e-member-pw';
async function seedAppScriptAndMember(opts: {
slug: string;
username: string;
role: 'viewer' | 'editor';
}): Promise<{ scriptId: string; userId: string }> {
const api = await adminApi();
try {
const appRes = await api.post('/api/v1/admin/apps', {
data: { slug: opts.slug, name: opts.slug }
});
expect(appRes.ok()).toBe(true);
const appId = ((await appRes.json()) as { id: string }).id;
const scriptRes = await api.post('/api/v1/admin/scripts', {
data: { app_id: appId, name: `${opts.slug}-sc`, source: HELLO_RHAI }
});
expect(scriptRes.ok()).toBe(true);
const scriptId = ((await scriptRes.json()) as { id: string }).id;
const userRes = await api.post('/api/v1/admin/admins', {
data: { username: opts.username, password: MEMBER_PW, instance_role: 'member' }
});
expect(userRes.ok()).toBe(true);
const userId = ((await userRes.json()) as { id: string }).id;
const memberRes = await api.post(`/api/v1/admin/apps/${opts.slug}/members`, {
data: { user_id: userId, role: opts.role }
});
expect(memberRes.ok()).toBe(true);
return { scriptId, userId };
} finally {
await api.dispose();
}
}
// Phase B3 — Scripts CRUD + Editor. The script editor lives at // Phase B3 — Scripts CRUD + Editor. The script editor lives at
// /admin/scripts/{id}. Setup uses the API to create the app (and // /admin/scripts/{id}. Setup uses the API to create the app (and
@@ -175,6 +210,105 @@ test.describe('B3 settings', () => {
}); });
}); });
test.describe('B3 scripts role shadowing', () => {
test('viewer: no Delete header, no Save/Format on Edit, no Add route on Routing', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('vscr');
const username = uniqueUsername('viewer');
const { scriptId, userId } = await seedAppScriptAndMember({
slug,
username,
role: 'viewer'
});
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/scripts/${scriptId}`);
// Header Delete is hidden for non-admins.
await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0);
// Save/Format on the Edit tab are hidden for viewers.
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
await expect(
page.locator('.editor-header').getByRole('button', { name: 'Format' })
).toHaveCount(0);
// Test invoke is still visible (everyone with read access).
await expect(page.getByRole('button', { name: /^Send$/ })).toBeVisible();
// Routing tab loads, no +Add route.
await page.getByRole('button', { name: /Routing/ }).click();
await expect(page.getByRole('button', { name: /\+ Add route/ })).toHaveCount(0);
// Settings tab is absent for non-admins.
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
} finally {
await page.context().close();
}
});
test('viewer: CodeMirror is read-only', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('vro');
const username = uniqueUsername('viewer');
const { scriptId, userId } = await seedAppScriptAndMember({
slug,
username,
role: 'viewer'
});
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/scripts/${scriptId}`);
const cm = page.locator('.cm-content').first();
await expect(cm).toBeVisible();
// CodeMirror sets contenteditable=false when EditorView.editable.of(false)
// is in effect; that's the canonical signal for read-only mode.
await expect(cm).toHaveAttribute('contenteditable', 'false');
} finally {
await page.context().close();
}
});
test('editor: Save visible, Delete header hidden', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('escr');
const username = uniqueUsername('editor');
const { scriptId, userId } = await seedAppScriptAndMember({
slug,
username,
role: 'editor'
});
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/scripts/${scriptId}`);
// Editor sees Save (disabled until the buffer changes — that's fine).
await expect(page.getByRole('button', { name: /^Save$/ })).toBeVisible();
// Delete stays admin-only.
await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0);
// Settings stays admin-only.
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
} finally {
await page.context().close();
}
});
});
test.describe('B3 adversarial', () => { test.describe('B3 adversarial', () => {
test('infinite loop script hits the sandbox timeout', async ({ page, uniqueSlug }) => { test('infinite loop script hits the sandbox timeout', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('loop'); const slug = uniqueSlug('loop');

View File

@@ -114,7 +114,7 @@ test.describe('B6 instance users', () => {
await expect(page.locator('.row', { hasText: decoy })).toHaveCount(0); await expect(page.locator('.row', { hasText: decoy })).toHaveCount(0);
}); });
test('deactivate then reactivate toggles the inactive indicator', async ({ test('deactivate confirm modal: Cancel keeps active, Deactivate flips, reactivate is one click', async ({
page, page,
uniqueUsername uniqueUsername
}) => { }) => {
@@ -127,10 +127,25 @@ test.describe('B6 instance users', () => {
const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username }); const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username });
await expect(row).toBeVisible(); await expect(row).toBeVisible();
// Deactivate opens the confirm modal.
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click(); await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click(); await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(username);
// Cancel leaves the user active.
await dialog.getByRole('button', { name: /^Cancel$/ }).click();
await expect(dialog).toHaveCount(0);
await expect(row).not.toContainText(/inactive/i);
// Open again and confirm — user becomes inactive.
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
await page.getByRole('dialog').getByRole('button', { name: /^Deactivate$/ }).click();
await expect(row).toContainText(/inactive/i); await expect(row).toContainText(/inactive/i);
// Reactivate is still one-click (non-destructive — no modal).
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click(); await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Reactivate$/ }).click(); await page.getByRole('menuitem', { name: /^Reactivate$/ }).click();
await expect(row).not.toContainText(/inactive/i); await expect(row).not.toContainText(/inactive/i);

View File

@@ -9,7 +9,7 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
test: { test: {
include: ['src/lib/rhai/**/*.test.ts'], include: ['src/lib/**/*.test.ts'],
environment: 'node' environment: 'node'
} }
}); });

View File

@@ -61,6 +61,7 @@ services:
caddy: caddy:
image: caddy:2-alpine image: caddy:2-alpine
restart: unless-stopped
ports: ports:
- "${PICLOUD_HOST_PORT:-8000}:80" - "${PICLOUD_HOST_PORT:-8000}:80"
volumes: volumes:

View File

@@ -1049,7 +1049,7 @@ pub struct Principal {
| Role | Powers | | Role | Powers |
|---|---| |---|---|
| `owner` | full instance control, manage other owners, implicit `app_admin` on every app. Multiple owners allowed. | | `owner` | full instance control, manage other owners, implicit `app_admin` on every app. Multiple owners allowed. |
| `admin` | create apps, invite users, implicit `editor` on every app. Cannot manage instance-wide settings or other owners. | | `admin` | create apps, invite users, implicit `app_admin` on every app. Cannot manage instance-wide settings (sandbox ceiling, etc.) or other owners. |
| `member` | invited into specific apps only. Cannot create apps, cannot invite. **Strict isolation enforced at SQL** — list endpoints `WHERE app_id IN (SELECT app_id FROM app_members WHERE user_id = $1)`; the API never returns apps a member isn't part of. | | `member` | invited into specific apps only. Cannot create apps, cannot invite. **Strict isolation enforced at SQL** — list endpoints `WHERE app_id IN (SELECT app_id FROM app_members WHERE user_id = $1)`; the API never returns apps a member isn't part of. |
The current Phase 3a `admin_users` rows all become `owner` via `DEFAULT 'owner'` on the new column. Multi-owner installs get a startup `tracing::warn!` listing the active owner usernames so the operator can demote extras via `PATCH /api/v1/admin/admins/{id}`. The current Phase 3a `admin_users` rows all become `owner` via `DEFAULT 'owner'` on the new column. Multi-owner installs get a startup `tracing::warn!` listing the active owner usernames so the operator can demote extras via `PATCH /api/v1/admin/admins/{id}`.
@@ -1058,11 +1058,13 @@ The current Phase 3a `admin_users` rows all become `owner` via `DEFAULT 'owner'`
| Role | Grants | | Role | Grants |
|---|---| |---|---|
| `app_admin` | settings, domain claims, delete app | | `app_admin` | settings, domain claims, delete app, **delete scripts** |
| `editor` | CRUD on scripts, routes, sandbox config | | `editor` | create + edit scripts, routes, sandbox config (no script delete) |
| `viewer` | read scripts + execution logs | | `viewer` | read scripts + execution logs |
Implicit grants from instance role: every `owner` is `app_admin` on every app; every `admin` is `editor` on every app. Explicit `app_members` rows are the only path for `member` users. Implicit grants from instance role: every `owner` and every `admin` is `app_admin` on every app — a single-human install would otherwise have to add itself to each new app's `app_members`. Explicit `app_members` rows are the only path for `member` users.
Script **save** uses `AppWriteScript` (editor+); script **delete** uses `AppAdmin` (app_admin+). Editors can iterate on a script's source freely but cannot remove it — destructive cleanup stays with the role that also owns the app.
### Auth Methods — Same Principal, Different Extractor ### Auth Methods — Same Principal, Different Extractor