49 Commits

Author SHA1 Message Date
MechaCat02
54efe61167 docs(stdlib): reference doc covering Rhai built-ins + new namespaces
A script author opening docs/stdlib-reference.md should see every
function they can call without imports: the Rhai built-in stdlib (math,
string, array, map, blob) plus the seven new PiCloud namespaces. Tight
tables over prose — scannable rather than exhaustive.

CLAUDE.md current-focus paragraph picks up a pointer to the new doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:29:15 +02:00
MechaCat02
1d2e99e42c test(stdlib): integration tests for the seven utility modules
43 tests exercising one happy path and the major error paths per
module (invalid regex pattern, oversize random::bytes, malformed JSON,
bad base64, mixed-case hex round-trip, invalid UTF-8 in url::decode,
etc.). Harness duplicates the pattern from sdk_contract.rs — each
integration test file in this crate keeps its own; there is no
tests/common/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:29:09 +02:00
MechaCat02
9e54b7f875 feat(stdlib): seven Rhai utility modules + register_stdlib hook
Adds the v1.1.0 user-visible stdlib: regex, random, time, json, base64,
hex, url — each exposed as a `::` namespace mirroring the existing
`log::` pattern. Modules register once at engine build via
`Engine::register_static_module`, distinct from the stateful service
modules (KV, docs, …) that hook into `sdk::register_all` per call.

- regex: linear-time, compile-per-call (no cache by design)
- random: OsRng only; bytes/string capped to prevent script-side blow-up
- time: UTC, ms-since-epoch as canonical i64; RFC 3339 strings for I/O
- json: parse/stringify via existing dynamic<->json bridge
- base64: standard + URL-safe alphabets, Blob and String inputs
- hex: lowercase output, case-insensitive decode
- url: RFC 3986 percent-encoding + encode_query for Maps

Stdlib registration runs unconditionally — including in the parse-only
validate path — so scripts get a uniform surface in both phases.

See docs/sdk-shape.md for the stateless-vs-stateful distinction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:29:02 +02:00
MechaCat02
a685674dbf chore(deps): add regex, hex, percent-encoding for v1.1.0 stdlib
Workspace deps for the seven Rhai utility modules that follow in this
PR. `rand`, `base64`, `uuid`, `chrono`, `serde_json` are already in
the workspace and reused as-is — only the genuinely new ones land here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:28:47 +02:00
MechaCat02
a8aab22163 Merge branch 'feat/v1.1.0-sdk-foundation'
v1.1.0 PR #0 — SDK Foundation.

Lands the architectural shape every v1.1.x stateful service hangs off,
without shipping any user-visible service. After this PR, subsequent
service PRs (KV v1.1.1, docs v1.1.2, …) are mechanical fill-in:

  - picloud_shared::{SdkCallCx, Services, ServiceEventEmitter +
    NoopEventEmitter} lock the per-call context, service bundle,
    and event-emission trait shape.
  - executor-core::sdk/ — register_all hook called per invocation;
    json↔dynamic bridge moved here from engine.rs.
  - ExecRequest gained app_id, principal, trigger_depth,
    root_execution_id (the last two reserved for v1.1.1's triggers
    framework).
  - orchestrator-core::gate::ExecutionGate — single global semaphore
    (PICLOUD_MAX_CONCURRENT_EXECUTIONS, default 32). Overflow returns
    503 + Retry-After: 1 immediately, no queue.
  - manager-core::attach_principal_if_present — opportunistic,
    fail-open middleware wired on data-plane + user-routes.
  - docs/sdk-shape.md — developer-facing reference for the
    conventions every future service PR implements against.
  - Blueprint revisions: Phase 3.5 marked ✓ Shipped, §8.1 KV switched
    from hstore to JSONB, new §7.5 SDK Architecture section and §7.5.1
    trigger sketch, §12 Phase 4 restructured into v1.1.0 → v1.1.8.
  - CLAUDE.md: current focus → v1.1.0, JSONB note, handle-pattern
    Working Rule, Runtime Configuration table with the new env var.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:11:08 +02:00
MechaCat02
e375735796 docs(blueprint+gate): drop hstore from Tech Stack; note gate-vs-timeout interaction
Two review-pass nits from the v1.1.0-foundation review:

  - Blueprint §6 Tech Stack table still listed the database as
    "PostgreSQL + hstore" with an hstore-for-KV rationale — directly
    contradicting the §8.1 KV rewrite that explicitly rejected hstore
    in favour of JSONB. Updates the row so the high-level summary
    matches the §8.1 reasoning.
  - LocalExecutorClient::execute now documents the permit-vs-timeout
    interaction: when tokio::time::timeout fires the future drops and
    the permit returns, but the detached spawn_blocking thread keeps
    running until the Rhai script winds down. In-use blocking threads
    can briefly exceed the gate's permit count after a timeout. Calling
    it out so future readers don't read the implementation as buggy.

No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:10:05 +02:00
MechaCat02
098e18a989 chore(clippy): silence three v1.1.0-foundation lints
- sdk/bridge.rs: drop #[must_use] on the bridge fns — `Dynamic` and
    `serde_json::Value` are both #[must_use] already; the wrapper
    attribute is double-must-use noise.
  - api.rs IntoResponse: hoist `use ApiError as E;` above the early
    Overloaded branch so `E::Exec(...)` works in the if-let too
    (clippy::items_after_statements).
  - gate.rs test: bind the returned permit with `let _ =` so the
    OwnedSemaphorePermit doesn't trip unused-must-use.

No behaviour change. Caught by `cargo clippy --all-targets
--all-features -- -D warnings`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 19:00:35 +02:00
MechaCat02
9b4a834627 chore(claude-md): refresh for v1.1.0 — focus, working rules, env vars
- Current focus moves to v1.1.0 (SDK foundation + stdlib) with a
    pointer to docs/sdk-shape.md. Notes Phase 3.5 capability gating is
    shipped end-to-end.
  - Tech-stack line drops the misleading "v1.1+ hstore" mention; v1.1+
    data-plane tables now use JSONB (see blueprint §8.1).
  - New Working Rules bullet for the handle pattern + SdkCallCx rule:
    services derive app_id from cx.app_id, never from a script-passed
    arg. That is the cross-app isolation boundary.
  - New "Runtime configuration" table documenting every env var the
    picloud binary consumes — including the new
    PICLOUD_MAX_CONCURRENT_EXECUTIONS alongside the existing
    PICLOUD_BIND, DATABASE_URL, session TTL, and sandbox knobs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:58:29 +02:00
MechaCat02
5302bd3192 docs(sdk): SDK-shape reference + blueprint updates for v1.1.x
Lands the developer-facing reference for the SDK shape every v1.1.x
service implements against, plus the blueprint changes the shape and
the recently-shipped Phase 3.5 imply:

  - New docs/sdk-shape.md — covers handle pattern, :: namespace,
    throw/() error convention, sync↔async bridge, cross-app isolation
    rule, ServiceEventEmitter, ExecutionGate + env var, stateless vs
    stateful module registration.
  - Blueprint §11.6 (Phase 3.5): Pending → ✓ Shipped, with a note that
    it landed ahead of the originally planned slot.
  - Blueprint §8.1 (KV Store): replace hstore schema + rationale with
    JSONB. PK becomes (app_id, collection, key); cross-app isolation
    is enforced at the index, not just the service layer. Note 64 KiB
    per-value cap enforced at the service layer (lands with the KV PR
    in v1.1.1).
  - Blueprint new §7.5 (SDK Architecture): brief overview pointing to
    docs/sdk-shape.md. Includes §7.5.1 sketch of the trigger
    architecture (outbox + depth limit + (service, event, filter) →
    script).
  - Blueprint §12 Phase 4: restructured to enumerate v1.1.0 through
    v1.1.8 with one focused capability per release. Current focus
    moves to Phase 4 (v1.1.0) now that Phase 3.5 is done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:57:44 +02:00
MechaCat02
902dd78027 feat(picloud): opportunistic principal middleware on the data plane
The data-plane (POST /execute/{id} + user-route fallback) is
unauthenticated by default — public scripts get hit by anonymous HTTP
traffic. But some calls are authed (dashboard test-runs, API-key
invocations) and v1.1.x services will want to see the caller via
`cx.principal` for audit / authz once those features land.

  - New manager-core::attach_principal_if_present middleware. Always
    inserts Extension<Option<Principal>>: Some on resolved bearer/cookie,
    None on absent or malformed token. Fail-open on DB blip so a
    transient infra failure can't 500 anonymous traffic.
  - Wired in picloud build_app, scoped to the data-plane and user-routes
    routers only. The admin path keeps using require_authenticated; no
    double-resolve on the same token.
  - orchestrator-core handlers (execute_by_id, user_route_handler) now
    extract Extension<Option<Principal>> and pass it to build_exec_request.
    Replaces the temporary `None` placeholders from the previous commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:53:27 +02:00
MechaCat02
dea776b2a3 feat(orchestrator-core): ExecutionGate + 503/Retry-After on overflow
Adds a single global concurrency cap on the data-plane dispatch path:

  - orchestrator-core::gate::ExecutionGate wraps tokio::Semaphore.
    Non-blocking try_acquire — no queue. PICLOUD_MAX_CONCURRENT_EXECUTIONS
    env var (default 32) sets the cap.
  - LocalExecutorClient acquires a permit before spawn_blocking; the
    permit drops with the future so the slot returns automatically.
  - On refusal, ExecError::Overloaded { retry_after_secs: 1 } surfaces
    upward. ApiError::IntoResponse already maps that to 503 with a
    Retry-After header (landed in the previous commit alongside the
    variant itself).
  - picloud binary constructs the gate once at build_app and shares it
    with LocalExecutorClient.

The cap exists so a Rhai script storm can't drain the blocking-thread
pool — pushing back hard beats letting requests pile up against a
finite worker count. Per-app / per-script caps stay deferred until a
real workload demands them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:50:44 +02:00
MechaCat02
fe1dd90836 feat(executor-core): plumb app_id/principal/depth through ExecRequest
Adds the four internal-only fields every v1.1.x stateful service needs
to isolate by app and audit by caller:

  - app_id            — owning app for this invocation
  - principal         — Option<Principal>; data-plane is unauthenticated
                        today so the orchestrator passes None until the
                        opportunistic middleware lands in the next commit
  - trigger_depth     — 0 for direct invocations; the triggers framework
                        (v1.1.1) bounds runaway feedback loops via this
  - root_execution_id — equal to execution_id for direct invocations;
                        preserved across trigger fan-out for audit grouping

ExecRequest stays serializable (cluster mode still has to ship it across
processes when v1.3+ arrives). principal is `#[serde(skip)]` because
shared::Principal has no wire derivation today — when cluster mode lands
the wire-Principal question gets revisited properly.

Engine now carries a Services bundle (empty in v1.1.0). Engine::execute
constructs an SdkCallCx from the request and hands it to sdk::register_all
just after the per-call Rhai engine is built. The hook is a no-op in v1.1.0;
v1.1.1 KV registers its first native fns there.

Adds ExecError::Overloaded { retry_after_secs } and the matching 503 +
Retry-After mapping in orchestrator-core's IntoResponse. The gate that
actually produces this variant lands in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:48:39 +02:00
MechaCat02
aaba58dee1 refactor(executor-core): extract sdk/ module + move json↔dynamic bridge
Hoist the json_to_dynamic / dynamic_to_json helpers out of engine.rs
into a new sdk/bridge.rs so the v1.1.x service modules (KV, docs, …)
can use them without engine.rs being the sole owner. No behavioural
change — the sdk_contract round-trip test pins the observable JSON
fidelity.

Also lands the structural shape that subsequent v1.1.x PRs hook into:

  - sdk::register_all(engine, services, cx) — single per-call hook
    every stateful service registers through. Body is a no-op for
    v1.1.0; SdkCallCx construction inside Engine::execute lands in
    the next commit alongside the new ExecRequest fields it reads.
  - sdk::cx re-exports picloud_shared::SdkCallCx so SDK callers don't
    cross-import shared for one type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:43:03 +02:00
MechaCat02
2669714a51 feat(shared): SdkCallCx, Services bundle, ServiceEventEmitter trait shape
Foundation for the v1.1.x stateful SDK services. Lands the shape only:

  - SdkCallCx — per-call context plumbed into every future service
    trait method (app_id, principal, execution/request ids, trigger
    depth slots).
  - Services — empty non_exhaustive bundle; v1.1.1 (KV) adds the first
    field, subsequent PRs follow.
  - ServiceEventEmitter — async trait future services emit through;
    real outbox-backed impl lands with triggers in v1.1.1. NoopEventEmitter
    is the v1.1.0 default.

No behaviour change. Subsequent commits in this PR plumb these types
through executor-core and the orchestrator dispatch path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:40:09 +02:00
MechaCat02
662d5a2cf8 Merge branch 'test/cli-journeys'
Refactors the bare-metal CLI e2e into focused journey modules sharing
one `LazyLock<Fixture>` server (mirrors the dashboard Playwright
suite's spawn-once shape), and folds in the comprehensive review-pass
fixes on top:

* `pic login` is now real auth — username + password POST'd to
  `/auth/login`. `--token` / `PICLOUD_TOKEN` keep the paste-a-bearer
  path for CI and API keys.
* `pic logout`, `pic apps delete|show`, `pic scripts delete`,
  `pic api-keys mint|ls|rm`, top-level `pic invoke` / `pic deploy`.
* `PICLOUD_URL` / `PICLOUD_TOKEN` override the on-disk creds file
  globally (gcloud/aws semantics), not just for `pic login`.
* Global `--output tsv|json` flag.
* `pic scripts ls` (no `--app`) collapses the N+1 per-app walk that
  aborted on the first 404 into a single `GET /admin/scripts` plus
  one parallel `apps_list`. Drops the 5× retry the test suite was
  carrying around it.
* HTTP-4xx asserts tightened to specific codes (422/404/403). The
  old loose `"HTTP 4"` predicates would have masked a regressed 401
  from broken auth.
* Redundant `tests/integration.rs` deleted — every step it covered
  lives in one of the focused modules.

All endpoints touched on the server side already existed before this
branch — no `manager-core` change here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 23:35:11 +02:00
MechaCat02
fc8d473416 Merge branch 'feat/cli'
Lands the `pic` command-line client: `pic login | whoami | apps
ls/create | scripts ls/deploy/invoke | logs`. Thin wrapper over the
existing admin + execute HTTP surface — no new server endpoints
introduced by this branch.

See `crates/picloud-cli/` for the binary and its bare-metal e2e
test. The follow-up `test/cli-journeys` branch refactors that test
into focused journey modules and extends the CLI with login/logout,
delete commands, api-keys, and JSON output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 23:34:53 +02:00
MechaCat02
c73e3c80c0 test(cli): focused journey suite + cover new commands + tighten asserts
Replace the single bare-metal `integration.rs` test with focused
modules driven by the shared `LazyLock<Fixture>` server. Each module
owns one journey:

* `auth.rs` — login (both bearer and username+password paths),
  logout (local file + server-side session invalidation), env-vars
  overriding the on-disk credentials file, role-label rendering.
* `apps.rs` — create / ls / show / delete (with and without
  `--force`), invalid-slug rejection, conflict on duplicate slug.
* `scripts.rs` — deploy (create + update), name override, version
  bumping, `ls` (with and without `--app`), delete.
* `invoke.rs` — body sources (inline, `@file`, `@-`), header
  propagation, non-2xx exit semantics, top-level `pic invoke` alias.
* `logs.rs` — emptiness, status labels, `--limit`, summary truncation.
* `roles.rs` — Member RBAC: app-list filtering, viewer-vs-editor on
  deploy, member can hit the unguarded data plane, non-member 403
  on logs.
* `output.rs` — TSV column headers, stdout/stderr separation, RFC3339
  shape, and the `--output json` invariants for apps / scripts /
  logs / whoami.
* `api_keys.rs` — mint emits `raw_token` once, `ls` omits it, the
  minted token works as a real bearer, `rm` invalidates server-side.

Bug-bug-fix-bug-fix:

* The 5× retry loop in `ls_without_app_walks_every_accessible_app`
  was masking the abort-on-first-404 walk in the CLI. Now that the
  CLI uses a single server call, the retry is gone — the test runs
  one `pic scripts ls` and asserts.
* Six `predicate::str::contains("HTTP 4")` assertions tightened to
  the specific status code: 422 for invalid-slug, 404 for unknown
  app/script/log id, 403 for role denials. Loose `HTTP 4` would
  have silently matched a regressed 401 from broken auth.
* `tests/integration.rs` deleted — every step it covered is in one
  of the focused modules above.
* Members module exposes `MEMBER_PASSWORD` so auth tests can drive
  the real username+password flow over stdin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 23:34:03 +02:00
MechaCat02
f147665157 feat(cli): real auth, delete commands, api-keys, JSON output, env override
Address the review findings on the CLI surface:

* `pic login` now prompts for username + password and POSTs to
  `/api/v1/admin/auth/login`. `--token` (and `PICLOUD_TOKEN`) still
  works for paste-a-bearer flows (CI, long-lived API keys). Falls
  back to a plain stdin read when no controlling tty is attached.
* `pic logout` revokes the session server-side and deletes the local
  credentials file. Idempotent.
* `PICLOUD_URL` / `PICLOUD_TOKEN` now override the on-disk credentials
  file for every command via `config::resolve`, not just for
  `pic login`. Matches gcloud/aws/kubectl semantics.
* New commands: `pic apps delete [--force]`, `pic apps show`,
  `pic scripts delete`, `pic api-keys mint|ls|rm`, plus top-level
  `pic invoke` / `pic deploy` shortcuts.
* `pic scripts ls` (no `--app`) now issues a single
  `GET /admin/scripts` + one `apps_list` in parallel and joins
  client-side, instead of walking N+1 per-app calls that aborted on
  the first 404 — the bug the test suite was retrying around.
* Global `--output tsv|json` flag wired through every list/show and
  through `whoami` / `logs`. TSV stays pipe-friendly; JSON is a real
  array of objects (or a flat object for single-row views).
* `whoami` and `logs` now emit labeled output instead of headerless
  tab lines, consistent with the existing `apps ls` / `scripts ls`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 23:33:44 +02:00
MechaCat02
e4851b3deb test(cli): extract shared Fixture into tests/common
The single bare-metal integration test now reuses a `LazyLock<Fixture>`
that spawns picloud once on a private port and shares it across every
test in the binary. Sets the stage for per-surface journey modules
(auth, apps, scripts, invoke, logs, roles, output) without each one
paying for its own server spawn — same trick the dashboard Playwright
suite uses with global-setup.

Notes:
- `tests/cli.rs` becomes a tiny module list; the seed flow moved to
  `tests/integration.rs`. The seed slug now goes through
  `common::unique_slug` so parallel/serial reruns can't collide.
- `autotests = false` + an explicit `[[test]] name = "cli"` keeps Cargo
  from auto-promoting future `tests/*.rs` files into their own binaries
  (which would each respawn picloud).
- Subprocess cleanup uses `libc::atexit` to SIGTERM picloud when the
  test binary exits. PR_SET_PDEATHSIG was tried and rejected: it fires
  when the *thread* that forked dies, and cargo's per-test worker
  threads exit between tests, which killed the fixture mid-suite.
- New helpers: AppGuard/UserGuard (RAII teardown), member_user /
  grant_membership / update_membership (direct API for role tests),
  unique_slug / unique_username, pic_as / pic_no_env.
- Two `fixture_url_is_shared_*` tests prove the LazyLock is actually
  shared, not respawned per test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:21:12 +02:00
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
MechaCat02
ec3c768262 test(dashboard): add full-stack integration specs
Two scenarios that span the dashboard UI and the data/control plane
end-to-end:

- App + domain claim + script + route all created via the dashboard,
  then the script is invoked through the public URL with the
  matching Host header. Verifies the dashboard actions actually
  reach the orchestrator's route trie.
- API key minted via the dashboard, then used as a bearer token
  against /api/v1/admin/* (the CLI surface). Confirms the scope is
  enforced (script:read passes /scripts, 403s /admins) and that
  revoking via the dashboard immediately invalidates the token.

Also: the B7 copy-token test selected the mint-form Name input via
getByLabel('Name'), which became ambiguous once the integration
test created an app and the Binding dropdown was no longer empty.
Switched both B7 mint flows to placeholder-based selectors.

Suite: 57/57 passing in ~18s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:56:24 +02:00
MechaCat02
3e72ddde78 test(dashboard): stabilize the e2e suite under parallel runs
Three issues found while running the full B1–B8 suite together:

- The B1 logout test was driving the shared admin storageState
  token, invalidating it for every subsequent test. Switched it to
  a fresh login so its session is disposable.
- Bumped navigationTimeout to 30s and capped local workers at 4 to
  cope with the Vite dev server's first-compile cost under
  parallel load. Local also gets one retry to absorb intermittent
  warmup flakiness.
- Cleared a few lint warnings (unused appId / _adminPage vars) and
  belt-and-braces gitignore for playwright artifacts written to
  the repo root when the CLI is invoked from there by accident.

Suite now: 55/55 passing in ~21s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:44:07 +02:00
MechaCat02
cd20ffb580 test(dashboard): add e2e cross-cutting security spec (B8)
Five tests covering platform-wide guarantees: expired-token
redirect, HttpOnly session cookie, bootstrap password not leaked
into the DOM after login, missing-app slug fails gracefully, and
an XSS-sink probe across the main authed routes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:43:51 +02:00
MechaCat02
cddd479fd2 test(dashboard): add e2e profile + API keys spec (B7)
Six tests covering /admin/profile: mint instance-wide key with the
reveal/ack flow, the app-binding mutual-exclusion guard (instance
scopes auto-disabled), revoke via the ConfirmModal, the
?denied=users banner, plus adversarial cases (empty-name button
disabled, copy-token writes the full token to the clipboard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:31:58 +02:00
MechaCat02
8bbcdd86aa test(dashboard): add e2e instance users spec (B6)
Eight tests covering the Users admin page: invite happy path (form
→ reveal modal → ack-gated dismiss → row in table), live username
validation, search filter, deactivate/reactivate, delete with phrase
modal, member-role redirect to /profile?denied=users, plus
adversarial inputs (too-short username, script-tag email).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:28:04 +02:00
MechaCat02
2d56e42699 test(dashboard): add e2e app members spec (B5)
Four tests covering the Members tab: invite + remove (action-menu +
phrase modal), role change, the non-app-admin viewer who never sees
the Members tab at all (cross-context via a second admin login),
and an adversarial that the role dropdown only exposes the
documented set of values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:23:01 +02:00
MechaCat02
f9d9ed8cb4 test(dashboard): add e2e routing spec (B4)
Seven tests covering the Routing tab inside the script editor: add
+ list + remove (handling the window.confirm dialog), match-preview
round trip, path-kind mismatch warning, unclaimed-host warning,
duplicate-route 409, plus reserved-prefix rejection and a path-XSS
adversarial that checks no script tag escapes into the route list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:18:01 +02:00
MechaCat02
c17f8a5bd9 test(dashboard): add e2e script CRUD + editor spec (B3)
Seven tests covering script creation via the Scripts tab, the source
editor (CodeMirror typing + save + reload), Format-button error
surfaces for both Rhai and the test-invoke JSON body, the test-invoke
happy path, settings input validation, and an infinite-loop adversarial
that asserts the sandbox timeout reports cleanly and the editor stays
interactive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:14:09 +02:00
MechaCat02
7198fb4d0e test(dashboard): add e2e apps lifecycle spec (B2)
Seven tests covering app CRUD via the dashboard: create with
slug auto-derive, settings rename, delete with phrase-confirmation
modal, historical-slug takeover via the create form, plus adversarial
inputs (slug normalization, XSS in name/description, oversized name).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:10:47 +02:00
MechaCat02
029a4a199f test(dashboard): add e2e auth & navigation spec (B1)
Eight tests covering the login form, layout-level redirects, logout,
and the obvious adversarial inputs (XSS in username, empty submit,
password field type, leaked tokens). All targeted at /admin/login and
the bounce-back behaviors implemented in +layout.svelte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:06:13 +02:00
MechaCat02
74f7b3b631 test(dashboard): add Playwright e2e scaffolding with smoke spec
Milestone A of the frontend test plan. Sets up the test rig — config,
globalSetup that probes the backend and seeds an admin session into
storageState, lightweight fixtures, and a 3-test smoke spec — without
yet covering any user journeys (those land in Milestone B).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:03:44 +02:00
103 changed files with 9810 additions and 359 deletions

11
.gitignore vendored
View File

@@ -30,6 +30,17 @@ config.local.toml
/dashboard/build
/dashboard/.env
# Dashboard — Playwright E2E
/dashboard/tests/e2e/.auth
/dashboard/tests/e2e/.results
/dashboard/playwright-report
/dashboard/test-results
/dashboard/.playwright
# When playwright is invoked from the repo root by accident, these
# also land here.
/playwright-report
/test-results
# Caddy
/caddy/data
/caddy/config

View File

@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
**Current focus (Phase 4, v1.1):** data-plane SDKs — KV store, then document store, then HTTP client, then cron triggers. See blueprint §12. Phase 3 (admin auth + multi-app scoping) shipped; every v1.1+ table starts with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE` and every Rhai SDK call resolves its app from the execution context.
**Current focus (Phase 4, v1.1.0):** SDK foundation + stdlib utilities — the shape every v1.1.x service module hangs off, see [docs/sdk-shape.md](docs/sdk-shape.md). Stdlib reference at [docs/stdlib-reference.md](docs/stdlib-reference.md). Subsequent v1.1.x releases (KV in v1.1.1, docs in v1.1.2, …) fill it in; see blueprint §12 for the full table. Phase 3 shipped end-to-end: admin auth, multi-app scoping, and Phase 3.5 capability gating (`manager-core::authz::{can, require, Capability}` + migration `0006_users_authz.sql`). Every v1.1+ table starts with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE` and every Rhai SDK call resolves its app from the execution context.
## Three-Service Architecture
@@ -48,7 +48,7 @@ Caddy fronts everything. Same Caddyfile shape works for single-node and cluster
- **Rust 1.92+** workspace, pinned via `rust-toolchain.toml`
- **Axum** for HTTP, **Tokio** async, **sqlx** for Postgres
- **Rhai** embedded scripting (in `executor-core`)
- **PostgreSQL 15+** with `pgcrypto` and (v1.1+) `hstore`
- **PostgreSQL 15+** with `pgcrypto`. v1.1+ data-plane tables use JSONB for value columns (hstore was considered for KV and rejected — see blueprint §8.1).
- **SvelteKit** dashboard, static adapter, CodeMirror 6 for the script editor
- **Caddy 2** reverse proxy (auto-HTTPS in prod)
- **Docker Compose** for dev and single-node prod
@@ -103,9 +103,22 @@ docs/
- **Honor the three-service boundary.** Don't reach across `*-core` crates. If `orchestrator-core` needs something from `manager-core`, define a trait in `shared` and inject the impl.
- **`executor-core` has no Postgres dependency.** Data-plane services (kv, docs, users — v1.1+) come in via injected `ServiceProvider` traits.
- **Database writes only from `manager-core`.** `orchestrator-core` reads scripts (cached); `executor-core` doesn't touch the DB.
- **Stateful SDK services use the handle pattern + `SdkCallCx`.** Collection-scoped surfaces look like `kv::collection("x").get(k)`, not `kv::get("x", k)`. Every service trait method takes `&SdkCallCx` and **MUST** derive `app_id` from `cx.app_id` — never trust a script-passed `app_id`. That is the cross-app isolation boundary. See [docs/sdk-shape.md](docs/sdk-shape.md).
- **MVP builds only the `picloud` all-in-one binary.** The three split binaries exist as skeletons so the crate boundaries stay honest; flesh them out only when cluster mode is being implemented.
- **Trunk-based dev.** See [docs/git-workflow.md](docs/git-workflow.md). No long-lived branches. Feature flags for incomplete work.
## Runtime configuration
Environment variables consumed by the `picloud` binary:
| Variable | Default | Purpose |
|---|---|---|
| `PICLOUD_BIND` | `0.0.0.0:8080` | HTTP listen address. Port 8080 is owned by another process on this host — override locally. |
| `PICLOUD_MAX_CONCURRENT_EXECUTIONS` | `32` | Global concurrency cap on data-plane script executions. Overflow returns HTTP 503 with `Retry-After: 1` immediately (no queue). |
| `DATABASE_URL` | — | Required. Postgres connection string. |
| `PICLOUD_SESSION_TTL_HOURS` | `24` | Sliding-window session lifetime. |
| `PICLOUD_SANDBOX_MAX_*` | conservative defaults | Per-knob admin ceilings on Rhai sandbox overrides. See `manager-core::sandbox::SandboxCeiling`. |
## Out of MVP
Queue triggers, cron triggers, SMTP ingress, KV / docs / email / users / HTTP SDKs in scripts, interceptors, workflows, function-to-function `invoke()`, secrets, metrics dashboard. All deferred to v1.1+ per the blueprint. Don't pre-build for them — but don't make decisions that close the door on them either.

358
Cargo.lock generated
View File

@@ -40,6 +40,56 @@ dependencies = [
"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]]
name = "anyhow"
version = "1.0.102"
@@ -68,6 +118,21 @@ dependencies = [
"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]]
name = "async-trait"
version = "0.1.89"
@@ -236,6 +301,17 @@ dependencies = [
"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]]
name = "bumpalo"
version = "3.20.3"
@@ -302,6 +378,52 @@ dependencies = [
"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]]
name = "concurrent-queue"
version = "2.5.0"
@@ -440,6 +562,12 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "difflib"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
[[package]]
name = "digest"
version = "0.10.7"
@@ -452,6 +580,27 @@ dependencies = [
"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]]
name = "displaydoc"
version = "0.2.5"
@@ -516,6 +665,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "figment"
version = "0.10.19"
@@ -536,6 +691,15 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "flume"
version = "0.11.1"
@@ -1010,6 +1174,12 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
@@ -1077,6 +1247,12 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
@@ -1161,6 +1337,12 @@ dependencies = [
"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]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -1231,6 +1413,18 @@ dependencies = [
"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]]
name = "parking"
version = "2.2.1"
@@ -1335,6 +1529,27 @@ dependencies = [
"uuid",
]
[[package]]
name = "picloud-cli"
version = "0.6.0"
dependencies = [
"anyhow",
"assert_cmd",
"chrono",
"clap",
"directories",
"libc",
"picloud-shared",
"predicates",
"reqwest",
"rpassword",
"serde",
"serde_json",
"tempfile",
"tokio",
"toml",
]
[[package]]
name = "picloud-executor"
version = "0.6.0"
@@ -1351,8 +1566,13 @@ dependencies = [
name = "picloud-executor-core"
version = "0.6.0"
dependencies = [
"base64",
"chrono",
"hex",
"percent-encoding",
"picloud-shared",
"rand 0.8.6",
"regex",
"rhai",
"serde",
"serde_json",
@@ -1509,6 +1729,36 @@ dependencies = [
"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]]
name = "pretty_assertions"
version = "1.4.1"
@@ -1704,6 +1954,29 @@ dependencies = [
"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]]
name = "regex-automata"
version = "0.4.14"
@@ -1729,7 +2002,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
@@ -1812,6 +2087,17 @@ dependencies = [
"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]]
name = "rsa"
version = "0.9.10"
@@ -1832,6 +2118,16 @@ dependencies = [
"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]]
name = "rust-multipart-rfc7578_2"
version = "0.8.0"
@@ -1853,6 +2149,19 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "rustls"
version = "0.23.40"
@@ -2327,6 +2636,12 @@ dependencies = [
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
@@ -2364,6 +2679,25 @@ dependencies = [
"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]]
name = "thin-vec"
version = "0.2.18"
@@ -2783,6 +3117,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.1"
@@ -2813,6 +3153,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "want"
version = "0.3.1"
@@ -3066,6 +3415,15 @@ dependencies = [
"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]]
name = "windows-sys"
version = "0.60.2"

View File

@@ -9,6 +9,7 @@ members = [
"crates/picloud-manager",
"crates/picloud-orchestrator",
"crates/picloud-executor",
"crates/picloud-cli",
]
[workspace.package]
@@ -73,6 +74,12 @@ sha2 = "0.10"
base64 = "0.22"
data-encoding = "2.6"
# Stdlib utility crates (v1.1.0 stdlib PR — registered into the
# Rhai engine as the regex::/random::/etc. namespaces)
regex = "1"
hex = "0.4"
percent-encoding = "2"
[workspace.lints.rust]
unsafe_code = "forbid"

View File

@@ -18,3 +18,10 @@ tracing.workspace = true
uuid.workspace = true
chrono.workspace = true
rhai.workspace = true
# Stdlib utility modules — see crates/executor-core/src/sdk/stdlib/.
regex.workspace = true
rand.workspace = true
base64.workspace = true
hex.workspace = true
percent-encoding.workspace = true

View File

@@ -3,30 +3,38 @@ use std::sync::{Arc, Mutex};
use std::time::Instant;
use chrono::Utc;
use picloud_shared::{ScriptValidator, ValidationError, SDK_VERSION};
use picloud_shared::{ScriptValidator, SdkCallCx, Services, ValidationError, SDK_VERSION};
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module, Scope};
use serde_json::Value as Json;
use crate::sandbox::Limits;
use crate::sdk;
use crate::sdk::bridge::{dynamic_to_json, json_to_dynamic};
use crate::types::{
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
};
/// Preconfigured Rhai engine with sandbox limits applied.
/// Preconfigured Rhai engine with sandbox limits applied and the SDK
/// `Services` bundle attached.
///
/// One `Engine` is constructed at process startup and reused across
/// invocations. `execute` is **synchronous** — it owns the per-call
/// scope and log buffer. Wall-clock timeouts and offloading off the
/// async runtime belong to the caller (orchestrator-core's
/// `LocalExecutorClient` wraps this with `spawn_blocking` + `timeout`).
///
/// The `Services` bundle is empty in v1.1.0; subsequent v1.1.x PRs add
/// service handles (KV, docs, …) and `sdk::register_all` wires them
/// into each per-call Rhai engine.
pub struct Engine {
limits: Limits,
services: Services,
}
impl Engine {
#[must_use]
pub fn new(limits: Limits) -> Self {
Self { limits }
pub fn new(limits: Limits, services: Services) -> Self {
Self { limits, services }
}
#[must_use]
@@ -55,7 +63,20 @@ impl Engine {
pub fn execute(&self, source: &str, req: ExecRequest) -> Result<ExecResponse, ExecError> {
let effective_limits = self.limits.with_overrides(&req.sandbox_overrides);
let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
let engine = build_engine(effective_limits, Some(logs.clone()));
let mut engine = build_engine(effective_limits, Some(logs.clone()));
// Per-call context handed to every stateful SDK service via the
// `sdk::register_all` hook. The Arc lets future service closures
// capture cheap clones of the cx for use at script-call time.
let cx = Arc::new(SdkCallCx {
app_id: req.app_id,
principal: req.principal.clone(),
execution_id: req.execution_id,
request_id: req.request_id,
trigger_depth: req.trigger_depth,
root_execution_id: req.root_execution_id,
});
sdk::register_all(&mut engine, &self.services, cx);
let ast = engine
.compile(source)
@@ -122,6 +143,11 @@ fn build_engine(limits: Limits, logs: Option<Arc<Mutex<Vec<LogEntry>>>>) -> Rhai
engine.register_static_module("log", build_log_module(logs).into());
}
// Stateless utility modules — regex::/random::/time::/json::/base64::/
// hex::/url::. Always registered, including in the parse-only validate
// path, so script authors get consistent surface in both phases.
sdk::stdlib::register_stdlib(&mut engine);
engine
}
@@ -265,69 +291,6 @@ fn parse_structured_response(map: Map) -> Result<(u16, BTreeMap<String, String>,
Ok((status_code, headers, body))
}
// ----------------------------------------------------------------------------
// Rhai ↔ serde_json bridges
// ----------------------------------------------------------------------------
fn json_to_dynamic(value: Json) -> Dynamic {
match value {
Json::Null => Dynamic::UNIT,
Json::Bool(b) => b.into(),
Json::Number(n) => {
if let Some(i) = n.as_i64() {
i.into()
} else if let Some(f) = n.as_f64() {
f.into()
} else {
n.to_string().into()
}
}
Json::String(s) => s.into(),
Json::Array(arr) => arr
.into_iter()
.map(json_to_dynamic)
.collect::<Vec<Dynamic>>()
.into(),
Json::Object(obj) => {
let mut m = Map::new();
for (k, v) in obj {
m.insert(k.into(), json_to_dynamic(v));
}
Dynamic::from(m)
}
}
}
fn dynamic_to_json(value: &Dynamic) -> Json {
if value.is_unit() {
return Json::Null;
}
if let Ok(b) = value.as_bool() {
return Json::Bool(b);
}
if let Ok(i) = value.as_int() {
return Json::Number(i.into());
}
if let Ok(f) = value.as_float() {
return serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number);
}
if value.is_string() {
return Json::String(value.clone().into_string().unwrap_or_default());
}
if let Some(arr) = value.clone().try_cast::<rhai::Array>() {
return Json::Array(arr.iter().map(dynamic_to_json).collect());
}
if let Some(map) = value.clone().try_cast::<Map>() {
let mut out = serde_json::Map::new();
for (k, v) in map {
out.insert(k.to_string(), dynamic_to_json(&v));
}
return Json::Object(out);
}
// Anything else (timestamps, custom types) — best-effort string form.
Json::String(value.to_string())
}
// ----------------------------------------------------------------------------
// Error mapping
// ----------------------------------------------------------------------------

View File

@@ -8,6 +8,7 @@ pub mod context;
pub mod engine;
pub mod logging;
pub mod sandbox;
pub mod sdk;
pub mod types;
pub use engine::Engine;

View File

@@ -0,0 +1,77 @@
//! JSON ↔ Rhai `Dynamic` value bridge.
//!
//! Originally inline in `engine.rs`; moved here for v1.1.0 so future
//! service modules (KV in v1.1.1, docs in v1.1.2, …) can convert
//! values without `engine.rs` being the only owner of the conversions.
//! Behaviour is unchanged from the pre-extraction implementation —
//! `sdk_contract.rs::json_round_trip_preserves_nested_shapes` pins the
//! observable round-trip.
use rhai::{Dynamic, Map};
use serde_json::Value as Json;
/// Convert a `serde_json::Value` into a Rhai `Dynamic` suitable for
/// pushing into a script's scope. Numbers prefer the narrowest type
/// (`i64` over `f64`); anything that can't round-trip falls back to a
/// string so the script always sees a defined value.
pub fn json_to_dynamic(value: Json) -> Dynamic {
match value {
Json::Null => Dynamic::UNIT,
Json::Bool(b) => b.into(),
Json::Number(n) => {
if let Some(i) = n.as_i64() {
i.into()
} else if let Some(f) = n.as_f64() {
f.into()
} else {
n.to_string().into()
}
}
Json::String(s) => s.into(),
Json::Array(arr) => arr
.into_iter()
.map(json_to_dynamic)
.collect::<Vec<Dynamic>>()
.into(),
Json::Object(obj) => {
let mut m = Map::new();
for (k, v) in obj {
m.insert(k.into(), json_to_dynamic(v));
}
Dynamic::from(m)
}
}
}
/// Convert a Rhai `Dynamic` back to a `serde_json::Value`. Custom Rhai
/// types (timestamps, user-registered modules) fall back to their
/// `Display` form so they appear as strings in JSON output rather than
/// failing the response build.
pub fn dynamic_to_json(value: &Dynamic) -> Json {
if value.is_unit() {
return Json::Null;
}
if let Ok(b) = value.as_bool() {
return Json::Bool(b);
}
if let Ok(i) = value.as_int() {
return Json::Number(i.into());
}
if let Ok(f) = value.as_float() {
return serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number);
}
if value.is_string() {
return Json::String(value.clone().into_string().unwrap_or_default());
}
if let Some(arr) = value.clone().try_cast::<rhai::Array>() {
return Json::Array(arr.iter().map(dynamic_to_json).collect());
}
if let Some(map) = value.clone().try_cast::<Map>() {
let mut out = serde_json::Map::new();
for (k, v) in map {
out.insert(k.to_string(), dynamic_to_json(&v));
}
return Json::Object(out);
}
Json::String(value.to_string())
}

View File

@@ -0,0 +1,10 @@
//! Re-export of `picloud_shared::SdkCallCx`.
//!
//! The type itself lives in `picloud-shared` because future stateful
//! service impls live in `manager-core` (which `executor-core` must
//! not depend on) and need to reference the same cx shape. This
//! re-export lets executor-side code write
//! `use picloud_executor_core::sdk::SdkCallCx;` instead of reaching
//! into `picloud_shared` for one type.
pub use picloud_shared::SdkCallCx;

View File

@@ -0,0 +1,40 @@
//! SDK plumbing — types and the per-call registration entry point.
//!
//! `executor-core` is responsible for building the per-invocation Rhai
//! engine and wiring stateful services into it. v1.1.0 ships the
//! shapes (`Services` bundle, `SdkCallCx`, `register_all` entry point)
//! but no actual services — subsequent v1.1.x PRs (KV in v1.1.1,
//! docs in v1.1.2, …) extend `register_all` rather than re-threading
//! plumbing through `engine.rs`.
//!
//! Bridge functions (`json_to_dynamic` / `dynamic_to_json`) also live
//! here so service modules can convert values without `engine.rs`
//! being the only home for the conversion logic.
pub mod bridge;
pub mod cx;
pub mod stdlib;
pub use bridge::{dynamic_to_json, json_to_dynamic};
pub use cx::SdkCallCx;
use std::sync::Arc;
use picloud_shared::Services;
use rhai::Engine as RhaiEngine;
/// Single hook every v1.1.x stateful service registers into. Called
/// once per invocation, just after `build_engine` constructs the
/// sandboxed Rhai engine and just before script compilation.
///
/// v1.1.0 ships an intentionally empty body — the call site exists so
/// future PRs (KV first) drop their registration logic here rather
/// than reaching into `engine.rs::build_engine`. The signature is
/// locked: subsequent PRs MUST keep the same parameter shape so that
/// hosts don't have to re-thread the plumbing.
pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
// Intentionally inert in v1.1.0. The unused-suppression below is a
// load-bearing placeholder: future PRs replace this `let _` with
// real `register_kv(engine, services, cx.clone())` calls etc.
let _ = (engine, services, cx);
}

View File

@@ -0,0 +1,48 @@
//! `base64::` — standard and URL-safe Base64.
//!
//! Two encoders are exposed: standard alphabet with padding (`encode`/
//! `decode`) and URL-safe alphabet without padding (`encode_url`/
//! `decode_url`). Each encoder accepts both `String` and `Blob` inputs
//! as separate Rhai overloads; decoders always return `Blob` — the
//! caller knows whether the original bytes were textual.
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use base64::Engine as _;
use rhai::{Blob, Engine as RhaiEngine, EvalAltResult, Module};
pub fn register(engine: &mut RhaiEngine) {
let mut module = Module::new();
module.set_native_fn("encode", |s: &str| -> Result<String, Box<EvalAltResult>> {
Ok(STANDARD.encode(s.as_bytes()))
});
module.set_native_fn("encode", |b: Blob| -> Result<String, Box<EvalAltResult>> {
Ok(STANDARD.encode(&b))
});
module.set_native_fn("decode", |s: &str| -> Result<Blob, Box<EvalAltResult>> {
STANDARD
.decode(s)
.map_err(|e| format!("base64::decode: {e}").into())
});
module.set_native_fn(
"encode_url",
|s: &str| -> Result<String, Box<EvalAltResult>> {
Ok(URL_SAFE_NO_PAD.encode(s.as_bytes()))
},
);
module.set_native_fn(
"encode_url",
|b: Blob| -> Result<String, Box<EvalAltResult>> { Ok(URL_SAFE_NO_PAD.encode(&b)) },
);
module.set_native_fn(
"decode_url",
|s: &str| -> Result<Blob, Box<EvalAltResult>> {
URL_SAFE_NO_PAD
.decode(s)
.map_err(|e| format!("base64::decode_url: {e}").into())
},
);
engine.register_static_module("base64", module.into());
}

View File

@@ -0,0 +1,21 @@
//! `hex::` — hexadecimal encode/decode (lowercase output, case-
//! insensitive input). String and Blob inputs are both accepted on
//! encode; decode always returns `Blob`.
use rhai::{Blob, Engine as RhaiEngine, EvalAltResult, Module};
pub fn register(engine: &mut RhaiEngine) {
let mut module = Module::new();
module.set_native_fn("encode", |s: &str| -> Result<String, Box<EvalAltResult>> {
Ok(hex::encode(s.as_bytes()))
});
module.set_native_fn("encode", |b: Blob| -> Result<String, Box<EvalAltResult>> {
Ok(hex::encode(&b))
});
module.set_native_fn("decode", |s: &str| -> Result<Blob, Box<EvalAltResult>> {
hex::decode(s).map_err(|e| format!("hex::decode: {e}").into())
});
engine.register_static_module("hex", module.into());
}

View File

@@ -0,0 +1,43 @@
//! `json::` — JSON parse and stringify. Reuses the bridge functions in
//! `crate::sdk::bridge` so script-visible JSON has the same shape
//! (numbers, maps, arrays, nulls) as `ctx.request.body` already does.
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Module};
use crate::sdk::bridge::{dynamic_to_json, json_to_dynamic};
pub fn register(engine: &mut RhaiEngine) {
let mut module = Module::new();
register_parse(&mut module);
register_stringify(&mut module);
register_stringify_pretty(&mut module);
engine.register_static_module("json", module.into());
}
fn register_parse(module: &mut Module) {
module.set_native_fn("parse", |s: &str| -> Result<Dynamic, Box<EvalAltResult>> {
let value: serde_json::Value =
serde_json::from_str(s).map_err(|e| format!("json::parse: {e}"))?;
Ok(json_to_dynamic(value))
});
}
fn register_stringify(module: &mut Module) {
module.set_native_fn(
"stringify",
|v: Dynamic| -> Result<String, Box<EvalAltResult>> {
serde_json::to_string(&dynamic_to_json(&v))
.map_err(|e| format!("json::stringify: {e}").into())
},
);
}
fn register_stringify_pretty(module: &mut Module) {
module.set_native_fn(
"stringify_pretty",
|v: Dynamic| -> Result<String, Box<EvalAltResult>> {
serde_json::to_string_pretty(&dynamic_to_json(&v))
.map_err(|e| format!("json::stringify_pretty: {e}").into())
},
);
}

View File

@@ -0,0 +1,25 @@
//! Stateless utility modules registered once at engine build via
//! `Engine::register_static_module`. They have no per-call state, no
//! cross-app sensitivity, and no `SdkCallCx` — distinguishing them
//! from stateful service modules (KV, docs, …) which hook into
//! `sdk::register_all` instead. See [docs/sdk-shape.md](../../../../../docs/sdk-shape.md).
use rhai::Engine as RhaiEngine;
pub mod base64;
pub mod hex;
pub mod json;
pub mod random;
pub mod regex;
pub mod time;
pub mod url;
pub fn register_stdlib(engine: &mut RhaiEngine) {
regex::register(engine);
random::register(engine);
time::register(engine);
json::register(engine);
base64::register(engine);
hex::register(engine);
url::register(engine);
}

View File

@@ -0,0 +1,70 @@
//! `random::` — CSPRNG primitives (`rand::rngs::OsRng`).
//!
//! Only the OS RNG is exposed. No "fast non-crypto" variant — scripts
//! should not pick between secure and insecure entropy. Output sizes
//! are capped to keep a single script call from blowing host memory.
use rand::distributions::{Alphanumeric, DistString};
use rand::{rngs::OsRng, Rng, RngCore};
use rhai::{Blob, Engine as RhaiEngine, EvalAltResult, Module};
use uuid::Uuid;
const MAX_BYTES: i64 = 65_536;
const MAX_STRING: i64 = 4_096;
pub fn register(engine: &mut RhaiEngine) {
let mut module = Module::new();
register_int(&mut module);
register_float(&mut module);
register_bytes(&mut module);
register_string(&mut module);
register_uuid(&mut module);
engine.register_static_module("random", module.into());
}
fn register_int(module: &mut Module) {
module.set_native_fn(
"int",
|min: i64, max: i64| -> Result<i64, Box<EvalAltResult>> {
if min > max {
return Err(format!("random::int: min ({min}) > max ({max})").into());
}
Ok(OsRng.gen_range(min..=max))
},
);
}
fn register_float(module: &mut Module) {
module.set_native_fn("float", || -> Result<f64, Box<EvalAltResult>> {
Ok(OsRng.gen::<f64>())
});
}
fn register_bytes(module: &mut Module) {
module.set_native_fn("bytes", |n: i64| -> Result<Blob, Box<EvalAltResult>> {
if !(0..=MAX_BYTES).contains(&n) {
return Err(format!("random::bytes: n must be in 0..={MAX_BYTES}, got {n}").into());
}
// Safe: n is non-negative and bounded by MAX_BYTES, which fits in usize.
let len = usize::try_from(n).expect("n bounded above by MAX_BYTES");
let mut buf = vec![0u8; len];
OsRng.fill_bytes(&mut buf);
Ok(buf)
});
}
fn register_string(module: &mut Module) {
module.set_native_fn("string", |n: i64| -> Result<String, Box<EvalAltResult>> {
if !(0..=MAX_STRING).contains(&n) {
return Err(format!("random::string: n must be in 0..={MAX_STRING}, got {n}").into());
}
let len = usize::try_from(n).expect("n bounded above by MAX_STRING");
Ok(Alphanumeric.sample_string(&mut OsRng, len))
});
}
fn register_uuid(module: &mut Module) {
module.set_native_fn("uuid", || -> Result<String, Box<EvalAltResult>> {
Ok(Uuid::new_v4().to_string())
});
}

View File

@@ -0,0 +1,105 @@
//! `regex::` — non-backtracking regular expressions (Rust `regex` crate).
//!
//! Patterns compile per call. No cache: premature for v1.1.0, and the
//! `regex` crate's linear-time guarantees keep per-call cost bounded.
//! Catastrophic patterns are rejected at compile time by the crate
//! itself; no extra defense needed.
use regex::Regex;
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Module};
pub fn register(engine: &mut RhaiEngine) {
let mut module = Module::new();
register_is_match(&mut module);
register_find(&mut module);
register_find_all(&mut module);
register_replace(&mut module);
register_replace_all(&mut module);
register_split(&mut module);
register_captures(&mut module);
engine.register_static_module("regex", module.into());
}
fn compile(pattern: &str) -> Result<Regex, Box<EvalAltResult>> {
Regex::new(pattern).map_err(|e| format!("invalid regex: {e}").into())
}
fn register_is_match(module: &mut Module) {
module.set_native_fn(
"is_match",
|pattern: &str, text: &str| -> Result<bool, Box<EvalAltResult>> {
Ok(compile(pattern)?.is_match(text))
},
);
}
fn register_find(module: &mut Module) {
module.set_native_fn(
"find",
|pattern: &str, text: &str| -> Result<Dynamic, Box<EvalAltResult>> {
Ok(compile(pattern)?
.find(text)
.map_or(Dynamic::UNIT, |m| Dynamic::from(m.as_str().to_string())))
},
);
}
fn register_find_all(module: &mut Module) {
module.set_native_fn(
"find_all",
|pattern: &str, text: &str| -> Result<Array, Box<EvalAltResult>> {
Ok(compile(pattern)?
.find_iter(text)
.map(|m| Dynamic::from(m.as_str().to_string()))
.collect())
},
);
}
fn register_replace(module: &mut Module) {
module.set_native_fn(
"replace",
|pattern: &str, text: &str, replacement: &str| -> Result<String, Box<EvalAltResult>> {
Ok(compile(pattern)?.replace(text, replacement).into_owned())
},
);
}
fn register_replace_all(module: &mut Module) {
module.set_native_fn(
"replace_all",
|pattern: &str, text: &str, replacement: &str| -> Result<String, Box<EvalAltResult>> {
Ok(compile(pattern)?
.replace_all(text, replacement)
.into_owned())
},
);
}
fn register_split(module: &mut Module) {
module.set_native_fn(
"split",
|pattern: &str, text: &str| -> Result<Array, Box<EvalAltResult>> {
Ok(compile(pattern)?
.split(text)
.map(|s| Dynamic::from(s.to_string()))
.collect())
},
);
}
fn register_captures(module: &mut Module) {
module.set_native_fn(
"captures",
|pattern: &str, text: &str| -> Result<Dynamic, Box<EvalAltResult>> {
let re = compile(pattern)?;
Ok(re.captures(text).map_or(Dynamic::UNIT, |caps| {
let arr: Array = caps
.iter()
.map(|m| m.map_or(Dynamic::UNIT, |m| Dynamic::from(m.as_str().to_string())))
.collect();
Dynamic::from(arr)
}))
},
);
}

View File

@@ -0,0 +1,68 @@
//! `time::` — UTC time. The canonical "time value" is milliseconds
//! since the Unix epoch as `i64`. ISO 8601 strings are for parsing and
//! display only. UTC only — no timezone support in v1.1.0 (would pull
//! in chrono-tz, deferred until a real use case demands it).
use chrono::{DateTime, SecondsFormat, Utc};
use rhai::{Engine as RhaiEngine, EvalAltResult, Module};
pub fn register(engine: &mut RhaiEngine) {
let mut module = Module::new();
register_now(&mut module);
register_now_ms(&mut module);
register_parse(&mut module);
register_format(&mut module);
register_add_seconds(&mut module);
register_diff_seconds(&mut module);
engine.register_static_module("time", module.into());
}
fn register_now(module: &mut Module) {
module.set_native_fn("now", || -> Result<String, Box<EvalAltResult>> {
Ok(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true))
});
}
fn register_now_ms(module: &mut Module) {
module.set_native_fn("now_ms", || -> Result<i64, Box<EvalAltResult>> {
Ok(Utc::now().timestamp_millis())
});
}
fn register_parse(module: &mut Module) {
module.set_native_fn("parse", |iso: &str| -> Result<i64, Box<EvalAltResult>> {
DateTime::parse_from_rfc3339(iso)
.map(|dt| dt.timestamp_millis())
.map_err(|e| format!("time::parse: invalid ISO 8601 / RFC 3339: {e}").into())
});
}
fn register_format(module: &mut Module) {
module.set_native_fn("format", |ms: i64| -> Result<String, Box<EvalAltResult>> {
DateTime::<Utc>::from_timestamp_millis(ms)
.map(|dt| dt.to_rfc3339_opts(SecondsFormat::Millis, true))
.ok_or_else(|| format!("time::format: ms ({ms}) out of representable range").into())
});
}
fn register_add_seconds(module: &mut Module) {
module.set_native_fn(
"add_seconds",
|ms: i64, secs: i64| -> Result<i64, Box<EvalAltResult>> {
secs.checked_mul(1000)
.and_then(|delta| ms.checked_add(delta))
.ok_or_else(|| format!("time::add_seconds: overflow (ms={ms}, secs={secs})").into())
},
);
}
fn register_diff_seconds(module: &mut Module) {
module.set_native_fn(
"diff_seconds",
|a_ms: i64, b_ms: i64| -> Result<i64, Box<EvalAltResult>> {
b_ms.checked_sub(a_ms)
.map(|d| d / 1000)
.ok_or_else(|| format!("time::diff_seconds: overflow (a={a_ms}, b={b_ms})").into())
},
);
}

View File

@@ -0,0 +1,64 @@
//! `url::` — RFC 3986 percent-encoding.
//!
//! `encode`/`decode` operate on opaque component values; `encode_query`
//! builds an `application/x-www-form-urlencoded`-style query string
//! from a Rhai `Map`. Key ordering is the map's natural order (Rhai's
//! `Map` is a `BTreeMap`, so keys come out alphabetically — fine for
//! query strings, which RFC 3986 leaves unordered).
use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
use rhai::{Engine as RhaiEngine, EvalAltResult, Map, Module};
/// RFC 3986 unreserved set: `A-Z / a-z / 0-9 / - / _ / . / ~`.
/// Everything outside this set gets percent-encoded.
const UNRESERVED: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'_')
.remove(b'.')
.remove(b'~');
pub fn register(engine: &mut RhaiEngine) {
let mut module = Module::new();
register_encode(&mut module);
register_decode(&mut module);
register_encode_query(&mut module);
engine.register_static_module("url", module.into());
}
fn register_encode(module: &mut Module) {
module.set_native_fn("encode", |s: &str| -> Result<String, Box<EvalAltResult>> {
Ok(utf8_percent_encode(s, UNRESERVED).to_string())
});
}
fn register_decode(module: &mut Module) {
module.set_native_fn("decode", |s: &str| -> Result<String, Box<EvalAltResult>> {
percent_decode_str(s)
.decode_utf8()
.map(std::borrow::Cow::into_owned)
.map_err(|e| format!("url::decode: invalid UTF-8: {e}").into())
});
}
fn register_encode_query(module: &mut Module) {
module.set_native_fn(
"encode_query",
|m: Map| -> Result<String, Box<EvalAltResult>> {
let mut out = String::new();
for (k, v) in m {
if !out.is_empty() {
out.push('&');
}
out.push_str(&utf8_percent_encode(&k, UNRESERVED).to_string());
out.push('=');
// Coerce values via `to_string` rather than throwing on
// non-strings — scripts commonly pass numbers/bools here
// and a forced cast at the call site is friction with
// no upside.
let value = v.to_string();
out.push_str(&utf8_percent_encode(&value, UNRESERVED).to_string());
}
Ok(out)
},
);
}

View File

@@ -1,7 +1,7 @@
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use picloud_shared::{ExecutionId, RequestId, ScriptId, ScriptSandbox};
use picloud_shared::{AppId, ExecutionId, Principal, RequestId, ScriptId, ScriptSandbox};
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -50,6 +50,35 @@ pub struct ExecRequest {
/// override) before the Rhai engine is built.
#[serde(default)]
pub sandbox_overrides: ScriptSandbox,
/// Owning application. Source of truth for every `(app_id, …)`
/// storage lookup the script makes via stateful SDK services.
/// Internal-only; not surfaced via `ctx` (which the script sees).
pub app_id: AppId,
/// Caller identity, when authenticated. `None` for unauthenticated
/// data-plane HTTP requests (the common case for public scripts);
/// `Some` when a bearer token or session cookie was resolved.
/// Internal-only — exposed via `SdkCallCx` to service trait impls.
///
/// `#[serde(skip)]`: `ExecRequest` is serializable so cluster mode
/// (v1.3+) can ship invocations to remote executors over HTTP, but
/// `Principal` has no wire derivation today. Skipping here keeps
/// v1.1.0 compiling; the cluster-mode PR will introduce a wire-safe
/// snapshot then.
#[serde(skip)]
pub principal: Option<Principal>,
/// Triggers-framework depth. `0` for direct invocations. The
/// dispatcher (v1.1.1) increments on each indirection to bound
/// runaway feedback loops.
#[serde(default)]
pub trigger_depth: u32,
/// Originating execution id of a trigger chain. Equal to
/// `execution_id` for direct invocations; preserves the root
/// across fan-out for audit log grouping.
pub root_execution_id: ExecutionId,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -100,4 +129,11 @@ pub enum ExecError {
#[error("script runtime error: {0}")]
Runtime(String),
/// Concurrency gate (orchestrator-core::ExecutionGate) refused
/// admission. Surfaced as HTTP 503 with a `Retry-After` header.
/// The gate enforces a global cap so a script storm can't park
/// every blocking thread.
#[error("execution declined: server at capacity (retry after {retry_after_secs}s)")]
Overloaded { retry_after_secs: u32 },
}

View File

@@ -1,12 +1,13 @@
use std::collections::BTreeMap;
use picloud_executor_core::{Engine, ExecError, ExecRequest, InvocationType, Limits, LogLevel};
use picloud_shared::{ExecutionId, RequestId, ScriptId, ScriptSandbox};
use picloud_shared::{AppId, ExecutionId, RequestId, ScriptId, ScriptSandbox, Services};
use serde_json::json;
fn req(body: serde_json::Value) -> ExecRequest {
let execution_id = ExecutionId::new();
ExecRequest {
execution_id: ExecutionId::new(),
execution_id,
request_id: RequestId::new(),
script_id: ScriptId::new(),
script_name: "test".into(),
@@ -18,11 +19,15 @@ fn req(body: serde_json::Value) -> ExecRequest {
query: BTreeMap::new(),
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id: AppId::new(),
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,
}
}
fn engine() -> Engine {
Engine::new(Limits::default())
Engine::new(Limits::default(), Services::new())
}
#[test]
@@ -121,7 +126,7 @@ fn enforces_operation_budget() {
max_operations: 1_000,
..Limits::default()
};
let engine = Engine::new(limits);
let engine = Engine::new(limits, Services::new());
// 10_000 iterations vastly exceeds 1_000 ops.
let src = r"let n = 0; for i in 0..10000 { n += 1; } n";
let err = engine

View File

@@ -23,7 +23,7 @@
use std::collections::BTreeMap;
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits, LogLevel};
use picloud_shared::{ExecutionId, RequestId, ScriptId, ScriptSandbox};
use picloud_shared::{AppId, ExecutionId, RequestId, ScriptId, ScriptSandbox, Services};
use serde_json::{json, Value};
// ----------------------------------------------------------------------------
@@ -31,12 +31,13 @@ use serde_json::{json, Value};
// ----------------------------------------------------------------------------
fn engine() -> Engine {
Engine::new(Limits::default())
Engine::new(Limits::default(), Services::new())
}
fn baseline_request() -> ExecRequest {
let execution_id = ExecutionId::new();
ExecRequest {
execution_id: ExecutionId::new(),
execution_id,
request_id: RequestId::new(),
script_id: ScriptId::new(),
script_name: "contract".into(),
@@ -48,6 +49,10 @@ fn baseline_request() -> ExecRequest {
query: BTreeMap::new(),
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id: AppId::new(),
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,
}
}

View File

@@ -0,0 +1,382 @@
//! Integration tests for the v1.1.0 stdlib utility modules.
//!
//! These exist alongside `sdk_contract.rs` rather than inside it
//! because the stateless utilities aren't part of the same versioned
//! SDK contract surface — `sdk_contract.rs` covers things that bump
//! `SDK_VERSION` when they change; stdlib additions don't.
use std::collections::BTreeMap;
use picloud_executor_core::{Engine, ExecError, ExecRequest, InvocationType, Limits};
use picloud_shared::{AppId, ExecutionId, RequestId, ScriptId, ScriptSandbox, Services};
use serde_json::{json, Value};
// ----------------------------------------------------------------------------
// Test harness — duplicated from sdk_contract.rs (each integration test
// crate has its own; there is no tests/common/).
// ----------------------------------------------------------------------------
fn engine() -> Engine {
Engine::new(Limits::default(), Services::new())
}
fn baseline_request() -> ExecRequest {
let execution_id = ExecutionId::new();
ExecRequest {
execution_id,
request_id: RequestId::new(),
script_id: ScriptId::new(),
script_name: "stdlib".into(),
invocation_type: InvocationType::Http,
path: "/stdlib-test".into(),
headers: BTreeMap::new(),
body: Value::Null,
params: BTreeMap::new(),
query: BTreeMap::new(),
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id: AppId::new(),
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,
}
}
fn run(source: &str) -> Value {
engine()
.execute(source, baseline_request())
.expect("stdlib test should execute cleanly")
.body
}
fn run_err(source: &str) -> ExecError {
engine()
.execute(source, baseline_request())
.expect_err("stdlib test expected to throw")
}
fn assert_runtime_err(err: ExecError, needle: &str) {
match err {
ExecError::Runtime(msg) => assert!(
msg.contains(needle),
"runtime error did not contain `{needle}`: {msg}"
),
other => panic!("expected Runtime error containing `{needle}`, got {other:?}"),
}
}
// ============================================================================
// regex
// ============================================================================
#[test]
fn regex_is_match_true_and_false() {
assert_eq!(run(r#"regex::is_match("^h", "hello")"#), json!(true));
assert_eq!(run(r#"regex::is_match("^x", "hello")"#), json!(false));
}
#[test]
fn regex_find_returns_first_match() {
assert_eq!(run(r#"regex::find("\\d+", "abc 42 def 99")"#), json!("42"));
}
#[test]
fn regex_find_returns_unit_when_no_match() {
// () serializes to JSON null via dynamic_to_json.
assert_eq!(run(r#"regex::find("\\d+", "abc")"#), Value::Null);
}
#[test]
fn regex_find_all_returns_array() {
assert_eq!(
run(r#"regex::find_all("\\d+", "a1 b22 c333")"#),
json!(["1", "22", "333"])
);
}
#[test]
fn regex_replace_first_only() {
assert_eq!(
run(r#"regex::replace("a", "banana", "X")"#),
json!("bXnana")
);
}
#[test]
fn regex_replace_all() {
assert_eq!(
run(r#"regex::replace_all("a", "banana", "X")"#),
json!("bXnXnX")
);
}
#[test]
fn regex_split() {
assert_eq!(
run(r#"regex::split(",\\s*", "a, b,c, d")"#),
json!(["a", "b", "c", "d"])
);
}
#[test]
fn regex_captures_extracts_groups() {
assert_eq!(
run(r#"regex::captures("(\\d+)-(\\w+)", "42-abc")"#),
json!(["42-abc", "42", "abc"])
);
}
#[test]
fn regex_captures_returns_unit_when_no_match() {
assert_eq!(run(r#"regex::captures("(\\d+)", "abc")"#), Value::Null);
}
#[test]
fn regex_invalid_pattern_throws() {
assert_runtime_err(run_err(r#"regex::is_match("(", "x")"#), "invalid regex");
}
// ============================================================================
// random
// ============================================================================
#[test]
fn random_int_within_range() {
// Run a few times to exercise the bounds — each call is independent.
let body = run(r"
let n = random::int(10, 20);
n >= 10 && n <= 20
");
assert_eq!(body, json!(true));
}
#[test]
fn random_int_throws_when_min_greater_than_max() {
assert_runtime_err(run_err("random::int(20, 10)"), "min");
}
#[test]
fn random_float_in_unit_interval() {
let body = run(r"
let f = random::float();
f >= 0.0 && f < 1.0
");
assert_eq!(body, json!(true));
}
#[test]
fn random_bytes_returns_blob_of_correct_length() {
assert_eq!(run("random::bytes(16).len()"), json!(16));
}
#[test]
fn random_bytes_rejects_negative() {
assert_runtime_err(run_err("random::bytes(-1)"), "random::bytes");
}
#[test]
fn random_bytes_rejects_oversize() {
assert_runtime_err(run_err("random::bytes(70000)"), "random::bytes");
}
#[test]
fn random_string_produces_alphanumeric_of_correct_length() {
let body = run(r#"
let s = random::string(32);
s.len == 32 && regex::is_match("^[A-Za-z0-9]+$", s)
"#);
assert_eq!(body, json!(true));
}
#[test]
fn random_uuid_has_canonical_format() {
let body = run(
r#"regex::is_match("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", random::uuid())"#,
);
assert_eq!(body, json!(true));
}
// ============================================================================
// time
// ============================================================================
#[test]
fn time_now_ms_is_positive() {
let body = run("time::now_ms() > 0");
assert_eq!(body, json!(true));
}
#[test]
fn time_now_string_looks_like_iso() {
let body = run(r#"regex::is_match("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}", time::now())"#);
assert_eq!(body, json!(true));
}
#[test]
fn time_parse_format_round_trip() {
let body = run(r"
let ms = 1700000000000;
time::parse(time::format(ms)) == ms
");
assert_eq!(body, json!(true));
}
#[test]
fn time_add_seconds() {
assert_eq!(run("time::add_seconds(0, 60)"), json!(60_000));
assert_eq!(run("time::add_seconds(1000, -1)"), json!(0));
}
#[test]
fn time_diff_seconds_truncates() {
assert_eq!(run("time::diff_seconds(0, 65_500)"), json!(65));
}
#[test]
fn time_parse_rejects_garbage() {
assert_runtime_err(run_err(r#"time::parse("nonsense")"#), "time::parse");
}
// ============================================================================
// json
// ============================================================================
#[test]
fn json_parse_then_stringify_round_trip() {
let body = run(r#"
let src = `{"a":1,"b":"x"}`;
json::stringify(json::parse(src)) == src
"#);
assert_eq!(body, json!(true));
}
#[test]
fn json_stringify_compact() {
assert_eq!(run(r"json::stringify(#{ a: 1 })"), json!(r#"{"a":1}"#));
}
#[test]
fn json_stringify_pretty_has_newlines() {
let body = run(r#"json::stringify_pretty(#{ a: 1 }).contains("\n")"#);
assert_eq!(body, json!(true));
}
#[test]
fn json_parse_invalid_throws() {
assert_runtime_err(run_err(r#"json::parse("not json")"#), "json::parse");
}
// ============================================================================
// base64
// ============================================================================
#[test]
fn base64_encode_string() {
assert_eq!(run(r#"base64::encode("hi")"#), json!("aGk="));
}
#[test]
fn base64_decode_then_re_encode_round_trip() {
assert_eq!(
run(r#"base64::encode(base64::decode("aGVsbG8="))"#),
json!("aGVsbG8=")
);
}
#[test]
fn base64_encode_url_has_no_padding() {
let body = run(r#"
let s = base64::encode_url("hello world!?");
!s.contains("=") && !s.contains("+") && !s.contains("/")
"#);
assert_eq!(body, json!(true));
}
#[test]
fn base64_decode_url_round_trip() {
assert_eq!(
run(r#"base64::encode_url(base64::decode_url("aGVsbG8"))"#),
json!("aGVsbG8")
);
}
#[test]
fn base64_decode_invalid_throws() {
assert_runtime_err(run_err(r#"base64::decode("!!!")"#), "base64::decode");
}
// ============================================================================
// hex
// ============================================================================
#[test]
fn hex_encode_produces_lowercase() {
assert_eq!(run(r#"hex::encode("Z")"#), json!("5a"));
}
#[test]
fn hex_decode_then_re_encode_round_trip() {
// mixed-case input → lowercase output proves both case-insensitive
// decode and lowercase encode.
assert_eq!(
run(r#"hex::encode(hex::decode("DeAdBeEf"))"#),
json!("deadbeef")
);
}
#[test]
fn hex_decode_returns_correct_length() {
assert_eq!(run(r#"hex::decode("deadbeef").len()"#), json!(4));
}
#[test]
fn hex_decode_invalid_throws() {
assert_runtime_err(run_err(r#"hex::decode("xyz")"#), "hex::decode");
}
// ============================================================================
// url
// ============================================================================
#[test]
fn url_encode_basic() {
assert_eq!(run(r#"url::encode("hello world")"#), json!("hello%20world"));
}
#[test]
fn url_encode_preserves_unreserved() {
assert_eq!(
run(r#"url::encode("abcXYZ123-_.~")"#),
json!("abcXYZ123-_.~")
);
}
#[test]
fn url_decode_round_trip() {
assert_eq!(
run(r#"url::decode(url::encode("hello world!?"))"#),
json!("hello world!?")
);
}
#[test]
fn url_encode_query_basic() {
// Map keys come out alphabetically (Rhai's Map is a BTreeMap).
assert_eq!(
run(r#"url::encode_query(#{ a: "1", b: "x y" })"#),
json!("a=1&b=x%20y")
);
}
#[test]
fn url_encode_query_coerces_non_strings() {
// Numbers and bools shouldn't throw; they coerce via to_string().
let body = run(r"url::encode_query(#{ n: 42, b: true })");
// Order is alphabetical: b before n.
assert_eq!(body, json!("b=true&n=42"));
}
#[test]
fn url_decode_rejects_invalid_utf8() {
assert_runtime_err(run_err(r#"url::decode("%FF%FE%80")"#), "url::decode");
}

View File

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

View File

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

View File

@@ -100,6 +100,35 @@ pub async fn require_admin(state: State<AuthState>, req: Request<Body>, next: Ne
require_authenticated(state, req, next).await
}
/// Opportunistic data-plane variant: always inserts an
/// `Extension<Option<Principal>>` and forwards the request. Used on
/// `/execute/{id}` and the user-route fallback, where most invocations
/// are anonymous public HTTP and the few authed ones (dashboard
/// test-runs, API keys) should still let scripts see the caller via
/// `cx.principal` once services consume it.
///
/// Failure modes — all degrade to `None` rather than rejecting:
/// * No bearer / cookie → `None`.
/// * Malformed or unknown token → `None`.
/// * DB blip while resolving → `None` (fail-open; the data plane
/// should not 500 on transient infra failures for an *optional*
/// identity check).
///
/// Admin-side routes that REQUIRE an identity keep using
/// `require_authenticated`.
pub async fn attach_principal_if_present(
State(state): State<AuthState>,
mut req: Request<Body>,
next: Next,
) -> Response {
let principal: Option<Principal> = match extract_token(&req) {
Some(token) => resolve_principal(&state, &token).await.unwrap_or(None),
None => None,
};
req.extensions_mut().insert(principal);
next.run(req).await
}
/// Decide whether the token is an API key (pic_ prefix) or a session
/// token, then resolve the corresponding `Principal`. `Ok(None)`
/// means the token was structurally valid but didn't match any active

View File

@@ -199,21 +199,14 @@ async fn role_grants(
}
}
/// Admin is implicit `editor` on every app (per blueprint §11.6). They
/// can create apps and manage users, but NOT touch instance-wide
/// settings or take app-admin-only actions on apps they're not
/// explicitly app_admin of. Everything not in this set falls through
/// to deny (`InstanceManageSettings`, `AppManageDomains`, `AppAdmin`).
/// Admin is implicit `app_admin` on every app (per blueprint §11.6).
/// They can create apps, manage users, and take any app-scoped action
/// on any app without an explicit `app_members` row — single-human
/// installs would otherwise need to add themselves to every new app.
/// Only `InstanceManageSettings` (sandbox ceiling, etc.) stays
/// owner-only.
const fn admin_grants(cap: Capability) -> bool {
matches!(
cap,
Capability::InstanceCreateApp
| Capability::InstanceManageUsers
| Capability::AppRead(_)
| Capability::AppWriteScript(_)
| Capability::AppWriteRoute(_)
| Capability::AppLogRead(_)
)
!matches!(cap, Capability::InstanceManageSettings)
}
/// Member has zero instance authority. App authority requires an
@@ -357,10 +350,23 @@ mod tests {
}
#[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 p = principal(InstanceRole::Admin);
let app = AppId::new();
// Instance-scoped allowances.
assert_eq!(
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
Decision::Allow,
@@ -371,36 +377,22 @@ mod tests {
.unwrap(),
Decision::Allow,
);
// Editor-like + app-admin grants both succeed without any
// app_members row.
for cap in [
Capability::AppRead(app),
Capability::AppWriteScript(app),
Capability::AppWriteRoute(app),
Capability::AppLogRead(app),
Capability::AppManageDomains(app),
Capability::AppAdmin(app),
] {
assert_eq!(
can(&repo, &p, Capability::InstanceManageSettings)
.await
.unwrap(),
Decision::Deny,
);
// Editor-like grants succeed
assert_eq!(
can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap(),
can(&repo, &p, cap).await.unwrap(),
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]
@@ -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]
async fn member_with_app_admin_role_can_do_app_admin_actions() {
let repo = InMemoryAuthzRepo::default();

View File

@@ -59,8 +59,8 @@ pub use auth_bootstrap::{
};
#[allow(deprecated)]
pub use auth_middleware::{
require_admin, require_authenticated, AuthState, AuthedAdmin, API_KEY_PREFIX,
API_KEY_PREFIX_LEN, SESSION_COOKIE,
attach_principal_if_present, require_admin, require_authenticated, AuthState, AuthedAdmin,
API_KEY_PREFIX, API_KEY_PREFIX_LEN, SESSION_COOKIE,
};
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
pub use log_sink::PostgresExecutionLogSink;

View File

@@ -12,12 +12,13 @@ use axum::{
http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
response::{IntoResponse, Response},
routing::post,
Json, Router,
Extension, Json, Router,
};
use chrono::Utc;
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
use picloud_shared::{
AppId, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
AppId, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, Principal, RequestId,
ScriptId,
};
use serde_json::Value as Json_;
use uuid::Uuid;
@@ -54,6 +55,11 @@ impl<E, R> Clone for DataPlaneState<E, R> {
/// Build the data-plane router. Handles `POST /execute/:id` — the
/// always-available ID-based bypass.
///
/// Handlers expect an `Extension<Option<Principal>>` to be attached by
/// upstream middleware (`manager-core::attach_principal_if_present`);
/// requests without that extension panic at extraction time. The
/// picloud binary wires this in `build_app`.
pub fn data_plane_router<E, R>(state: DataPlaneState<E, R>) -> Router
where
E: ExecutorClient + 'static,
@@ -67,6 +73,10 @@ where
/// Build a router that handles ALL paths via the user-defined routing
/// table. Intended to be merged into the picloud app router as a
/// fallback (after the system routes are mounted).
///
/// Same middleware expectation as `data_plane_router` — wrap with
/// `attach_principal_if_present` so handlers can extract
/// `Extension<Option<Principal>>`.
pub fn user_routes_router<E, R>(state: DataPlaneState<E, R>) -> Router
where
E: ExecutorClient + 'static,
@@ -84,6 +94,7 @@ where
async fn execute_by_id<E, R>(
State(state): State<DataPlaneState<E, R>>,
Path(id): Path<ScriptId>,
Extension(principal): Extension<Option<Principal>>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, ApiError>
@@ -97,7 +108,7 @@ where
.await?
.ok_or(ApiError::NotFound(id))?;
let mut req = build_exec_request(id, &script.name, &headers, &body)?;
let mut req = build_exec_request(id, &script.name, &headers, &body, script.app_id, principal)?;
req.sandbox_overrides = script.sandbox;
let request_id = req.request_id;
let request_path = req.path.clone();
@@ -133,6 +144,7 @@ where
async fn user_route_handler<E, R>(
State(state): State<DataPlaneState<E, R>>,
Extension(principal): Extension<Option<Principal>>,
request: Request,
) -> Result<Response, ApiError>
where
@@ -195,6 +207,8 @@ where
&script.name,
&headers,
&body_bytes,
app_id,
principal,
)?;
req.path = path;
req.params = matched.params;
@@ -264,6 +278,8 @@ fn build_exec_request(
name: &str,
headers: &HeaderMap,
body: &Bytes,
app_id: AppId,
principal: Option<Principal>,
) -> Result<ExecRequest, ApiError> {
let mut hmap = BTreeMap::new();
for (k, v) in headers {
@@ -279,8 +295,9 @@ fn build_exec_request(
.map_err(|e| ApiError::BadRequest(format!("invalid JSON body: {e}")))?
};
let execution_id = ExecutionId::new();
Ok(ExecRequest {
execution_id: ExecutionId::new(),
execution_id,
request_id: RequestId::new(),
script_id: id,
script_name: name.to_string(),
@@ -293,6 +310,13 @@ fn build_exec_request(
rest: String::new(),
// Overwritten by the handler after the script is resolved.
sandbox_overrides: picloud_shared::ScriptSandbox::default(),
app_id,
principal,
// Direct invocations are at depth 0 with a self-referential
// root. The triggers framework (v1.1.1) increments depth and
// preserves the original root for chained executions.
trigger_depth: 0,
root_execution_id: execution_id,
})
}
@@ -396,7 +420,22 @@ pub enum ApiError {
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
// Overloaded is the only variant that needs to attach an HTTP
// header (Retry-After), so it short-circuits the (status, body)
// reduction below. Axum's tuple builder makes per-arm header
// injection awkward otherwise.
use ApiError as E;
if let E::Exec(ExecError::Overloaded { retry_after_secs }) = &self {
let retry = retry_after_secs.to_string();
let body = Json(serde_json::json!({ "error": self.to_string() }));
return (
StatusCode::SERVICE_UNAVAILABLE,
[(axum::http::header::RETRY_AFTER, retry)],
body,
)
.into_response();
}
let (status, message) = match &self {
E::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
E::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
@@ -416,6 +455,7 @@ impl IntoResponse for ApiError {
(StatusCode::INSUFFICIENT_STORAGE, e.to_string())
}
ExecError::Runtime(_) => (StatusCode::BAD_GATEWAY, e.to_string()),
ExecError::Overloaded { .. } => unreachable!("handled above"),
},
};
(status, Json(serde_json::json!({ "error": message }))).into_response()

View File

@@ -4,6 +4,8 @@ use std::time::Duration;
use async_trait::async_trait;
use picloud_executor_core::{Engine, ExecError, ExecRequest, ExecResponse};
use crate::gate::{AcquireError, ExecutionGate};
/// Maximum wall-clock time we'll wait for a single invocation, regardless
/// of the per-script `timeout_seconds`. Provides a hard ceiling on
/// resource usage independent of misconfigured scripts.
@@ -30,14 +32,19 @@ pub trait ExecutorClient: Send + Sync {
/// `executor-core::Engine::execute` is synchronous; we offload it to a
/// blocking thread so it doesn't park a Tokio worker, and apply the
/// wall-clock timeout here.
///
/// Holds an `ExecutionGate` and acquires a permit before `spawn_blocking`
/// so a script storm can't drain the blocking-thread pool. The permit
/// drops with the future, returning the slot.
pub struct LocalExecutorClient {
engine: Arc<Engine>,
gate: Arc<ExecutionGate>,
}
impl LocalExecutorClient {
#[must_use]
pub fn new(engine: Arc<Engine>) -> Self {
Self { engine }
pub fn new(engine: Arc<Engine>, gate: Arc<ExecutionGate>) -> Self {
Self { engine, gate }
}
}
@@ -49,6 +56,24 @@ impl ExecutorClient for LocalExecutorClient {
req: ExecRequest,
timeout: Duration,
) -> Result<ExecResponse, ExecError> {
// Acquire before spending any wall-clock budget. The permit is
// held by this future; on `tokio::time::timeout` firing, the
// future drops and the permit returns to the pool — but the
// detached `spawn_blocking` thread keeps running until the
// Rhai script finishes (or panics). So in-use blocking threads
// can briefly exceed the gate's permit count after a timeout.
// That is intentional: a new admission can be served while the
// already-doomed script winds down, which is preferable to
// wedging the slot for the worst-case timeout duration.
let _permit =
self.gate
.try_acquire()
.map_err(
|AcquireError::Overloaded { retry_after_secs }| ExecError::Overloaded {
retry_after_secs,
},
)?;
let timeout = timeout.min(HARD_TIMEOUT_CAP);
let timeout_secs = u32::try_from(timeout.as_secs()).unwrap_or(u32::MAX);

View File

@@ -0,0 +1,155 @@
//! Global concurrency gate for the data plane.
//!
//! Wraps a single `tokio::sync::Semaphore` so the executor can refuse
//! admission immediately when too many invocations are already in
//! flight. Designed for v1.1.0's single-node MVP — one cap across all
//! apps and scripts. Per-app or per-script caps come later when a real
//! workload surfaces the need.
//!
//! Policy: **non-blocking, no queue**. If a permit isn't free right
//! now, the call returns `AcquireError::Overloaded` and the data-plane
//! HTTP layer translates that to a 503 with `Retry-After: 1`. Pushing
//! back hard beats letting requests pile up against a finite pool of
//! blocking threads (executor work runs under `spawn_blocking`).
//!
//! Configured via the `PICLOUD_MAX_CONCURRENT_EXECUTIONS` env var.
//! Default is 32 — comfortable for a single-node Pi, low enough that
//! a script storm doesn't park every blocking thread.
use std::sync::Arc;
use thiserror::Error;
use tokio::sync::{OwnedSemaphorePermit, Semaphore, TryAcquireError};
/// Env var consulted by `from_env`.
pub const ENV_MAX_CONCURRENT: &str = "PICLOUD_MAX_CONCURRENT_EXECUTIONS";
/// Default cap when the env var is unset or invalid.
pub const DEFAULT_MAX_CONCURRENT: u32 = 32;
/// `Retry-After` header value (seconds) returned alongside the 503
/// when the gate refuses. Fixed for v1.1.0; later versions may compute
/// a smarter value from in-flight latency.
pub const DEFAULT_RETRY_AFTER_SECS: u32 = 1;
/// Refused admission. The HTTP layer translates this to 503 with a
/// `Retry-After` header.
#[derive(Debug, Error)]
pub enum AcquireError {
#[error("at capacity (retry after {retry_after_secs}s)")]
Overloaded { retry_after_secs: u32 },
}
/// Global execution gate. Constructed once at orchestrator startup and
/// shared via `Arc`. Holds an inner `Arc<Semaphore>` so permits are
/// owned (they release on drop independent of the gate's lifetime).
pub struct ExecutionGate {
permits: Arc<Semaphore>,
max_permits: u32,
}
impl ExecutionGate {
/// Construct with an explicit cap. Mostly for tests; production
/// uses `from_env`.
#[must_use]
pub fn new(max_permits: u32) -> Self {
Self {
permits: Arc::new(Semaphore::new(max_permits as usize)),
max_permits,
}
}
/// Read `PICLOUD_MAX_CONCURRENT_EXECUTIONS` from the environment.
/// Falls back to `DEFAULT_MAX_CONCURRENT` on absence; warns and
/// falls back on parse failure or non-positive value. Mirrors the
/// `SandboxCeiling::from_env` ergonomics so operators see a
/// consistent shape across the env-tunables.
#[must_use]
pub fn from_env() -> Self {
let max = match std::env::var(ENV_MAX_CONCURRENT) {
Err(_) => DEFAULT_MAX_CONCURRENT,
Ok(v) => match v.parse::<u32>() {
Ok(n) if n > 0 => n,
Ok(_) => {
tracing::warn!(
env = ENV_MAX_CONCURRENT,
value = %v,
"value must be > 0; using default {DEFAULT_MAX_CONCURRENT}"
);
DEFAULT_MAX_CONCURRENT
}
Err(e) => {
tracing::warn!(
env = ENV_MAX_CONCURRENT,
value = %v,
error = %e,
"invalid value; using default {DEFAULT_MAX_CONCURRENT}"
);
DEFAULT_MAX_CONCURRENT
}
},
};
Self::new(max)
}
/// Maximum concurrent permits this gate was configured for. Useful
/// for diagnostics / future metrics.
#[must_use]
pub fn max_permits(&self) -> u32 {
self.max_permits
}
/// Non-blocking permit acquisition. Returns the owned permit on
/// success (drop releases the slot) or `AcquireError::Overloaded`
/// when saturated. Sync because the semaphore's non-blocking try is
/// sync — no runtime hop needed.
pub fn try_acquire(&self) -> Result<OwnedSemaphorePermit, AcquireError> {
self.permits
.clone()
.try_acquire_owned()
.map_err(|err| match err {
TryAcquireError::NoPermits | TryAcquireError::Closed => AcquireError::Overloaded {
retry_after_secs: DEFAULT_RETRY_AFTER_SECS,
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn acquire_succeeds_under_capacity() {
let gate = ExecutionGate::new(2);
let _p1 = gate.try_acquire().expect("first permit available");
let _p2 = gate.try_acquire().expect("second permit available");
}
#[test]
fn acquire_overloaded_when_saturated() {
let gate = ExecutionGate::new(1);
let _p = gate.try_acquire().expect("first permit available");
let AcquireError::Overloaded { retry_after_secs } = gate
.try_acquire()
.expect_err("second permit must be refused");
assert!(retry_after_secs > 0, "retry-after must be positive");
}
#[test]
fn permit_drop_releases_slot() {
let gate = ExecutionGate::new(1);
{
let _p = gate.try_acquire().expect("first permit available");
}
let _ = gate
.try_acquire()
.expect("slot must be returned after permit drops");
}
#[test]
fn max_permits_exposed() {
let gate = ExecutionGate::new(7);
assert_eq!(gate.max_permits(), 7);
}
}

View File

@@ -10,9 +10,11 @@
pub mod api;
pub mod client;
pub mod gate;
pub mod resolver;
pub mod routing;
pub use api::{data_plane_router, user_routes_router, DataPlaneState};
pub use client::{ExecutorClient, LocalExecutorClient, RemoteExecutorClient};
pub use gate::{AcquireError, ExecutionGate};
pub use resolver::{ResolverError, ScriptResolver};

View File

@@ -0,0 +1,42 @@
[package]
name = "picloud-cli"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "PiCloud command-line client"
# Each top-level `tests/*.rs` would otherwise auto-discover as its own
# test binary, respawning picloud once per file. We want one binary
# with module sub-files (auth.rs, apps.rs, …) so the LazyLock fixture
# is genuinely shared.
autotests = false
[[bin]]
name = "pic"
path = "src/main.rs"
[[test]]
name = "cli"
path = "tests/cli.rs"
[dependencies]
picloud-shared.workspace = true
reqwest = { workspace = true, features = ["json"] }
serde.workspace = true
serde_json.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
chrono = { workspace = true }
clap = { version = "4", features = ["derive"] }
toml = "0.8"
directories = "5"
rpassword = "7"
anyhow = "1"
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"
reqwest = { workspace = true, features = ["json", "blocking"] }
libc = "0.2"

View File

@@ -0,0 +1,501 @@
//! Reqwest-backed HTTP client + minimal wire DTOs.
//!
//! The CLI deliberately re-declares small request/response structs here
//! rather than depending on `manager-core` (and pulling its Postgres
//! transitive surface). Fields kept to what the CLI actually sends or
//! reads.
use std::collections::BTreeMap;
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use picloud_shared::{
AdminUserId, ApiKeyId, App, AppId, AppRole, ExecutionLog, InstanceRole, Scope, Script,
};
use reqwest::{header, Method, RequestBuilder, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::config::Credentials;
pub struct Client {
http: reqwest::Client,
url: String,
token: String,
}
impl Client {
pub fn from_creds(creds: &Credentials) -> Result<Self> {
Self::new(&creds.url, &creds.token)
}
pub fn new(url: &str, token: &str) -> Result<Self> {
let http = reqwest::Client::builder()
.user_agent(concat!("pic/", env!("CARGO_PKG_VERSION")))
.build()
.context("building HTTP client")?;
Ok(Self {
http,
url: url.trim_end_matches('/').to_string(),
token: token.to_string(),
})
}
#[allow(dead_code)] // used by the trailing-slash unit test below.
pub fn url(&self) -> &str {
&self.url
}
fn request(&self, method: Method, path: &str) -> RequestBuilder {
self.http
.request(method, format!("{}{path}", self.url))
.header(header::AUTHORIZATION, format!("Bearer {}", self.token))
}
/// `GET /api/v1/admin/auth/me`
pub async fn auth_me(&self) -> Result<AuthMeDto> {
let resp = self
.request(Method::GET, "/api/v1/admin/auth/me")
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/apps`
pub async fn apps_list(&self) -> Result<Vec<App>> {
let resp = self
.request(Method::GET, "/api/v1/admin/apps")
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id_or_slug}` — slug or UUID accepted.
pub async fn apps_get(&self, ident: &str) -> Result<AppLookupDto> {
let resp = self
.request(Method::GET, &format!("/api/v1/admin/apps/{ident}"))
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/apps`
pub async fn apps_create(&self, body: &CreateAppBody<'_>) -> Result<App> {
let resp = self
.request(Method::POST, "/api/v1/admin/apps")
.json(body)
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/scripts?app={ident}`
pub async fn scripts_list_by_app(&self, ident: &str) -> Result<Vec<Script>> {
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/scripts?app={}", urlencoded(ident)),
)
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/scripts` — every script the caller can see
/// (server filters by membership for `Member`). Lets `pic scripts ls`
/// (no `--app`) collapse what used to be an N+1 per-app walk into a
/// single request that can't be partially-broken by a concurrent app
/// delete.
pub async fn scripts_list_all(&self) -> Result<Vec<Script>> {
let resp = self
.request(Method::GET, "/api/v1/admin/scripts")
.send()
.await?;
decode(resp).await
}
/// `DELETE /api/v1/admin/apps/{id_or_slug}` with optional `?force=true`.
/// Server requires `AppAdmin` capability; without `force`, returns
/// 409 if the app still has scripts.
pub async fn apps_delete(&self, ident: &str, force: bool) -> Result<()> {
let path = if force {
format!("/api/v1/admin/apps/{ident}?force=true")
} else {
format!("/api/v1/admin/apps/{ident}")
};
let resp = self.request(Method::DELETE, &path).send().await?;
decode_status(resp).await
}
/// `DELETE /api/v1/admin/scripts/{id}` — requires `AppAdmin` on the
/// owning app (stricter than the edit endpoints, by design).
pub async fn scripts_delete(&self, id: &str) -> Result<()> {
let resp = self
.request(Method::DELETE, &format!("/api/v1/admin/scripts/{id}"))
.send()
.await?;
decode_status(resp).await
}
/// `POST /api/v1/admin/scripts`
pub async fn scripts_create(&self, body: &CreateScriptBody<'_>) -> Result<Script> {
let resp = self
.request(Method::POST, "/api/v1/admin/scripts")
.json(body)
.send()
.await?;
decode(resp).await
}
/// `PUT /api/v1/admin/scripts/{id}` — matches the dashboard, which
/// uses PUT despite the field-level update semantics.
pub async fn scripts_update_source(&self, id: &str, source: &str) -> Result<Script> {
let body = UpdateScriptBody { source };
let resp = self
.request(Method::PUT, &format!("/api/v1/admin/scripts/{id}"))
.json(&body)
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/execute/{id}` — returns the raw HTTP status, headers,
/// and JSON body (the orchestrator marshals the script's output as
/// the HTTP response itself, not a wrapper object).
pub async fn execute(
&self,
id: &str,
body: Value,
headers: &[(String, String)],
) -> Result<ExecuteResponse> {
let mut req = self
.request(Method::POST, &format!("/api/v1/execute/{id}"))
.json(&body);
for (k, v) in headers {
req = req.header(k, v);
}
let resp = req.send().await?;
let status = resp.status().as_u16();
let mut headers_out: BTreeMap<String, String> = BTreeMap::new();
for (k, v) in resp.headers() {
if let Ok(val) = v.to_str() {
headers_out.insert(k.as_str().to_string(), val.to_string());
}
}
let bytes = resp.bytes().await.context("reading execute response")?;
let body_json: Value = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes)
.unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into_owned()))
};
Ok(ExecuteResponse {
status_code: status,
headers: headers_out,
body: body_json,
})
}
/// `GET /api/v1/admin/scripts/{id}/logs?limit=N`
pub async fn logs_list(&self, script_id: &str, limit: u32) -> Result<Vec<ExecutionLog>> {
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/scripts/{script_id}/logs?limit={limit}"),
)
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/auth/logout` — best-effort: server returns
/// 204 whether or not the token matched a live session, so we just
/// fire and discard the body. Caller still wipes the local creds.
pub async fn auth_logout(&self) -> Result<()> {
let resp = self
.request(Method::POST, "/api/v1/admin/auth/logout")
.send()
.await?;
decode_status(resp).await
}
/// `GET /api/v1/admin/api-keys` — caller's keys only (server filters
/// by user_id, no cross-user enumeration).
pub async fn apikeys_list(&self) -> Result<Vec<ApiKeyDto>> {
let resp = self
.request(Method::GET, "/api/v1/admin/api-keys")
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/api-keys` — `raw_token` is in the response
/// **once** and never appears in `GET /api-keys` afterward.
pub async fn apikeys_mint(&self, body: &MintApiKeyBody<'_>) -> Result<MintApiKeyResponseDto> {
let resp = self
.request(Method::POST, "/api/v1/admin/api-keys")
.json(body)
.send()
.await?;
decode(resp).await
}
/// `DELETE /api/v1/admin/api-keys/{id}` — 404 covers both "doesn't
/// exist" and "not yours" (server flattens to avoid enumeration).
pub async fn apikeys_delete(&self, id: &str) -> Result<()> {
let resp = self
.request(Method::DELETE, &format!("/api/v1/admin/api-keys/{id}"))
.send()
.await?;
decode_status(resp).await
}
}
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
/// it runs before any token exists. Mirrors the dashboard's login.ts
/// wire shape (see `manager-core/src/auth_api.rs:49-60`).
pub async fn auth_login(url: &str, username: &str, password: &str) -> Result<LoginResponseDto> {
let http = reqwest::Client::builder()
.user_agent(concat!("pic/", env!("CARGO_PKG_VERSION")))
.build()
.context("building HTTP client")?;
let body = LoginRequestBody { username, password };
let resp = http
.post(format!(
"{}/api/v1/admin/auth/login",
url.trim_end_matches('/')
))
.json(&body)
.send()
.await?;
decode(resp).await
}
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct AuthMeDto {
// Part of the wire shape (and kept for symmetry with the dashboard's
// MeDto), even though the CLI never displays it.
pub id: String,
pub username: String,
pub instance_role: InstanceRole,
#[serde(default)]
pub email: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct AppLookupDto {
#[serde(flatten)]
pub app: App,
// Not surfaced yet — `pic apps ls` only shows what `apps_list` returns.
// Kept on the DTO so future `pic apps inspect <slug>` work is one-line.
#[serde(default)]
pub my_role: Option<AppRole>,
}
#[derive(Debug, Serialize)]
pub struct CreateAppBody<'a> {
pub slug: &'a str,
pub name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
}
#[derive(Debug, Serialize)]
pub struct CreateScriptBody<'a> {
pub app_id: AppId,
pub name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
pub source: &'a str,
}
#[derive(Debug, Serialize)]
struct UpdateScriptBody<'a> {
source: &'a str,
}
#[derive(Debug, Serialize)]
struct LoginRequestBody<'a> {
username: &'a str,
password: &'a str,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct LoginResponseDto {
pub user: LoginUserDto,
pub token: String,
pub expires_at: DateTime<Utc>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct LoginUserDto {
pub id: AdminUserId,
pub username: String,
pub instance_role: InstanceRole,
#[serde(default)]
pub email: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct MintApiKeyBody<'a> {
pub name: &'a str,
pub scopes: &'a [Scope],
#[serde(skip_serializing_if = "Option::is_none")]
pub app_id: Option<AppId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
}
/// Fresh-mint response. The `raw_token` field is the one and only
/// chance to capture the bearer string; subsequent `GET /api-keys`
/// returns the `ApiKeyDto` portion without it.
#[derive(Debug, Deserialize)]
pub struct MintApiKeyResponseDto {
#[serde(flatten)]
pub key: ApiKeyDto,
pub raw_token: String,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct ApiKeyDto {
pub id: ApiKeyId,
pub prefix: String,
pub name: String,
pub scopes: Vec<Scope>,
pub app_id: Option<AppId>,
pub expires_at: Option<DateTime<Utc>>,
pub last_used_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
#[allow(dead_code)]
#[derive(Debug)]
pub struct ExecuteResponse {
pub status_code: u16,
// Captured for completeness; not displayed today, but `pic invoke -v`
// could surface them later without changing this struct.
pub headers: BTreeMap<String, String>,
pub body: Value,
}
// ---------- helpers ----------
/// Parse `-H "Key: value"` or `-H "Key=value"` into a `(name, value)`
/// pair. Trims surrounding whitespace on both sides.
pub fn parse_kv_header(raw: &str) -> Result<(String, String), String> {
let (k, v) = raw
.split_once(':')
.or_else(|| raw.split_once('='))
.ok_or_else(|| format!("expected `Key: value` or `Key=value`, got {raw:?}"))?;
let k = k.trim();
let v = v.trim();
if k.is_empty() {
return Err(format!("empty header name in {raw:?}"));
}
Ok((k.to_string(), v.to_string()))
}
fn urlencoded(s: &str) -> String {
// Minimal pass: percent-encode the few chars that break the query.
// Slugs and UUIDs don't contain them in practice, but be safe.
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' | '=' | '?' | '#' | ' ' => {
out.push_str(&format!("%{:02X}", u32::from(ch)));
}
_ => out.push(ch),
}
}
out
}
async fn decode<T: for<'de> Deserialize<'de>>(resp: reqwest::Response) -> Result<T> {
if resp.status().is_success() {
return resp.json::<T>().await.context("parsing response body");
}
Err(server_error(resp).await)
}
/// Like `decode` but for endpoints whose 2xx response has no body
/// (204 No Content) — DELETE handlers, logout.
async fn decode_status(resp: reqwest::Response) -> Result<()> {
if resp.status().is_success() {
return Ok(());
}
Err(server_error(resp).await)
}
async fn server_error(resp: reqwest::Response) -> anyhow::Error {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let msg = parse_error_body(&body).unwrap_or(body);
let hint = role_hint(status);
if hint.is_empty() {
anyhow!("HTTP {}: {}", status.as_u16(), msg)
} else {
anyhow!("HTTP {}: {} ({})", status.as_u16(), msg, hint)
}
}
fn parse_error_body(s: &str) -> Option<String> {
let v: Value = serde_json::from_str(s).ok()?;
let obj = v.as_object()?;
if let Some(m) = obj.get("message").and_then(Value::as_str) {
return Some(m.to_string());
}
if let Some(e) = obj.get("error").and_then(Value::as_str) {
return Some(e.to_string());
}
None
}
fn role_hint(status: StatusCode) -> &'static str {
match status {
StatusCode::FORBIDDEN => "your role may lack the required capability; check `pic whoami`",
StatusCode::UNAUTHORIZED => "token rejected; re-run `pic login`",
_ => "",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_kv_colon() {
let (k, v) = parse_kv_header("X-Foo: bar").unwrap();
assert_eq!(k, "X-Foo");
assert_eq!(v, "bar");
}
#[test]
fn parse_kv_equals() {
let (k, v) = parse_kv_header("X-Foo=bar").unwrap();
assert_eq!(k, "X-Foo");
assert_eq!(v, "bar");
}
#[test]
fn parse_kv_rejects_no_separator() {
assert!(parse_kv_header("X-Foo").is_err());
}
#[test]
fn parse_kv_rejects_empty_name() {
assert!(parse_kv_header(": bar").is_err());
}
#[test]
fn url_strip_trailing_slash() {
let c = Client::new("http://localhost:8000/", "pic_x").unwrap();
assert_eq!(c.url(), "http://localhost:8000");
}
}

View File

@@ -0,0 +1,201 @@
//! `pic api-keys` — long-lived bearer-key management.
//!
//! Server semantics (mirrored from `manager-core/src/api_keys_api.rs`):
//! * `raw_token` is returned **once** on mint and never again.
//! * `app_id` (optional `--app`) binds the key to one app; instance
//! scopes (`instance:*`) are rejected when `--app` is also set.
//! * `scopes` is a `text[]` in the wire form (`script:read`, …).
use anyhow::{anyhow, Result};
use chrono::{DateTime, Utc};
use picloud_shared::Scope;
use crate::client::{Client, MintApiKeyBody};
use crate::config;
use crate::output::{KvBlock, OutputMode, Table};
pub async fn mint(
name: &str,
scope_strs: &[String],
app_ident: Option<&str>,
expires: Option<&str>,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let scopes = parse_scopes(scope_strs)?;
let expires_at = expires.map(parse_expires).transpose()?;
let app_id = match app_ident {
Some(ident) => Some(client.apps_get(ident).await?.app.id),
None => None,
};
let body = MintApiKeyBody {
name,
scopes: &scopes,
app_id,
expires_at,
};
let resp = client.apikeys_mint(&body).await?;
let mut block = KvBlock::new();
block
.field("id", resp.key.id.to_string())
.field("name", resp.key.name.clone())
.field("prefix", resp.key.prefix.clone())
.field(
"scopes",
resp.key
.scopes
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(","),
)
.field(
"app_id",
resp.key
.app_id
.map(|a| a.to_string())
.unwrap_or_else(|| "-".into()),
)
.field(
"expires_at",
resp.key
.expires_at
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| "-".into()),
)
.field("token", resp.raw_token.clone());
block.print(mode);
if matches!(mode, OutputMode::Tsv) {
// The token row is human-easy-to-miss in a wall of metadata;
// call it out exactly once on the human path. Skip on JSON
// since machine consumers don't need the nudge.
eprintln!("Save this token — it will not be shown again.");
}
Ok(())
}
pub async fn ls(mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let keys = client.apikeys_list().await?;
let mut table = Table::new([
"id",
"name",
"prefix",
"scopes",
"app_id",
"expires_at",
"last_used_at",
"created_at",
]);
for k in keys {
table.row([
k.id.to_string(),
k.name,
k.prefix,
k.scopes
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(","),
k.app_id
.map(|a| a.to_string())
.unwrap_or_else(|| "-".into()),
k.expires_at
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| "-".into()),
k.last_used_at
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| "-".into()),
k.created_at.to_rfc3339(),
]);
}
table.print(mode);
Ok(())
}
pub async fn rm(id: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.apikeys_delete(id).await?;
println!("Revoked api-key {id}");
Ok(())
}
fn parse_scopes(raw: &[String]) -> Result<Vec<Scope>> {
if raw.is_empty() {
return Err(anyhow!(
"at least one `--scope` is required (e.g. --scope script:read)"
));
}
raw.iter()
.map(|s| Scope::from_wire(s).ok_or_else(|| anyhow!("unknown scope: {s}")))
.collect()
}
/// `--expires` accepts either RFC 3339 (`2026-12-31T23:59:59Z`) or a
/// shorthand `<N>d` / `<N>h` / `<N>m` (days / hours / minutes from now).
/// Shorthand wins for the common "key good for 30 days" case; full
/// RFC 3339 keeps the door open for precise cutoffs.
fn parse_expires(raw: &str) -> Result<DateTime<Utc>> {
if let Some(spec) = raw.strip_suffix('d') {
let days: i64 = spec.parse().map_err(|_| anyhow!("bad days: {raw}"))?;
return Ok(Utc::now() + chrono::Duration::days(days));
}
if let Some(spec) = raw.strip_suffix('h') {
let hours: i64 = spec.parse().map_err(|_| anyhow!("bad hours: {raw}"))?;
return Ok(Utc::now() + chrono::Duration::hours(hours));
}
if let Some(spec) = raw.strip_suffix('m') {
let mins: i64 = spec.parse().map_err(|_| anyhow!("bad minutes: {raw}"))?;
return Ok(Utc::now() + chrono::Duration::minutes(mins));
}
DateTime::parse_from_rfc3339(raw)
.map(|d| d.with_timezone(&Utc))
.map_err(|e| anyhow!("expected RFC 3339 or `<N>d/h/m`, got {raw:?}: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_scopes_accepts_wire_form() {
let scopes = parse_scopes(&["script:read".into(), "log:read".into()]).unwrap();
assert_eq!(scopes, vec![Scope::ScriptRead, Scope::LogRead]);
}
#[test]
fn parse_scopes_rejects_empty() {
let err = parse_scopes(&[]).unwrap_err();
assert!(format!("{err}").contains("at least one"));
}
#[test]
fn parse_scopes_rejects_unknown() {
let err = parse_scopes(&["script:nope".into()]).unwrap_err();
assert!(format!("{err}").contains("unknown scope"));
}
#[test]
fn parse_expires_days_shorthand() {
let d = parse_expires("7d").unwrap();
let diff = (d - Utc::now()).num_days();
assert!((6..=7).contains(&diff), "got {diff}");
}
#[test]
fn parse_expires_rfc3339_passes_through() {
let d = parse_expires("2030-01-01T00:00:00Z").unwrap();
assert_eq!(d.timestamp(), 1893456000);
}
#[test]
fn parse_expires_garbage_errors() {
assert!(parse_expires("tomorrow").is_err());
}
}

View File

@@ -0,0 +1,84 @@
//! `pic apps` subcommands: `ls`, `create`, `show`, `delete`.
use anyhow::Result;
use picloud_shared::AppRole;
use crate::client::{Client, CreateAppBody};
use crate::config;
use crate::output::{KvBlock, OutputMode, Table};
pub async fn ls(mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let apps = client.apps_list().await?;
let mut table = Table::new(["slug", "name", "my_role", "created_at"]);
for app in apps {
// The list endpoint returns App without my_role. We do a per-app
// lookup only on demand; for `ls` we leave the column dashed so
// the call stays cheap (one HTTP request).
table.row([
app.slug.clone(),
app.name.clone(),
"-".to_string(),
app.created_at.to_rfc3339(),
]);
}
table.print(mode);
Ok(())
}
pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let body = CreateAppBody {
slug,
name: name.unwrap_or(slug),
description,
};
let app = client.apps_create(&body).await?;
println!("Created app {}", app.slug);
Ok(())
}
/// `pic apps show <slug>` — single-app inspect using the lookup
/// endpoint, which carries `my_role` for the caller (the `ls` endpoint
/// doesn't).
pub async fn show(ident: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let lookup = client.apps_get(ident).await?;
let mut block = KvBlock::new();
block
.field("id", lookup.app.id.to_string())
.field("slug", lookup.app.slug.clone())
.field("name", lookup.app.name.clone())
.field(
"description",
lookup.app.description.clone().unwrap_or_else(|| "-".into()),
)
.field("my_role", role_label(lookup.my_role.as_ref()))
.field("created_at", lookup.app.created_at.to_rfc3339())
.field("updated_at", lookup.app.updated_at.to_rfc3339());
block.print(mode);
Ok(())
}
/// `pic apps delete <slug> [--force]`. Without `--force` the server
/// returns 409 if the app still owns scripts — surface that as a
/// useful error rather than swallowing.
pub async fn delete(ident: &str, force: bool) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.apps_delete(ident, force).await?;
println!("Deleted app {ident}");
Ok(())
}
fn role_label(role: Option<&AppRole>) -> String {
// Use the wire form so the CLI label matches what the dashboard
// shows and what the membership APIs accept.
match role {
Some(r) => r.as_str().to_string(),
None => "-".into(),
}
}

View File

@@ -0,0 +1,129 @@
//! `pic login` — primary auth entry point.
//!
//! Two flows:
//! * **username + password** (default, interactive): POST
//! `/api/v1/admin/auth/login` with the credentials and persist the
//! returned session token. Mirrors the dashboard's login form.
//! * **paste-a-token** (`--token <T>`, or `PICLOUD_TOKEN` env): skip
//! the credential exchange and persist a bearer string directly.
//! Used by CI and by anyone using a long-lived API key minted via
//! `pic api-keys mint`. Validated against `/auth/me` before save.
//!
//! `--url <U>` (or `PICLOUD_URL`) overrides the URL prompt non-interactively.
use std::io::{self, BufRead, Write};
use anyhow::{Context, Result};
use picloud_shared::InstanceRole;
use crate::client::{self, Client};
use crate::config::{save, Credentials};
const DEFAULT_URL: &str = "http://localhost:8000";
pub async fn run(url_arg: Option<&str>, token_arg: Option<&str>) -> Result<()> {
let url = resolve_url(url_arg)?;
let token_from_env = std::env::var("PICLOUD_TOKEN")
.ok()
.filter(|s| !s.is_empty());
let bearer_token = token_arg.map(str::to_string).or(token_from_env);
let (token, username, role) = match bearer_token {
Some(t) => login_with_bearer(&url, &t).await?,
None => login_with_password(&url).await?,
};
let creds = Credentials {
url: url.clone(),
token,
username: username.clone(),
};
save(&creds)?;
println!(
"Logged in as {username} ({}) at {url}",
instance_role_label(&role)
);
Ok(())
}
async fn login_with_password(url: &str) -> Result<(String, String, InstanceRole)> {
let username = prompt_line("Username: ")?;
if username.is_empty() {
anyhow::bail!("username is required");
}
let password = read_password()?;
let resp = client::auth_login(url, &username, &password).await?;
Ok((resp.token, resp.user.username, resp.user.instance_role))
}
/// Read a password without echoing it where possible. Falls back to a
/// plain stdin read when no controlling terminal is attached — CI
/// systems and `cargo test`'s piped stdin both land here, and dying
/// outright would block scripted use entirely. The fallback is louder
/// (visible characters), but it's that or no functioning login.
fn read_password() -> Result<String> {
match rpassword::prompt_password("Password: ") {
Ok(p) => Ok(p),
Err(_) => {
eprint!("Password: ");
io::stderr().flush()?;
let mut buf = String::new();
io::stdin()
.lock()
.read_line(&mut buf)
.context("reading password from stdin")?;
Ok(buf.trim_end_matches(['\r', '\n']).to_string())
}
}
}
/// Bearer-token path: validate against `/auth/me` so a typo doesn't get
/// persisted, then trust the username the server reports rather than
/// whatever the user typed (which they didn't type at all in this mode).
async fn login_with_bearer(url: &str, token: &str) -> Result<(String, String, InstanceRole)> {
let client = Client::new(url, token)?;
let me = client.auth_me().await?;
Ok((token.to_string(), me.username, me.instance_role))
}
fn instance_role_label(role: &InstanceRole) -> &'static str {
match role {
InstanceRole::Owner => "owner",
InstanceRole::Admin => "admin",
InstanceRole::Member => "member",
}
}
fn resolve_url(url_arg: Option<&str>) -> Result<String> {
if let Some(u) = url_arg {
return Ok(u.trim_end_matches('/').to_string());
}
if let Ok(env_url) = std::env::var("PICLOUD_URL") {
if !env_url.is_empty() {
return Ok(env_url.trim_end_matches('/').to_string());
}
}
let typed = prompt_with_default("PiCloud URL", DEFAULT_URL)?;
Ok(typed.trim_end_matches('/').to_string())
}
fn prompt_line(label: &str) -> Result<String> {
print!("{label}");
io::stdout().flush()?;
let mut buf = String::new();
io::stdin().lock().read_line(&mut buf)?;
Ok(buf.trim().to_string())
}
fn prompt_with_default(label: &str, default: &str) -> Result<String> {
print!("{label} [{default}]: ");
io::stdout().flush()?;
let mut buf = String::new();
io::stdin().lock().read_line(&mut buf)?;
let trimmed = buf.trim();
Ok(if trimmed.is_empty() {
default.to_string()
} else {
trimmed.to_string()
})
}

View File

@@ -0,0 +1,29 @@
//! `pic logout` — revoke the saved session server-side, then wipe the
//! local credentials file.
//!
//! Idempotent: if the file doesn't exist or the server already forgot
//! the session, we still succeed. The point is leaving the user in a
//! clean "no token" state, not enforcing that a session existed.
use anyhow::Result;
use crate::client::Client;
use crate::config;
pub async fn run() -> Result<()> {
// Load before delete so we have a token to POST /logout with; if
// there's no creds file there's also nothing to revoke server-side.
let creds = config::load().ok();
if let Some(creds) = creds {
let client = Client::from_creds(&creds)?;
// Best-effort: a 4xx (token already invalid) or network error
// shouldn't block the local wipe. The whole point of logout is
// leaving no credentials on disk.
let _ = client.auth_logout().await;
}
config::delete()?;
println!("Logged out");
Ok(())
}

View File

@@ -0,0 +1,79 @@
//! `pic logs <script-id>` — print recent execution log rows.
//!
//! In TSV mode emits a header + truncated-summary rows (`pic logs` was
//! previously headerless — inconsistent with `apps ls` / `scripts ls`).
//! In JSON mode emits the raw `ExecutionLog` array (no truncation),
//! letting `jq` consumers see request/response bodies in full.
use anyhow::Result;
use picloud_shared::{ExecutionLog, ExecutionStatus};
use crate::client::Client;
use crate::config;
use crate::output::{OutputMode, Table};
pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let entries = client.logs_list(script_id, limit).await?;
match mode {
OutputMode::Tsv => render_tsv(&entries),
OutputMode::Json => render_json(&entries),
}
Ok(())
}
fn render_tsv(entries: &[ExecutionLog]) {
let mut table = Table::new(["created_at", "status", "summary"]);
for e in entries {
let summary = summarize(&e.response_body, &e.script_logs);
table.row([
e.created_at.to_rfc3339(),
status_label(&e.status).to_string(),
truncate(&summary, 120),
]);
}
table.print(OutputMode::Tsv);
}
fn render_json(entries: &[ExecutionLog]) {
// Pretty for human jq-piping; consumers that want compact can pipe
// through `jq -c`.
let s = serde_json::to_string_pretty(entries).unwrap_or_else(|_| "[]".to_string());
println!("{s}");
}
fn status_label(s: &ExecutionStatus) -> &'static str {
match s {
ExecutionStatus::Success => "success",
ExecutionStatus::Error => "error",
ExecutionStatus::Timeout => "timeout",
ExecutionStatus::BudgetExceeded => "budget_exceeded",
}
}
fn summarize(response_body: &Option<serde_json::Value>, script_logs: &serde_json::Value) -> String {
// Prefer the last script-side log line (often the most useful for
// grepping). Fall back to the response body.
if let Some(arr) = script_logs.as_array() {
if let Some(last) = arr.last() {
if let Some(msg) = last.get("message").and_then(|m| m.as_str()) {
return msg.to_string();
}
}
}
response_body
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| "-".to_string())
}
fn truncate(s: &str, n: usize) -> String {
let normalized = s.replace('\n', " ");
if normalized.chars().count() <= n {
normalized
} else {
let head: String = normalized.chars().take(n).collect();
format!("{head}")
}
}

View File

@@ -0,0 +1,7 @@
pub mod api_keys;
pub mod apps;
pub mod login;
pub mod logout;
pub mod logs;
pub mod scripts;
pub mod whoami;

View File

@@ -0,0 +1,197 @@
//! `pic scripts ls | deploy | invoke | delete`.
use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::path::Path;
use anyhow::{anyhow, Context, Result};
use picloud_shared::AppId;
use serde_json::Value;
use crate::client::{Client, CreateScriptBody};
use crate::config;
use crate::output::{OutputMode, Table};
pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let mut table = Table::new(["id", "app_slug", "name", "version", "updated_at"]);
if let Some(ident) = app {
let app = client.apps_get(ident).await?;
let scripts = client.scripts_list_by_app(&app.app.slug).await?;
for s in scripts {
table.row([
s.id.to_string(),
app.app.slug.clone(),
s.name,
s.version.to_string(),
s.updated_at.to_rfc3339(),
]);
}
} else {
// No filter → use the single `GET /admin/scripts` call. Server
// filters by membership for `Member`; for `Admin`/`Owner` it
// returns every script. Two requests total (apps + scripts) run
// in parallel; the per-app walk we used to do here aborted on
// the first 404 when another caller deleted an app mid-listing,
// and was the entire reason a 5× retry existed in the tests.
let (apps, scripts) = tokio::try_join!(client.apps_list(), client.scripts_list_all())?;
let slug_by_id: HashMap<AppId, String> = apps.into_iter().map(|a| (a.id, a.slug)).collect();
for s in scripts {
let app_slug = slug_by_id
.get(&s.app_id)
.cloned()
.unwrap_or_else(|| "-".to_string());
table.row([
s.id.to_string(),
app_slug,
s.name,
s.version.to_string(),
s.updated_at.to_rfc3339(),
]);
}
}
table.print(mode);
Ok(())
}
pub async fn deploy(
file: &Path,
app_ident: &str,
name_override: Option<&str>,
description: Option<&str>,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let source =
std::fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?;
let name = match name_override {
Some(n) => n.to_string(),
None => file
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string)
.ok_or_else(|| {
anyhow!(
"could not derive script name from path {} (use --name)",
file.display()
)
})?,
};
// Slug-or-id resolution: a single GET satisfies both lookups and
// gives us the canonical app_id needed for create.
let app = client.apps_get(app_ident).await?;
let existing = client.scripts_list_by_app(app_ident).await?;
if let Some(s) = existing.into_iter().find(|s| s.name == name) {
let updated = client
.scripts_update_source(&s.id.to_string(), &source)
.await?;
println!("Updated {} v{}", updated.name, updated.version);
} else {
let body = CreateScriptBody {
app_id: app.app.id,
name: &name,
description,
source: &source,
};
let created = client.scripts_create(&body).await?;
println!("Created {} v{}", created.name, created.version);
}
Ok(())
}
pub async fn invoke(id: &str, body_arg: Option<&str>, headers: &[(String, String)]) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let body = parse_body_arg(body_arg)?;
let resp = client.execute(id, body, headers).await?;
// Status to stderr so stdout stays JSON for piping into jq.
let _ = writeln!(io::stderr(), "<- HTTP {}", resp.status_code);
let pretty = serde_json::to_string_pretty(&resp.body).unwrap_or_else(|_| resp.body.to_string());
println!("{pretty}");
if (200..400).contains(&resp.status_code) {
Ok(())
} else {
Err(anyhow!("execute returned HTTP {}", resp.status_code))
}
}
/// `pic scripts delete <id>`. Requires `AppAdmin` on the owning app
/// server-side, which is stricter than the edit endpoints — Editor
/// can deploy/update but not destroy. Surfaces that as a 403 with the
/// usual role hint.
pub async fn delete(id: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.scripts_delete(id).await?;
println!("Deleted script {id}");
Ok(())
}
fn parse_body_arg(arg: Option<&str>) -> Result<Value> {
match arg {
None => Ok(Value::Object(serde_json::Map::new())),
Some("@-") => {
let mut buf = String::new();
io::stdin()
.read_to_string(&mut buf)
.context("reading stdin")?;
parse_or_string(&buf)
}
Some(raw) if raw.starts_with('@') => {
let path = &raw[1..];
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading body file {path}"))?;
parse_or_string(&text)
}
Some(raw) => parse_or_string(raw),
}
}
fn parse_or_string(s: &str) -> Result<Value> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Ok(Value::Object(serde_json::Map::new()));
}
serde_json::from_str(trimmed)
.with_context(|| format!("body is not valid JSON: {}", truncate(trimmed, 80)))
}
fn truncate(s: &str, n: usize) -> String {
if s.len() <= n {
s.to_string()
} else {
format!("{}", &s[..n])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_body_inline_json() {
let v = parse_body_arg(Some(r#"{"x":1}"#)).unwrap();
assert_eq!(v["x"], 1);
}
#[test]
fn parse_body_none_is_empty_object() {
let v = parse_body_arg(None).unwrap();
assert!(v.is_object());
assert_eq!(v.as_object().unwrap().len(), 0);
}
#[test]
fn parse_body_invalid_json_reports() {
let err = parse_body_arg(Some("not-json{")).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("not valid JSON"), "got: {msg}");
}
}

View File

@@ -0,0 +1,34 @@
//! `pic whoami` — re-validates the saved token by hitting `/auth/me`
//! every time. Cached username in the credentials file is for
//! display-only contexts; this command is the source of truth.
//!
//! TSV output uses `KvBlock` (aligned `key: value` rows), JSON output
//! is a flat object — both downstream-friendly without the user having
//! to parse a headerless tab-line.
use anyhow::Result;
use picloud_shared::InstanceRole;
use crate::client::Client;
use crate::config;
use crate::output::{KvBlock, OutputMode};
pub async fn run(mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let me = client.auth_me().await?;
let role = match me.instance_role {
InstanceRole::Owner => "owner",
InstanceRole::Admin => "admin",
InstanceRole::Member => "member",
};
let email = me.email.as_deref().unwrap_or("-");
let mut block = KvBlock::new();
block
.field("username", me.username)
.field("role", role)
.field("email", email)
.field("url", creds.url.clone());
block.print(mode);
Ok(())
}

View File

@@ -0,0 +1,153 @@
//! On-disk credentials store.
//!
//! Path is resolved via `directories::ProjectDirs` so the file lives in
//! the platform-appropriate config dir (XDG on Linux, Library on macOS,
//! AppData on Windows). On POSIX the file is forced to mode 0600 so the
//! pasted bearer token isn't world-readable.
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Credentials {
pub url: String,
pub token: String,
pub username: String,
}
/// Resolve the credentials file path. Honors `PICLOUD_CONFIG_DIR` as an
/// override (used by tests to redirect to a tempdir) before falling
/// back to the platform default.
pub fn credentials_path() -> Result<PathBuf> {
if let Ok(dir) = std::env::var("PICLOUD_CONFIG_DIR") {
return Ok(PathBuf::from(dir).join("credentials"));
}
let dirs = ProjectDirs::from("dev", "picloud", "picloud")
.ok_or_else(|| anyhow!("could not determine config directory"))?;
Ok(dirs.config_dir().join("credentials"))
}
pub fn load() -> Result<Credentials> {
let path = credentials_path()?;
let body = fs::read_to_string(&path).with_context(|| {
format!(
"no credentials at {}. run `pic login` first",
path.display()
)
})?;
toml::from_str(&body).with_context(|| format!("failed to parse {}", path.display()))
}
/// Resolution order used by every non-login command:
/// 1. If both `PICLOUD_URL` and `PICLOUD_TOKEN` are set (and non-empty),
/// use them directly. Matches gcloud/aws/kubectl semantics — env
/// wins so CI never accidentally reads a developer's stale file.
/// 2. Otherwise fall back to the on-disk credentials file.
///
/// Username is best-effort: env mode has no way to know the real one
/// (no round-trip to `/auth/me`), so it shows as `"-"` in `whoami`
/// output. Callers that need the canonical username re-fetch via
/// `Client::auth_me`.
pub fn resolve() -> Result<Credentials> {
if let (Ok(url), Ok(token)) = (std::env::var("PICLOUD_URL"), std::env::var("PICLOUD_TOKEN")) {
if !url.is_empty() && !token.is_empty() {
return Ok(Credentials {
url,
token,
username: "-".to_string(),
});
}
}
load()
}
/// Delete the on-disk credentials file. Idempotent — silently succeeds
/// if the file is already gone (the user already logged out, or never
/// logged in to begin with).
pub fn delete() -> Result<()> {
let path = credentials_path()?;
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err).with_context(|| format!("removing {}", path.display())),
}
}
pub fn save(creds: &Credentials) -> Result<()> {
let path = credentials_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
let body = toml::to_string(creds).context("serializing credentials")?;
write_private(&path, body.as_bytes())?;
Ok(())
}
#[cfg(unix)]
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
use std::os::unix::fs::OpenOptionsExt;
let mut f = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("opening {}", path.display()))?;
f.write_all(bytes)
.with_context(|| format!("writing {}", path.display()))?;
// Belt-and-suspenders: re-set perms in case the file already existed
// with a wider mode (mode() on create doesn't downgrade existing).
let mut perms = fs::metadata(path)?.permissions();
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o600);
fs::set_permissions(path, perms)?;
Ok(())
}
#[cfg(not(unix))]
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
fs::write(path, bytes).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn roundtrip_toml() {
let creds = Credentials {
url: "http://localhost:8000".to_string(),
token: "pic_abc".to_string(),
username: "admin".to_string(),
};
let serialized = toml::to_string(&creds).unwrap();
let parsed: Credentials = toml::from_str(&serialized).unwrap();
assert_eq!(creds, parsed);
}
#[cfg(unix)]
#[test]
fn posix_mode_is_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
std::env::set_var("PICLOUD_CONFIG_DIR", dir.path());
let creds = Credentials {
url: "http://localhost:8000".to_string(),
token: "pic_secret".to_string(),
username: "admin".to_string(),
};
save(&creds).unwrap();
let path = credentials_path().unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "credentials must be readable only by owner");
std::env::remove_var("PICLOUD_CONFIG_DIR");
}
}

View File

@@ -0,0 +1,268 @@
//! PiCloud command-line client.
//!
//! Thin client over the existing admin + execute HTTP surface — the
//! server gains nothing for the CLI; the CLI is just a developer
//! ergonomics layer over endpoints the dashboard already uses.
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Args, Parser, Subcommand};
mod client;
mod cmds;
mod config;
mod output;
use crate::output::OutputMode;
#[derive(Parser)]
#[command(name = "pic", version, about = "PiCloud command-line client")]
struct Cli {
/// Output format for `ls` / `show` / `whoami` / `logs` commands.
/// TSV stays pipe-friendly; JSON is `jq`-ready.
#[arg(long, value_enum, global = true, default_value_t = OutputMode::Tsv)]
output: OutputMode,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Authenticate with the server. Default flow prompts for username
/// + password and saves the returned session token; `--token` skips
/// the password exchange and persists a bearer string directly (use
/// this for long-lived API keys minted via `pic api-keys mint`).
Login(LoginArgs),
/// Revoke the saved session server-side and delete the local
/// credentials file. Idempotent.
Logout,
/// Print the principal the saved token resolves to.
Whoami,
/// App management.
Apps {
#[command(subcommand)]
cmd: AppsCmd,
},
/// Script management.
Scripts {
#[command(subcommand)]
cmd: ScriptsCmd,
},
/// Long-lived bearer API key management.
#[command(name = "api-keys")]
ApiKeys {
#[command(subcommand)]
cmd: ApiKeysCmd,
},
/// Tail recent execution logs for a script.
Logs(LogsArgs),
/// Top-level alias for `pic scripts invoke <id>`.
Invoke(InvokeArgs),
/// Top-level alias for `pic scripts deploy <file> --app <slug>`.
Deploy(DeployArgs),
}
#[derive(Args)]
struct LoginArgs {
/// Override the URL prompt non-interactively. Also reads
/// `PICLOUD_URL`.
#[arg(long)]
url: Option<String>,
/// Skip the username + password exchange and persist this bearer
/// directly (validated against `/auth/me` first). Also reads
/// `PICLOUD_TOKEN`.
#[arg(long)]
token: Option<String>,
}
#[derive(Subcommand)]
enum AppsCmd {
/// List apps the caller can see.
Ls,
/// Create a new app.
Create {
slug: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
},
/// Show a single app, including the caller's role in it.
Show { ident: String },
/// Delete an app. Without `--force`, the server rejects if the app
/// still owns scripts.
Delete {
ident: String,
#[arg(long)]
force: bool,
},
}
#[derive(Subcommand)]
enum ScriptsCmd {
/// List scripts. With `--app`, scoped to one app; without, one
/// `GET /admin/scripts` for everything the caller can see.
Ls {
#[arg(long)]
app: Option<String>,
},
/// Upload a `.rhai` file. Patches the existing script with the
/// matching name in `--app` if one exists, otherwise creates it.
Deploy(DeployArgs),
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
/// `--body @-` for stdin, or inline JSON.
Invoke(InvokeArgs),
/// Delete a script. Requires AppAdmin on the owning app.
Delete { id: String },
}
#[derive(Args)]
struct DeployArgs {
file: PathBuf,
#[arg(long)]
app: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
}
#[derive(Args)]
struct InvokeArgs {
id: String,
#[arg(long)]
body: Option<String>,
#[arg(short = 'H', long = "header", value_parser = client::parse_kv_header)]
headers: Vec<(String, String)>,
}
#[derive(Subcommand)]
enum ApiKeysCmd {
/// Mint a new long-lived bearer key. Token printed exactly once.
Mint {
name: String,
/// Repeat for multiple scopes: `--scope script:read --scope log:read`.
#[arg(long = "scope", required = true)]
scopes: Vec<String>,
/// Bind the key to a single app (slug or id). Rejects
/// `instance:*` scopes when set.
#[arg(long)]
app: Option<String>,
/// Absolute RFC 3339 (`2026-12-31T23:59:59Z`) or shorthand
/// `<N>d`/`<N>h`/`<N>m`.
#[arg(long)]
expires: Option<String>,
},
/// List the caller's keys (no `raw_token` after mint).
Ls,
/// Revoke a key by id.
Rm { id: String },
}
#[derive(Args)]
struct LogsArgs {
script_id: String,
#[arg(long, default_value_t = 50)]
limit: u32,
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> ExitCode {
let cli = Cli::parse();
let mode = cli.output;
let result = match cli.cmd {
Cmd::Login(args) => cmds::login::run(args.url.as_deref(), args.token.as_deref()).await,
Cmd::Logout => cmds::logout::run().await,
Cmd::Whoami => cmds::whoami::run(mode).await,
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await,
Cmd::Apps {
cmd:
AppsCmd::Create {
slug,
name,
description,
},
} => cmds::apps::create(&slug, name.as_deref(), description.as_deref()).await,
Cmd::Apps {
cmd: AppsCmd::Show { ident },
} => cmds::apps::show(&ident, mode).await,
Cmd::Apps {
cmd: AppsCmd::Delete { ident, force },
} => cmds::apps::delete(&ident, force).await,
Cmd::Scripts {
cmd: ScriptsCmd::Ls { app },
} => cmds::scripts::ls(app.as_deref(), mode).await,
Cmd::Scripts {
cmd: ScriptsCmd::Deploy(args),
} => {
cmds::scripts::deploy(
&args.file,
&args.app,
args.name.as_deref(),
args.description.as_deref(),
)
.await
}
Cmd::Scripts {
cmd: ScriptsCmd::Invoke(args),
} => cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await,
Cmd::Scripts {
cmd: ScriptsCmd::Delete { id },
} => cmds::scripts::delete(&id).await,
Cmd::ApiKeys {
cmd:
ApiKeysCmd::Mint {
name,
scopes,
app,
expires,
},
} => cmds::api_keys::mint(&name, &scopes, app.as_deref(), expires.as_deref(), mode).await,
Cmd::ApiKeys {
cmd: ApiKeysCmd::Ls,
} => cmds::api_keys::ls(mode).await,
Cmd::ApiKeys {
cmd: ApiKeysCmd::Rm { id },
} => cmds::api_keys::rm(&id).await,
Cmd::Logs(LogsArgs { script_id, limit }) => cmds::logs::run(&script_id, limit, mode).await,
Cmd::Invoke(args) => {
cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await
}
Cmd::Deploy(args) => {
cmds::scripts::deploy(
&args.file,
&args.app,
args.name.as_deref(),
args.description.as_deref(),
)
.await
}
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
output::print_error(&err);
ExitCode::FAILURE
}
}
}

View File

@@ -0,0 +1,252 @@
//! Output rendering for the CLI.
//!
//! Two formats:
//! * **TSV** (default): aligned columns separated by `\t`. Stays
//! pipe-friendly — `pic apps ls | awk -F'\t' '{print $1}'` works
//! without parsing box-drawing.
//! * **JSON**: array of `{column: value, …}` objects (for tables) or
//! a flat object (for single-row `show`/`whoami`). Designed to be
//! `jq`-friendly without escaping the table column names.
//!
//! Mode is set globally by the top-level `--output` flag and threaded
//! through every command. Single-row commands (`whoami`, `apps show`)
//! use `KvBlock`; everything plural uses `Table`.
use std::io::{self, Write};
use clap::ValueEnum;
use serde_json::{Map, Value};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
#[clap(rename_all = "lowercase")]
pub enum OutputMode {
#[default]
Tsv,
Json,
}
// ----------------------------------------------------------------------------
// Table — list views (`apps ls`, `scripts ls`, `logs`)
// ----------------------------------------------------------------------------
pub struct Table {
headers: Vec<String>,
rows: Vec<Vec<String>>,
}
impl Table {
pub fn new<I, S>(headers: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
headers: headers.into_iter().map(Into::into).collect(),
rows: Vec::new(),
}
}
pub fn row<I, S>(&mut self, cells: I) -> &mut Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.rows.push(cells.into_iter().map(Into::into).collect());
self
}
pub fn render_tsv(&self) -> String {
let mut widths: Vec<usize> = self.headers.iter().map(String::len).collect();
for row in &self.rows {
for (i, cell) in row.iter().enumerate() {
if i >= widths.len() {
widths.push(cell.len());
} else if cell.len() > widths[i] {
widths[i] = cell.len();
}
}
}
let mut out = String::new();
write_row(&mut out, &self.headers, &widths);
for row in &self.rows {
write_row(&mut out, row, &widths);
}
out
}
/// JSON form: `[{header: cell, …}, …]`. Cells go in as strings even
/// when they happen to look like numbers — the CLI doesn't carry
/// type information all the way through (e.g., `version` is already
/// `to_string`'d at the call site). Consumers that need typed
/// numbers should parse `jq -r '.[].version|tonumber'`.
pub fn render_json(&self) -> String {
let arr: Vec<Value> = self
.rows
.iter()
.map(|row| {
let mut obj = Map::new();
for (i, header) in self.headers.iter().enumerate() {
let cell = row.get(i).cloned().unwrap_or_default();
obj.insert(header.clone(), Value::String(cell));
}
Value::Object(obj)
})
.collect();
serde_json::to_string_pretty(&Value::Array(arr)).unwrap_or_else(|_| "[]".to_string())
}
pub fn print(&self, mode: OutputMode) {
let s = match mode {
OutputMode::Tsv => self.render_tsv(),
OutputMode::Json => {
let mut s = self.render_json();
s.push('\n');
s
}
};
// Best-effort write — broken pipe from `| head` etc. shouldn't
// surface as an error.
let _ = io::stdout().write_all(s.as_bytes());
}
}
fn write_row(out: &mut String, row: &[String], widths: &[usize]) {
for (i, cell) in row.iter().enumerate() {
if i > 0 {
out.push('\t');
}
out.push_str(cell);
// Right-pad with spaces so tabs land on the column grid for
// human readers. Skip on the final column.
if i + 1 < row.len() {
let w = widths.get(i).copied().unwrap_or(cell.len());
for _ in cell.len()..w {
out.push(' ');
}
}
}
out.push('\n');
}
// ----------------------------------------------------------------------------
// KvBlock — single-row views (`whoami`, `apps show`)
// ----------------------------------------------------------------------------
/// One row's worth of fields, rendered as aligned `key: value` lines in
/// TSV mode (one line per field — easier on the eye than a 1-row table)
/// or a flat JSON object.
pub struct KvBlock {
fields: Vec<(String, String)>,
}
impl KvBlock {
pub fn new() -> Self {
Self { fields: Vec::new() }
}
pub fn field(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
self.fields.push((key.into(), value.into()));
self
}
pub fn render_tsv(&self) -> String {
let key_width = self.fields.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
let mut out = String::new();
for (k, v) in &self.fields {
out.push_str(k);
for _ in k.len()..key_width {
out.push(' ');
}
out.push('\t');
out.push_str(v);
out.push('\n');
}
out
}
pub fn render_json(&self) -> String {
let mut obj = Map::new();
for (k, v) in &self.fields {
obj.insert(k.clone(), Value::String(v.clone()));
}
serde_json::to_string_pretty(&Value::Object(obj)).unwrap_or_else(|_| "{}".to_string())
}
pub fn print(&self, mode: OutputMode) {
let s = match mode {
OutputMode::Tsv => self.render_tsv(),
OutputMode::Json => {
let mut s = self.render_json();
s.push('\n');
s
}
};
let _ = io::stdout().write_all(s.as_bytes());
}
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
pub fn print_error(err: &anyhow::Error) {
let mut stderr = io::stderr();
let _ = writeln!(stderr, "error: {err:#}");
}
// ----------------------------------------------------------------------------
// Tests
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_aligns_columns_tsv() {
let mut t = Table::new(["slug", "name"]);
t.row(["a", "Alpha"]).row(["bravo", "B"]);
let out = t.render_tsv();
assert_eq!(out, "slug \tname\na \tAlpha\nbravo\tB\n");
}
#[test]
fn table_empty_rows_tsv() {
let t = Table::new(["a", "b"]);
assert_eq!(t.render_tsv(), "a\tb\n");
}
#[test]
fn table_render_json_is_array_of_objects() {
let mut t = Table::new(["slug", "name"]);
t.row(["a", "Alpha"]).row(["bravo", "B"]);
let raw = t.render_json();
let v: Value = serde_json::from_str(&raw).expect("valid JSON");
let arr = v.as_array().expect("array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["slug"], "a");
assert_eq!(arr[0]["name"], "Alpha");
assert_eq!(arr[1]["slug"], "bravo");
assert_eq!(arr[1]["name"], "B");
}
#[test]
fn kv_block_tsv_aligns_keys() {
let mut b = KvBlock::new();
b.field("username", "admin").field("role", "owner");
let out = b.render_tsv();
// username (8 chars) defines the key width.
assert_eq!(out, "username\tadmin\nrole \towner\n");
}
#[test]
fn kv_block_json_is_flat_object() {
let mut b = KvBlock::new();
b.field("username", "admin").field("role", "owner");
let raw = b.render_json();
let v: Value = serde_json::from_str(&raw).expect("valid JSON");
assert_eq!(v["username"], "admin");
assert_eq!(v["role"], "owner");
}
}

View File

@@ -0,0 +1,170 @@
//! `pic api-keys` — mint / ls / rm journeys.
//!
//! Server semantics asserted here:
//! * `mint` emits the `raw_token` *exactly once* and never on `ls`.
//! * A minted key is a valid bearer for `/auth/me`.
//! * After `rm`, the same token is rejected (401).
use predicates::prelude::*;
use serde_json::Value;
use crate::common;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn mint_prints_raw_token_once_and_ls_omits_it() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let name = format!("pic-cli-mint-{}", common::unique_slug("k"));
let out = common::pic_as(&env)
.args([
"--output",
"json",
"api-keys",
"mint",
&name,
"--scope",
"script:read",
])
.output()
.expect("api-keys mint");
assert!(out.status.success(), "mint failed: {out:?}");
let body: Value = serde_json::from_slice(&out.stdout).expect("JSON");
let token = body["token"]
.as_str()
.expect("mint should expose `token`")
.to_string();
let key_id = body["id"]
.as_str()
.expect("mint should expose `id`")
.to_string();
assert!(
token.starts_with("pic_"),
"tokens are pic_-prefixed: {token}"
);
// `ls` must NEVER carry the raw token. The key row should appear,
// identified by name, but `token` is mint-only.
let ls = common::pic_as(&env)
.args(["--output", "json", "api-keys", "ls"])
.output()
.expect("api-keys ls");
assert!(ls.status.success(), "ls failed: {ls:?}");
let ls_body: Value = serde_json::from_slice(&ls.stdout).expect("JSON");
let arr = ls_body.as_array().expect("array");
let row = arr
.iter()
.find(|r| r.get("id").and_then(Value::as_str) == Some(key_id.as_str()))
.expect("our key in ls");
assert!(
row.get("token").is_none(),
"ls must not expose raw_token: {row}"
);
// Cleanup so we don't leak keys across runs.
common::pic_as(&env)
.args(["api-keys", "rm", &key_id])
.assert()
.success();
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn minted_key_works_as_bearer() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let name = format!("pic-cli-bearer-{}", common::unique_slug("k"));
let mint = common::pic_as(&env)
.args([
"--output",
"json",
"api-keys",
"mint",
&name,
"--scope",
"script:read",
])
.output()
.expect("mint");
assert!(mint.status.success());
let body: Value = serde_json::from_slice(&mint.stdout).unwrap();
let token = body["token"].as_str().unwrap().to_string();
let id = body["id"].as_str().unwrap().to_string();
// Drive whoami with the minted token — proves the bearer string we
// captured really is what the server stamped.
let key_env = common::custom_env(&fx.url, &token);
common::seed_credentials(&key_env, &fx.admin_username);
common::pic_as(&key_env)
.args(["whoami"])
.assert()
.success()
.stdout(predicate::str::contains(fx.admin_username.as_str()));
common::pic_as(&env)
.args(["api-keys", "rm", &id])
.assert()
.success();
}
/// After `rm`, the bearer token is dead server-side: a follow-up
/// `whoami` driven by it must 401, not 500.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn rm_revokes_the_token() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let name = format!("pic-cli-rm-{}", common::unique_slug("k"));
let mint = common::pic_as(&env)
.args([
"--output",
"json",
"api-keys",
"mint",
&name,
"--scope",
"script:read",
])
.output()
.expect("mint");
let body: Value = serde_json::from_slice(&mint.stdout).unwrap();
let token = body["token"].as_str().unwrap().to_string();
let id = body["id"].as_str().unwrap().to_string();
common::pic_as(&env)
.args(["api-keys", "rm", &id])
.assert()
.success()
.stdout(predicate::str::contains(format!("Revoked api-key {id}")));
let dead = common::custom_env(&fx.url, &token);
common::pic_as(&dead)
.args(["whoami"])
.assert()
.failure()
.stderr(predicate::str::contains("HTTP 401"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn mint_with_unknown_scope_is_rejected_client_side() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
common::pic_as(&env)
.args(["api-keys", "mint", "doomed", "--scope", "script:nope"])
.assert()
.failure()
.stderr(predicate::str::contains("unknown scope"));
}

View File

@@ -0,0 +1,268 @@
//! `pic apps create` / `pic apps ls` edge cases. The integration smoke
//! test covers the happy path; this module covers conflict, validation,
//! and the persistence of the optional `--name` / `--description` flags
//! (which `apps ls` doesn't surface).
use predicates::prelude::*;
use serde_json::Value;
use crate::common;
use crate::common::cleanup::AppGuard;
use crate::common::member;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn create_with_name_and_description_persists() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("apps-named");
common::pic_as(&env)
.args([
"apps",
"create",
&slug,
"--name",
"Pretty Name",
"--description",
"test description",
])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
// `apps ls` only shows slug+name+role+created_at, so verify the
// persisted shape via the admin GET endpoint.
let client = reqwest::blocking::Client::new();
let resp = client
.get(format!("{}/api/v1/admin/apps/{}", env.url, slug))
.bearer_auth(&env.token)
.send()
.expect("GET app");
assert!(resp.status().is_success(), "GET app failed: {resp:?}");
let body: Value = resp.json().expect("app json");
assert_eq!(body["slug"].as_str(), Some(slug.as_str()));
assert_eq!(body["name"].as_str(), Some("Pretty Name"));
assert_eq!(body["description"].as_str(), Some("test description"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn create_duplicate_slug_conflicts() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("apps-dup");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.failure()
.stderr(predicate::str::contains("409").or(predicate::str::contains("conflict")));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn create_invalid_slug_rejected() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
// Server slug regex is `^[a-z0-9][a-z0-9-]{0,62}$` — uppercase
// breaks the rule on the very first char. The server returns 422
// (`InvalidSlug` → `UNPROCESSABLE_ENTITY`), not 400 — the previous
// `"HTTP 4"` predicate would have silently matched any other 4xx
// (a regressed 401 from broken auth, for example).
common::pic_as(&env)
.args(["apps", "create", "NotALowerSlug"])
.assert()
.failure()
.stderr(predicate::str::contains("HTTP 422"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn ls_includes_created_app_with_expected_columns() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("apps-ls");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let out = common::pic_as(&env)
.args(["apps", "ls"])
.output()
.expect("apps ls");
assert!(out.status.success(), "apps ls failed: {out:?}");
let stdout = String::from_utf8(out.stdout).expect("utf8 stdout");
let mut lines = stdout.lines();
let header = lines.next().expect("header row");
assert_eq!(
common::cells(header),
vec!["slug", "name", "my_role", "created_at"]
);
// The slug must appear in some data row and its row's my_role column
// is dashed (the ls endpoint doesn't compute it per-app).
let row = lines
.map(common::cells)
.find(|c| c.first().copied() == Some(slug.as_str()))
.unwrap_or_else(|| panic!("slug {slug} not in apps ls output: {stdout}"));
assert_eq!(row.len(), 4, "row should have 4 cells: {row:?}");
assert_eq!(row[2], "-", "my_role column should be dashed: {row:?}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn delete_removes_app_from_ls() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("apps-del");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
common::pic_as(&env)
.args(["apps", "delete", &slug])
.assert()
.success()
.stdout(predicate::str::contains(format!("Deleted app {slug}")));
let out = common::pic_as(&env)
.args(["apps", "ls"])
.output()
.expect("apps ls");
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
!stdout.lines().any(|l| l.starts_with(&slug)),
"deleted slug should not appear in ls: {stdout}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn delete_with_scripts_errors_without_force() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("apps-del-busy");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
// AppGuard is the safety net: if the no-force delete fails (as
// expected) the app stays around; AppGuard force-deletes on drop.
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let fixture = common::fixture_path("hello.rhai");
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.success();
common::pic_as(&env)
.args(["apps", "delete", &slug])
.assert()
.failure()
// Server `HasScripts` → 409 with a "scripts present" message.
.stderr(predicate::str::contains("HTTP 409"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn delete_with_scripts_succeeds_with_force() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("apps-del-force");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let fixture = common::fixture_path("hello.rhai");
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.success();
common::pic_as(&env)
.args(["apps", "delete", &slug, "--force"])
.assert()
.success()
.stdout(predicate::str::contains(format!("Deleted app {slug}")));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn show_prints_my_role_for_member() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let admin_env = common::admin_env(fx);
let slug = common::unique_slug("apps-show");
common::pic_as(&admin_env)
.args(["apps", "create", &slug])
.assert()
.success();
let _g = AppGuard::new(&admin_env.url, &admin_env.token, &slug);
let m = member::member_user(fx, &common::unique_username("show"));
member::grant_membership(fx, &slug, &m.id, "viewer");
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
let out = common::pic_as(&member_env)
.args(["apps", "show", &slug])
.output()
.expect("apps show");
assert!(out.status.success(), "apps show failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
// KvBlock output: `my_role` row carries the wire form (`viewer`).
assert!(
stdout
.lines()
.any(|l| l.starts_with("my_role") && l.trim_end().ends_with("viewer")),
"show should surface my_role=viewer, got: {stdout}"
);
assert!(
stdout.lines().any(|l| l.starts_with("slug")),
"show should include slug row: {stdout}"
);
}

View File

@@ -0,0 +1,288 @@
//! Login + whoami journeys beyond the happy path: bad tokens, missing
//! credentials file, stale on-disk creds, and the role-label rendered
//! by `pic login`.
use predicates::prelude::*;
use crate::common;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn login_persists_credentials_with_correct_perms() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
common::pic_as(&env).args(["login"]).assert().success();
let creds_path = env.config_dir.path().join("credentials");
let body = std::fs::read_to_string(&creds_path).expect("credentials file");
assert!(
body.contains(&format!("url = \"{}\"", env.url)),
"creds missing url line: {body}",
);
assert!(
body.contains(&format!("token = \"{}\"", env.token)),
"creds missing token line: {body}",
);
assert!(
body.contains(&format!("username = \"{}\"", fx.admin_username)),
"creds missing username line: {body}",
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&creds_path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "credentials file must be 0600, got {mode:o}");
}
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn login_rejects_bad_token() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::custom_env(&fx.url, "pic_garbage_token");
common::pic_as(&env)
.args(["login"])
.assert()
.failure()
.stderr(predicate::str::contains("401").or(predicate::str::contains("token rejected")));
let creds_path = env.config_dir.path().join("credentials");
assert!(
!creds_path.exists(),
"failed login must not persist credentials"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn whoami_without_credentials_errors() {
let Some(_fx) = common::fixture_or_skip() else {
return;
};
// Build a TestEnv directly so the config dir stays empty —
// `admin_env` would seed a credentials file, masking the bug
// this test is supposed to catch.
let env = common::TestEnv {
url: String::new(),
token: String::new(),
config_dir: tempfile::TempDir::new().unwrap(),
home: tempfile::TempDir::new().unwrap(),
};
common::pic_no_env(&env)
.args(["whoami"])
.assert()
.failure()
.stderr(predicate::str::contains("pic login"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn whoami_with_stale_token_errors() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let body = format!(
"url = \"{}\"\ntoken = \"pic_stale_token\"\nusername = \"ghost\"\n",
env.url
);
std::fs::write(env.config_dir.path().join("credentials"), body).unwrap();
common::pic_no_env(&env)
.args(["whoami"])
.assert()
.failure()
.stderr(predicate::str::contains("401").or(predicate::str::contains("token rejected")));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn login_prints_member_role_label() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let username = common::unique_username("auth");
let m = common::member::member_user(fx, &username);
let env = common::custom_env(&fx.url, &m.token);
common::pic_as(&env)
.args(["login"])
.assert()
.success()
.stdout(predicate::str::contains(format!(
"Logged in as {} (member)",
m.username
)));
}
/// Drive the real username+password flow end-to-end. `pic_no_env`
/// strips `PICLOUD_TOKEN` so login can't short-circuit through the
/// bearer path; stdin feeds `username\npassword\n` (the URL is supplied
/// via `--url` to avoid the third prompt).
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn login_with_username_and_password_persists() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let username = common::unique_username("lpw");
let m = common::member::member_user(fx, &username);
let env = common::custom_env(&fx.url, ""); // empty token — file gets written by login
let stdin_payload = format!("{}\n{}\n", m.username, common::member::MEMBER_PASSWORD);
common::pic_no_env(&env)
.args(["login", "--url", &fx.url])
.write_stdin(stdin_payload)
.assert()
.success()
.stdout(predicate::str::contains(format!(
"Logged in as {} (member)",
m.username
)));
let creds_path = env.config_dir.path().join("credentials");
let body = std::fs::read_to_string(&creds_path).expect("credentials file");
assert!(
body.contains(&format!("username = \"{}\"", m.username)),
"creds should carry the canonical username: {body}",
);
// The token persisted must be a real session token, not whatever
// the user typed — a regression where we accidentally saved the
// password as the token would fail this check.
assert!(
!body.contains(common::member::MEMBER_PASSWORD),
"password leaked into credentials file: {body}",
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn login_with_wrong_password_errors() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let username = common::unique_username("lpwbad");
let m = common::member::member_user(fx, &username);
let env = common::custom_env(&fx.url, "");
let stdin_payload = format!("{}\nwrong-password\n", m.username);
common::pic_no_env(&env)
.args(["login", "--url", &fx.url])
.write_stdin(stdin_payload)
.assert()
.failure()
.stderr(predicate::str::contains("HTTP 401"));
let creds_path = env.config_dir.path().join("credentials");
assert!(
!creds_path.exists(),
"failed login must not persist credentials"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logout_clears_local_credentials() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
// Use a member's token so we don't yank the admin session out from
// under parallel tests. The local-file cleanup is the same.
let username = common::unique_username("lout");
let m = common::member::member_user(fx, &username);
let env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&env, &m.username);
let creds_path = env.config_dir.path().join("credentials");
assert!(creds_path.exists(), "precondition: creds file seeded");
common::pic_no_env(&env)
.args(["logout"])
.assert()
.success()
.stdout(predicate::str::contains("Logged out"));
assert!(
!creds_path.exists(),
"credentials file should be removed after logout"
);
}
/// `pic logout` is meant to be idempotent: running it with no
/// credentials file present is not an error.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logout_is_idempotent_when_already_logged_out() {
let Some(_fx) = common::fixture_or_skip() else {
return;
};
let env = common::TestEnv {
url: String::new(),
token: String::new(),
config_dir: tempfile::TempDir::new().unwrap(),
home: tempfile::TempDir::new().unwrap(),
};
common::pic_no_env(&env)
.args(["logout"])
.assert()
.success()
.stdout(predicate::str::contains("Logged out"));
}
/// Server-side session invalidation: after `pic logout`, a subsequent
/// `pic whoami` driven by the same (now-stale) token must 401.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logout_invalidates_server_session() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let username = common::unique_username("lout2");
let m = common::member::member_user(fx, &username);
let env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&env, &m.username);
common::pic_no_env(&env).args(["logout"]).assert().success();
// Replay the member's old token explicitly — pic_no_env reads the
// (now-deleted) file, so we go back to env-driven mode with the
// stale bearer.
let stale = common::custom_env(&fx.url, &m.token);
common::pic_as(&stale)
.args(["whoami"])
.assert()
.failure()
.stderr(predicate::str::contains("HTTP 401"));
}
/// Env vars must override the on-disk credentials file globally. Write
/// garbage into the file, set env to the real admin creds, and prove
/// every read-side command (here `whoami`) goes via env.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn env_vars_override_credentials_file() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::custom_env(&fx.url, &fx.admin_token);
// Garbage in the file: would 401 if used.
let body = format!(
"url = \"{}\"\ntoken = \"pic_stale_garbage_token\"\nusername = \"ghost\"\n",
env.url
);
std::fs::write(env.config_dir.path().join("credentials"), body).unwrap();
common::pic_as(&env)
.args(["whoami"])
.assert()
.success()
.stdout(predicate::str::contains(fx.admin_username.as_str()));
}

View File

@@ -0,0 +1,23 @@
//! Integration-test binary for the `pic` CLI.
//!
//! Every `#[test]` in this binary routes through `common::fixture()`, a
//! `LazyLock` that spawns picloud once on a private port and reuses it
//! across all journey modules. Mirrors the dashboard Playwright suite,
//! which spins backend + Vite up once for 63 specs.
//!
//! Gated on `DATABASE_URL`. To run:
//!
//! docker compose up -d postgres
//! DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \
//! cargo test -p picloud-cli --test cli -- --include-ignored
mod common;
mod api_keys;
mod apps;
mod auth;
mod invoke;
mod logs;
mod output;
mod roles;
mod scripts;

View File

@@ -0,0 +1,61 @@
//! RAII guards that delete server-side resources on `Drop`.
//!
//! Each guard owns the minimum it needs to issue a single DELETE: the
//! base URL, an admin bearer token, and the resource identifier.
//! Failures are swallowed because Drop runs during teardown — a panic
//! here would just mask the real failure that the test was reporting.
pub struct AppGuard {
url: String,
token: String,
slug: String,
}
impl AppGuard {
pub fn new(url: &str, token: &str, slug: &str) -> Self {
Self {
url: url.to_string(),
token: token.to_string(),
slug: slug.to_string(),
}
}
}
impl Drop for AppGuard {
fn drop(&mut self) {
let client = reqwest::blocking::Client::new();
let _ = client
.delete(format!(
"{}/api/v1/admin/apps/{}?force=true",
self.url, self.slug
))
.bearer_auth(&self.token)
.send();
}
}
pub struct UserGuard {
url: String,
token: String,
user_id: String,
}
impl UserGuard {
pub fn new(url: &str, token: &str, user_id: &str) -> Self {
Self {
url: url.to_string(),
token: token.to_string(),
user_id: user_id.to_string(),
}
}
}
impl Drop for UserGuard {
fn drop(&mut self) {
let client = reqwest::blocking::Client::new();
let _ = client
.delete(format!("{}/api/v1/admin/admins/{}", self.url, self.user_id))
.bearer_auth(&self.token)
.send();
}
}

View File

@@ -0,0 +1,99 @@
//! Helpers for non-admin (`instance_role: Member`) user lifecycle plus
//! direct API calls for granting / updating app memberships.
//!
//! These talk to the manager HTTP surface directly instead of going
//! through the CLI, so role-gated tests can stage state without
//! requiring `pic` to grow new commands.
use serde_json::{json, Value};
use super::cleanup::UserGuard;
use super::Fixture;
pub const MEMBER_PASSWORD: &str = "pic-cli-test-pw-12345678";
pub struct MemberUser {
pub id: String,
pub username: String,
pub token: String,
pub _guard: UserGuard,
}
/// Mint a fresh `instance_role: Member` user, log them in for a bearer
/// token, and register a `UserGuard` for teardown.
pub fn member_user(fx: &Fixture, username: &str) -> MemberUser {
let client = reqwest::blocking::Client::new();
let create = client
.post(format!("{}/api/v1/admin/admins", fx.url))
.bearer_auth(&fx.admin_token)
.json(&json!({
"username": username,
"password": MEMBER_PASSWORD,
// InstanceRole / AppRole serialize via `rename_all =
// "snake_case"` — wire forms are always lowercase.
"instance_role": "member",
}))
.send()
.expect("create member user");
assert!(
create.status().is_success(),
"create member user failed: {} {}",
create.status(),
create.text().unwrap_or_default(),
);
let body: Value = create.json().expect("admin create json");
let id = body["id"]
.as_str()
.expect("admin create returns id")
.to_string();
// Register cleanup before we attempt anything else that could fail.
let guard = UserGuard::new(&fx.url, &fx.admin_token, &id);
let token = super::server::login_for_bearer_token(&fx.url, username, MEMBER_PASSWORD);
MemberUser {
id,
username: username.to_string(),
token,
_guard: guard,
}
}
/// `POST /api/v1/admin/apps/{slug}/members` — grant `role` to `user_id`.
pub fn grant_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) {
let client = reqwest::blocking::Client::new();
let resp = client
.post(format!("{}/api/v1/admin/apps/{}/members", fx.url, app_slug))
.bearer_auth(&fx.admin_token)
.json(&json!({ "user_id": user_id, "role": role }))
.send()
.expect("grant membership");
assert!(
resp.status().is_success(),
"grant membership failed: {} {}",
resp.status(),
resp.text().unwrap_or_default(),
);
}
/// `PATCH /api/v1/admin/apps/{slug}/members/{user_id}` — promote/demote.
pub fn update_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) {
let client = reqwest::blocking::Client::new();
let resp = client
.patch(format!(
"{}/api/v1/admin/apps/{}/members/{}",
fx.url, app_slug, user_id
))
.bearer_auth(&fx.admin_token)
.json(&json!({ "role": role }))
.send()
.expect("update membership");
assert!(
resp.status().is_success(),
"update membership failed: {} {}",
resp.status(),
resp.text().unwrap_or_default(),
);
}

View File

@@ -0,0 +1,314 @@
//! Shared fixture and helpers for the CLI integration test binary.
//!
//! All tests in `tests/cli.rs` route through `fixture()`, a `LazyLock`
//! that spawns picloud on a private port the first time it's touched
//! and reuses that subprocess for every subsequent test. The dashboard
//! Playwright suite pays the same cost once for 63 tests; we do the
//! same here.
#![allow(dead_code)] // shared helpers — not every module uses every fn.
pub mod cleanup;
pub mod member;
pub mod server;
use std::path::PathBuf;
use std::process::Child;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{LazyLock, Mutex, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use assert_cmd::Command as AssertCommand;
use tempfile::TempDir;
// --------------------------------------------------------------------
// Fixture
// --------------------------------------------------------------------
pub struct Fixture {
pub url: String,
pub admin_token: String,
pub admin_username: String,
// Held in a Mutex so Drop can kill it without UB; we never re-enter.
child: Mutex<Option<Child>>,
}
impl Drop for Fixture {
fn drop(&mut self) {
if let Ok(mut guard) = self.child.lock() {
if let Some(mut child) = guard.take() {
server::kill_subprocess(&mut child);
}
}
}
}
static FIXTURE: LazyLock<Fixture> = LazyLock::new(init_fixture);
fn init_fixture() -> Fixture {
let database_url =
std::env::var("DATABASE_URL").expect("DATABASE_URL is required to spawn picloud");
let username = admin_username();
let password = admin_password();
let port = server::pick_free_port();
let url = format!("http://127.0.0.1:{port}");
let mut child = server::spawn_picloud(&database_url, port, &username, &password);
if let Err(e) = server::wait_for_health(&url, Duration::from_secs(60)) {
server::kill_subprocess(&mut child);
panic!("picloud failed to become healthy: {e}");
}
let token = server::login_for_bearer_token(&url, &username, &password);
Fixture {
url,
admin_token: token,
admin_username: username,
child: Mutex::new(Some(child)),
}
}
/// Returns the shared fixture, spawning the picloud subprocess on first
/// call. Returns `None` (and prints a skip message) when `DATABASE_URL`
/// is absent — matching the existing convention so the suite is a
/// no-op outside the integration environment.
pub fn fixture_or_skip() -> Option<&'static Fixture> {
if std::env::var("DATABASE_URL").is_err() {
eprintln!("skipping: DATABASE_URL not set");
return None;
}
Some(&FIXTURE)
}
// --------------------------------------------------------------------
// Per-test env
// --------------------------------------------------------------------
pub struct TestEnv {
pub url: String,
pub token: String,
pub config_dir: TempDir,
pub home: TempDir,
}
/// Per-test env pre-loaded with the admin token, and a credentials
/// file already on disk so non-login commands ("pic apps create", …)
/// can run without first calling `pic login`. As of the env-var
/// consistency fix, `PICLOUD_URL`/`PICLOUD_TOKEN` (set by `pic_as`)
/// also work for *every* command, not just `login` — `config::resolve`
/// reads them first and falls back to the on-disk file.
pub fn admin_env(fx: &Fixture) -> TestEnv {
let env = TestEnv {
url: fx.url.clone(),
token: fx.admin_token.clone(),
config_dir: TempDir::new().expect("config tempdir"),
home: TempDir::new().expect("home tempdir"),
};
seed_credentials(&env, &fx.admin_username);
env
}
/// Per-test env pre-loaded with a specific (URL, token) pair. Used by
/// tests that want a non-admin token, a bogus token, or an unreachable
/// URL. Does **not** seed a credentials file — call `seed_credentials`
/// explicitly when the test needs to run non-login commands.
pub fn custom_env(url: &str, token: &str) -> TestEnv {
TestEnv {
url: url.to_string(),
token: token.to_string(),
config_dir: TempDir::new().expect("config tempdir"),
home: TempDir::new().expect("home tempdir"),
}
}
/// Write a valid credentials TOML into `env.config_dir` so subsequent
/// `pic_as(&env)` invocations can issue non-login subcommands. Mirrors
/// the file shape `pic login` produces (url/token/username). Tests that
/// exercise the "no credentials" / "stale token" error paths construct
/// `TestEnv` directly to keep the config dir empty.
pub fn seed_credentials(env: &TestEnv, username: &str) {
let body = format!(
"url = \"{}\"\ntoken = \"{}\"\nusername = \"{}\"\n",
env.url, env.token, username,
);
std::fs::write(env.config_dir.path().join("credentials"), body).expect("seed credentials file");
}
/// `pic` invocation with the env wired up — credentials dir, HOME, and
/// the `PICLOUD_URL`/`PICLOUD_TOKEN` shortcut env vars.
pub fn pic_as(env: &TestEnv) -> AssertCommand {
let mut cmd = AssertCommand::cargo_bin("pic").expect("pic binary");
cmd.env("PICLOUD_URL", &env.url)
.env("PICLOUD_TOKEN", &env.token)
.env("PICLOUD_CONFIG_DIR", env.config_dir.path())
.env("HOME", env.home.path());
cmd
}
/// `pic` invocation with `PICLOUD_URL`/`PICLOUD_TOKEN` *cleared*, so the
/// command sees only the on-disk credentials file (or lack thereof).
pub fn pic_no_env(env: &TestEnv) -> AssertCommand {
let mut cmd = AssertCommand::cargo_bin("pic").expect("pic binary");
cmd.env_remove("PICLOUD_URL")
.env_remove("PICLOUD_TOKEN")
.env("PICLOUD_CONFIG_DIR", env.config_dir.path())
.env("HOME", env.home.path());
cmd
}
// --------------------------------------------------------------------
// Unique slugs / usernames
// --------------------------------------------------------------------
static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0);
pub fn unique_slug(prefix: &str) -> String {
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
let n = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("pic-cli-{prefix}-{ms}-{n:x}")
}
pub fn unique_username(prefix: &str) -> String {
// Server regex: [a-z0-9._-]{2,32}. Build out of lowercase
// alphanumerics only; "piccli" prefix keeps collisions with other
// test suites obvious. Caller's `prefix` must be ≤8 chars and
// already match the regex.
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
let n = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
let name = format!("piccli{prefix}{ms:x}{n:x}");
assert!(name.len() <= 32, "username overflow: {name}");
name
}
// --------------------------------------------------------------------
// Misc helpers
// --------------------------------------------------------------------
pub fn admin_username() -> String {
std::env::var("PICLOUD_CLI_E2E_USERNAME").unwrap_or_else(|_| "admin".to_string())
}
pub fn admin_password() -> String {
std::env::var("PICLOUD_CLI_E2E_PASSWORD").unwrap_or_else(|_| "admin".to_string())
}
pub fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(name)
}
/// Create a fresh app and deploy a `tests/fixtures/<fixture_name>` into
/// it. Returns the new script id plus the `AppGuard` that cleans the
/// app (and its scripts via `force=true`) on Drop. Used by invoke /
/// logs / output journeys that all need "deploy something, then drive
/// `pic` against it".
pub fn deploy_fixture(
env: &TestEnv,
app_label: &str,
fixture_name: &str,
) -> (String, cleanup::AppGuard) {
let slug = unique_slug(app_label);
pic_as(env)
.args(["apps", "create", &slug])
.assert()
.success();
let guard = cleanup::AppGuard::new(&env.url, &env.token, &slug);
let fixture = fixture_path(fixture_name);
pic_as(env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.success();
let out = pic_as(env)
.args(["scripts", "ls", "--app", &slug])
.output()
.expect("scripts ls");
let id = parse_first_id(std::str::from_utf8(&out.stdout).unwrap())
.expect("scripts ls should produce one row");
(id, guard)
}
/// Split a row from `pic apps ls` / `pic scripts ls` into trimmed
/// cells. The output writer space-pads each cell to its column's max
/// width before the tab, so raw `split('\t')` leaves trailing spaces;
/// this helper hides that detail from tests that only care about the
/// logical values.
pub fn cells(row: &str) -> Vec<&str> {
row.split('\t').map(str::trim).collect()
}
/// First data row's first tab-delimited cell, used to extract IDs from
/// `pic scripts ls` output. The header is expected to start with "id".
pub fn parse_first_id(table: &str) -> Option<String> {
let mut lines = table.lines().filter(|l| !l.trim().is_empty());
let header = lines.next()?;
if !header.starts_with("id") {
return None;
}
let row = lines.next()?;
let first = row.split('\t').next()?.trim();
if first.is_empty() {
None
} else {
Some(first.to_string())
}
}
// --------------------------------------------------------------------
// Fixture-sharing sanity check
// --------------------------------------------------------------------
//
// Two tests record `fixture().url` into the same `OnceLock` — if the
// fixture isn't actually shared, the second test sees a different URL
// and panics. Belt-and-suspenders: pointer identity on `&Fixture`.
static OBSERVED_URL: OnceLock<String> = OnceLock::new();
fn observe_fixture_url(label: &str) {
let Some(fx) = fixture_or_skip() else {
return;
};
let url = fx.url.clone();
match OBSERVED_URL.get() {
Some(prev) => assert_eq!(
prev, &url,
"{label} observed a different fixture URL: prior={prev} now={url}"
),
None => {
let _ = OBSERVED_URL.set(url);
}
}
// Same `&'static Fixture` from every call — proves the LazyLock is
// sharing, not respawning.
let a = fixture_or_skip().unwrap();
let b = fixture_or_skip().unwrap();
assert!(
std::ptr::eq(a, b),
"fixture_or_skip should return the same &'static Fixture"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn fixture_url_is_shared_a() {
observe_fixture_url("fixture_url_is_shared_a");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn fixture_url_is_shared_b() {
observe_fixture_url("fixture_url_is_shared_b");
}

View File

@@ -0,0 +1,166 @@
//! Picloud subprocess lifecycle for the CLI integration test binary.
//!
//! Mirrors what the original seed test did inline, lifted out so it can
//! be shared across modules via `common::fixture()`. The Fixture lives
//! in a `LazyLock` — and `LazyLock<T>` never drops, so we register an
//! `atexit` handler that SIGTERMs the child when the test binary exits
//! normally (which is the common case under `cargo test`).
//!
//! `PR_SET_PDEATHSIG` is intentionally *not* used: it fires when the
//! creating thread dies, and cargo runs each `#[test]` on its own
//! worker thread that exits as soon as the test returns — which would
//! kill picloud after the first test that triggered the LazyLock,
//! breaking every test after it.
//!
//! Abnormal exit (SIGKILL of the test binary) leaks the child; the
//! dashboard Playwright suite accepts the same tradeoff.
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{Child, Command as StdCommand, Stdio};
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use serde_json::Value;
pub fn pick_free_port() -> u16 {
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("bind 127.0.0.1:0 to pick port");
listener.local_addr().expect("local addr").port()
}
pub fn picloud_binary_path() -> PathBuf {
// The integration test binary lives at
// `<target>/debug/deps/cli-<hash>`. Walk up two levels to reach
// `<target>/debug` and look for `picloud` next to ourselves.
let exe = std::env::current_exe().expect("current_exe");
let debug_dir = exe
.parent()
.and_then(|p| p.parent())
.expect("test binary should live under target/debug/deps");
debug_dir.join(if cfg!(windows) {
"picloud.exe"
} else {
"picloud"
})
}
pub fn spawn_picloud(database_url: &str, port: u16, admin_user: &str, admin_pass: &str) -> Child {
let binary = picloud_binary_path();
assert!(
binary.exists(),
"expected picloud binary at {}. Run `cargo build -p picloud` first \
(or use `cargo test --workspace -- --include-ignored` which builds it)",
binary.display()
);
let mut child = StdCommand::new(&binary)
.env("PICLOUD_BIND", format!("127.0.0.1:{port}"))
.env("DATABASE_URL", database_url)
.env("PICLOUD_ADMIN_USERNAME", admin_user)
.env("PICLOUD_ADMIN_PASSWORD", admin_pass)
.env("RUST_LOG", "warn")
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.expect("spawn picloud");
// Drain stderr in a side thread so the pipe buffer doesn't fill and
// block the server.
if let Some(err) = child.stderr.take().map(BufReader::new) {
let (tx, _rx) = mpsc::channel::<String>();
thread::spawn(move || {
for line in err.lines().map_while(Result::ok) {
let _ = tx.send(line);
}
});
}
register_atexit_killer(child.id());
child
}
// --------------------------------------------------------------------
// atexit-based child cleanup
// --------------------------------------------------------------------
static PICLOUD_PID: AtomicI32 = AtomicI32::new(0);
fn register_atexit_killer(pid: u32) {
// First spawn wins; subsequent spawns (none expected today) would
// overwrite, but the previous child would already be tracked via
// its Drop path on the Fixture.
PICLOUD_PID.store(pid as i32, Ordering::SeqCst);
#[cfg(unix)]
{
use std::sync::Once;
static REGISTERED: Once = Once::new();
REGISTERED.call_once(|| {
// SAFETY: atexit's contract is a `extern "C" fn()` callback;
// ours signals a child PID we own.
unsafe {
libc::atexit(kill_picloud_at_exit);
}
});
}
}
#[cfg(unix)]
extern "C" fn kill_picloud_at_exit() {
let pid = PICLOUD_PID.swap(0, Ordering::SeqCst);
if pid > 0 {
// SAFETY: SIGTERM to a PID we recorded; if PID has been reused
// we're killing the wrong process — accepted risk for a test
// helper.
unsafe {
libc::kill(pid, libc::SIGTERM);
}
}
}
pub fn wait_for_health(url: &str, timeout: Duration) -> Result<(), String> {
let deadline = Instant::now() + timeout;
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.map_err(|e| e.to_string())?;
while Instant::now() < deadline {
if let Ok(resp) = client.get(format!("{url}/healthz")).send() {
if resp.status().is_success() {
return Ok(());
}
}
thread::sleep(Duration::from_millis(250));
}
Err(format!("/healthz never returned 200 within {timeout:?}"))
}
pub fn login_for_bearer_token(url: &str, username: &str, password: &str) -> String {
let client = reqwest::blocking::Client::new();
let resp = client
.post(format!("{url}/api/v1/admin/auth/login"))
.json(&serde_json::json!({
"username": username,
"password": password,
}))
.send()
.expect("login request");
assert!(
resp.status().is_success(),
"login should succeed, got {}: {}",
resp.status(),
resp.text().unwrap_or_default()
);
let v: Value = resp.json().expect("login json");
v["token"]
.as_str()
.expect("login returns token")
.to_string()
}
pub fn kill_subprocess(child: &mut Child) {
let _ = child.kill();
let _ = child.wait();
}

View File

@@ -0,0 +1,7 @@
// Returns a structured 500. The execution is still `Success` in the
// log because the script ran cleanly — for an `Error`-status log entry
// use throw.rhai instead.
#{
statusCode: 500,
body: #{ ok: false, why: "intentional" },
}

View File

@@ -0,0 +1,6 @@
// Echoes the request body and headers back so invoke tests can verify
// that `--body` (inline / @file / @-) and `-H` flow through end-to-end.
#{
body: ctx.request.body,
headers: ctx.request.headers,
}

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

@@ -0,0 +1,5 @@
// Logs a long line so the logs-truncation test has something to chew on.
// `pic logs` truncates the summary cell to 120 characters; this line is
// 240 chars after the prefix so the truncation is unambiguous.
log::info("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
#{ ok: true }

View File

@@ -0,0 +1,4 @@
// Throws a Rhai runtime error. The orchestrator records this as
// `ExecutionStatus::Error` in the execution log (a structured 5xx
// response is recorded as `Success`).
throw "boom";

View File

@@ -0,0 +1,171 @@
//! `pic scripts invoke` — body sources (inline, `@file`, `@-`), header
//! propagation, exit-code semantics for non-2xx responses, and 404
//! handling for unknown ids.
use predicates::prelude::*;
use serde_json::Value;
use crate::common;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn invoke_with_inline_json_body_echoes() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "invoke-inline", "echo.rhai");
let out = common::pic_as(&env)
.args(["scripts", "invoke", &id, "--body", r#"{"x":1}"#])
.output()
.expect("invoke");
assert!(out.status.success(), "invoke failed: {out:?}");
let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
assert_eq!(parsed["body"]["x"], 1, "echoed body: {parsed}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn invoke_with_file_body() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "invoke-file", "echo.rhai");
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
std::fs::write(tmp.path(), r#"{"src":"file"}"#).unwrap();
let body_arg = format!("@{}", tmp.path().display());
let out = common::pic_as(&env)
.args(["scripts", "invoke", &id, "--body", &body_arg])
.output()
.expect("invoke");
assert!(out.status.success(), "invoke failed: {out:?}");
let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
assert_eq!(parsed["body"]["src"], "file", "echoed body: {parsed}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn invoke_with_stdin_body() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "invoke-stdin", "echo.rhai");
let assert = common::pic_as(&env)
.args(["scripts", "invoke", &id, "--body", "@-"])
.write_stdin(r#"{"src":"stdin"}"#)
.assert()
.success();
let out = assert.get_output();
let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
assert_eq!(parsed["body"]["src"], "stdin", "echoed body: {parsed}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn invoke_propagates_headers() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "invoke-hdr", "echo.rhai");
let out = common::pic_as(&env)
.args([
"scripts",
"invoke",
&id,
"-H",
"X-Foo: bar",
"-H",
"X-Baz=qux",
])
.output()
.expect("invoke");
assert!(out.status.success(), "invoke failed: {out:?}");
let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
// HTTP normalises header names to lowercase.
assert_eq!(parsed["headers"]["x-foo"], "bar", "echoed: {parsed}");
assert_eq!(parsed["headers"]["x-baz"], "qux", "echoed: {parsed}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn invoke_unknown_script_id_errors() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
// Any well-formed UUID that doesn't exist server-side. The
// orchestrator's `/execute/{id}` handler returns 404 specifically
// for unknown ids — tighten the predicate so a regressed 401
// wouldn't sneak through.
let bogus = "00000000-0000-0000-0000-000000000000";
common::pic_as(&env)
.args(["scripts", "invoke", bogus])
.assert()
.failure()
.stderr(predicate::str::contains("HTTP 404"));
}
/// `pic invoke <id>` (top-level alias) and `pic scripts invoke <id>`
/// must hit the same handler and produce identical-shape stdout.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn top_level_invoke_alias_works() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "inv-alias", "hello.rhai");
let nested = common::pic_as(&env)
.args(["scripts", "invoke", &id])
.output()
.expect("scripts invoke");
assert!(nested.status.success());
let nested_body: Value = serde_json::from_slice(&nested.stdout).unwrap();
let aliased = common::pic_as(&env)
.args(["invoke", &id])
.output()
.expect("invoke (top-level)");
assert!(aliased.status.success());
let aliased_body: Value = serde_json::from_slice(&aliased.stdout).unwrap();
assert_eq!(
nested_body, aliased_body,
"top-level alias should produce identical body to scripts invoke"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn invoke_non_2xx_exits_nonzero_but_prints_body() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "invoke-500", "boom.rhai");
let out = common::pic_as(&env)
.args(["scripts", "invoke", &id])
.output()
.expect("invoke");
assert!(!out.status.success(), "expected non-zero exit: {out:?}");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("<- HTTP 500"),
"stderr should report HTTP 500: {stderr}"
);
let parsed: Value = serde_json::from_slice(&out.stdout)
.unwrap_or_else(|e| panic!("stdout was not JSON ({e}): {:?}", out.stdout));
assert_eq!(parsed["ok"], false, "boom body: {parsed}");
assert_eq!(parsed["why"], "intentional", "boom body: {parsed}");
}

View File

@@ -0,0 +1,179 @@
//! `pic logs <script-id>` — emptiness, status labels, `--limit`
//! clamping, error path for unknown ids, and the 120-char truncate
//! applied to the summary column.
use predicates::prelude::*;
use crate::common;
/// Pick out the data rows from `pic logs` TSV output — the header line
/// (`created_at\tstatus\tsummary`) is now always present, so the old
/// "no non-empty lines means no logs" check needs to skip it.
fn data_rows(stdout: &str) -> Vec<&str> {
stdout
.lines()
.filter(|l| !l.trim().is_empty())
.filter(|l| !l.starts_with("created_at"))
.collect()
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logs_for_fresh_script_is_empty() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "logs-empty", "hello.rhai");
let out = common::pic_as(&env)
.args(["logs", &id])
.output()
.expect("logs");
assert!(out.status.success(), "logs failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
data_rows(&stdout).is_empty(),
"expected no log rows (header is allowed), got: {stdout}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logs_after_invoke_records_success_row() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "logs-ok", "hello.rhai");
common::pic_as(&env)
.args(["scripts", "invoke", &id])
.assert()
.success();
let out = common::pic_as(&env)
.args(["logs", &id])
.output()
.expect("logs");
assert!(out.status.success(), "logs failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let rows = data_rows(&stdout);
assert_eq!(rows.len(), 1, "expected 1 data row, got: {stdout}");
let cols: Vec<&str> = rows[0].split('\t').map(str::trim).collect();
assert_eq!(
cols.len(),
3,
"row should be 3 tab-delimited cells: {rows:?}"
);
assert_eq!(cols[1], "success");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logs_records_error_for_throwing_script() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "logs-err", "throw.rhai");
// The invoke is expected to fail — we only care that the execution
// gets recorded with `Error` status.
let _ = common::pic_as(&env)
.args(["scripts", "invoke", &id])
.output();
let out = common::pic_as(&env)
.args(["logs", &id])
.output()
.expect("logs");
assert!(out.status.success(), "logs failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let row = data_rows(&stdout)
.into_iter()
.next()
.expect("at least one data row");
let cols: Vec<&str> = row.split('\t').map(str::trim).collect();
assert_eq!(cols[1], "error", "expected error status, got row: {row}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logs_respects_limit_flag() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "logs-limit", "hello.rhai");
for _ in 0..3 {
common::pic_as(&env)
.args(["scripts", "invoke", &id])
.assert()
.success();
}
let out = common::pic_as(&env)
.args(["logs", &id, "--limit", "1"])
.output()
.expect("logs");
assert!(out.status.success(), "logs failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let rows = data_rows(&stdout).len();
assert_eq!(rows, 1, "expected --limit 1, got rows: {stdout}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logs_for_unknown_id_errors() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let bogus = "00000000-0000-0000-0000-000000000000";
common::pic_as(&env)
.args(["logs", bogus])
.assert()
.failure()
// 404 specifically — same `NotFound(ScriptId)` path the get/edit
// endpoints use.
.stderr(predicate::str::contains("HTTP 404"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logs_truncates_long_summary() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "logs-loud", "loud.rhai");
common::pic_as(&env)
.args(["scripts", "invoke", &id])
.assert()
.success();
let out = common::pic_as(&env)
.args(["logs", &id])
.output()
.expect("logs");
assert!(out.status.success(), "logs failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let row = data_rows(&stdout)
.into_iter()
.next()
.expect("at least one data row");
let summary = row.split('\t').nth(2).expect("summary column");
assert!(
summary.ends_with('…'),
"summary should be truncated with `…`, got: {summary}"
);
let chars = summary.chars().count();
assert!(
chars <= 121,
"summary should be ≤120 chars + the truncation marker, got {chars}: {summary}"
);
}

View File

@@ -0,0 +1,289 @@
//! Output-shape invariants — the contracts downstream `jq`/`awk`
//! pipelines depend on: column headers, stdout-vs-stderr separation,
//! and RFC3339 timestamps.
use serde_json::Value;
use crate::common;
use crate::common::cleanup::AppGuard;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn apps_ls_header_columns() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let out = common::pic_as(&env)
.args(["apps", "ls"])
.output()
.expect("apps ls");
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
let header = stdout.lines().next().expect("header row");
assert_eq!(
common::cells(header),
vec!["slug", "name", "my_role", "created_at"]
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn scripts_ls_header_columns() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("out-ls");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let out = common::pic_as(&env)
.args(["scripts", "ls", "--app", &slug])
.output()
.expect("scripts ls");
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
let header = stdout.lines().next().expect("header row");
assert_eq!(
common::cells(header),
vec!["id", "app_slug", "name", "version", "updated_at"]
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn invoke_separates_stdout_and_stderr() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "out-inv", "hello.rhai");
let out = common::pic_as(&env)
.args(["scripts", "invoke", &id])
.output()
.expect("invoke");
assert!(out.status.success());
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.starts_with("<- HTTP 200"),
"stderr should announce HTTP status: {stderr:?}"
);
let parsed: Value = serde_json::from_slice(&out.stdout)
.expect("stdout should be JSON only, with no status prefix");
assert_eq!(parsed["ok"], true, "body: {parsed}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn error_goes_to_stderr_not_stdout() {
let Some(_fx) = common::fixture_or_skip() else {
return;
};
// Use a pristine env (no credentials file) so `whoami` is guaranteed
// to fail at the `config::load` step — `admin_env` would pre-seed
// creds and the command would succeed.
let env = common::TestEnv {
url: String::new(),
token: String::new(),
config_dir: tempfile::TempDir::new().unwrap(),
home: tempfile::TempDir::new().unwrap(),
};
let out = common::pic_no_env(&env)
.args(["whoami"])
.output()
.expect("whoami");
assert!(!out.status.success(), "expected failure, got: {out:?}");
assert!(
out.stdout.is_empty(),
"stdout should be empty on error, got: {:?}",
String::from_utf8_lossy(&out.stdout),
);
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("error:"),
"stderr should be prefixed with `error:`: {stderr}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn apps_ls_created_at_is_rfc3339() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("out-date");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let out = common::pic_as(&env)
.args(["apps", "ls"])
.output()
.expect("apps ls");
let stdout = String::from_utf8(out.stdout).unwrap();
let row = stdout
.lines()
.map(common::cells)
.find(|c| c.first().copied() == Some(slug.as_str()))
.unwrap_or_else(|| panic!("slug {slug} missing in: {stdout}"));
let created_at = row.get(3).expect("created_at cell");
// Accept the RFC3339 shape without pulling in chrono — `YYYY-MM-DDTHH:MM:SS`
// with optional fraction + timezone is enough of a contract for the test.
assert!(
created_at.len() >= 20
&& created_at.as_bytes()[4] == b'-'
&& created_at.as_bytes()[7] == b'-'
&& created_at.as_bytes()[10] == b'T'
&& created_at.as_bytes()[13] == b':'
&& created_at.as_bytes()[16] == b':',
"created_at not RFC3339-shaped: {created_at}"
);
}
/// `--output json` is the global pipeline-friendly format. Validates
/// `apps ls` returns a real JSON array (not a TSV-with-quotes hack).
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn apps_ls_json_output_is_valid_array() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("out-json-apps");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let out = common::pic_as(&env)
.args(["--output", "json", "apps", "ls"])
.output()
.expect("apps ls --output json");
assert!(out.status.success(), "apps ls failed: {out:?}");
let v: Value = serde_json::from_slice(&out.stdout).expect("stdout should be JSON");
let arr = v.as_array().expect("apps ls JSON should be an array");
assert!(
arr.iter()
.any(|row| row.get("slug").and_then(Value::as_str) == Some(slug.as_str())),
"json should include created slug: {v}"
);
// The header row must NOT bleed into JSON output — the rendered
// objects use header *keys*, not data cells.
assert!(
arr.iter().all(|row| row.get("slug").is_some()),
"every row should have a `slug` key: {v}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn scripts_ls_json_output_has_app_slug() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("out-json-scr");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let fixture = common::fixture_path("hello.rhai");
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.success();
let out = common::pic_as(&env)
.args(["--output", "json", "scripts", "ls", "--app", &slug])
.output()
.expect("scripts ls --output json");
assert!(out.status.success());
let v: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
let arr = v.as_array().expect("array");
let row = arr
.iter()
.find(|r| r.get("name").and_then(Value::as_str) == Some("hello"))
.expect("hello row");
assert_eq!(row["app_slug"].as_str(), Some(slug.as_str()));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logs_json_output_is_array_of_objects() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "out-json-log", "hello.rhai");
common::pic_as(&env)
.args(["scripts", "invoke", &id])
.assert()
.success();
let out = common::pic_as(&env)
.args(["--output", "json", "logs", &id])
.output()
.expect("logs --output json");
assert!(out.status.success());
let v: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
let arr = v.as_array().expect("array");
assert!(!arr.is_empty(), "expected at least one log");
// Schema: each row carries the raw `ExecutionLog`, not the
// truncated summary the TSV form uses.
assert!(
arr[0].get("status").is_some(),
"log row missing status: {arr:?}"
);
}
/// TSV `whoami` used to be a single tab-separated line with no labels;
/// downstream tools couldn't tell which column was the role. Now it's
/// a key/value block with stable labels.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn whoami_tsv_has_labeled_rows() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let out = common::pic_as(&env)
.args(["whoami"])
.output()
.expect("whoami");
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
let labels: Vec<&str> = stdout
.lines()
.filter_map(|l| l.split('\t').next())
.map(str::trim)
.collect();
assert!(
labels.contains(&"username"),
"missing username row: {stdout}"
);
assert!(labels.contains(&"role"), "missing role row: {stdout}");
assert!(labels.contains(&"email"), "missing email row: {stdout}");
assert!(labels.contains(&"url"), "missing url row: {stdout}");
}

View File

@@ -0,0 +1,146 @@
//! RBAC mirror of the dashboard's role-shadowing specs. A Member user
//! is minted via the admin API, granted (or denied) membership on an
//! app, then `pic` is driven against the member's bearer token to
//! confirm the server's capability gates surface as expected exit
//! codes / error messages.
use predicates::prelude::*;
use crate::common;
use crate::common::cleanup::AppGuard;
use crate::common::member;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn member_apps_ls_only_shows_their_apps() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let admin_env = common::admin_env(fx);
let slug_visible = common::unique_slug("roles-visible");
let slug_hidden = common::unique_slug("roles-hidden");
common::pic_as(&admin_env)
.args(["apps", "create", &slug_visible])
.assert()
.success();
let _g1 = AppGuard::new(&admin_env.url, &admin_env.token, &slug_visible);
common::pic_as(&admin_env)
.args(["apps", "create", &slug_hidden])
.assert()
.success();
let _g2 = AppGuard::new(&admin_env.url, &admin_env.token, &slug_hidden);
let m = member::member_user(fx, &common::unique_username("rls"));
member::grant_membership(fx, &slug_visible, &m.id, "viewer");
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
let out = common::pic_as(&member_env)
.args(["apps", "ls"])
.output()
.expect("apps ls");
assert!(out.status.success(), "apps ls failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains(&slug_visible),
"member should see {slug_visible}, got: {stdout}"
);
assert!(
!stdout.contains(&slug_hidden),
"member should NOT see {slug_hidden}, got: {stdout}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn viewer_cannot_deploy_but_editor_can() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let admin_env = common::admin_env(fx);
let slug = common::unique_slug("roles-write");
common::pic_as(&admin_env)
.args(["apps", "create", &slug])
.assert()
.success();
let _g = AppGuard::new(&admin_env.url, &admin_env.token, &slug);
let m = member::member_user(fx, &common::unique_username("vw"));
member::grant_membership(fx, &slug, &m.id, "viewer");
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
let fixture = common::fixture_path("hello.rhai");
common::pic_as(&member_env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.failure()
// `Forbidden` → 403. A regressed predicate of `"HTTP 4"` would
// have masked an auth break (401) as an authz issue.
.stderr(predicate::str::contains("HTTP 403"));
// Promote to Editor and retry — the same command should now succeed.
member::update_membership(fx, &slug, &m.id, "editor");
common::pic_as(&member_env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.success()
.stdout(predicate::str::contains("Created hello v1"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn member_can_invoke_any_script_with_id() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
// `/api/v1/execute/{id}` is the unguarded data-plane ingress — even
// a member with no app membership can hit it as long as they hold
// a valid token (the orchestrator doesn't gate it).
let admin_env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&admin_env, "roles-inv", "hello.rhai");
let m = member::member_user(fx, &common::unique_username("inv"));
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
common::pic_as(&member_env)
.args(["scripts", "invoke", &id])
.assert()
.success();
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn non_member_cannot_read_logs() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let admin_env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&admin_env, "roles-log", "hello.rhai");
let m = member::member_user(fx, &common::unique_username("rl"));
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
common::pic_as(&member_env)
.args(["logs", &id])
.assert()
.failure()
// Non-member → 403 from the authz layer, not 404 — the script
// exists; the caller just can't see it.
.stderr(predicate::str::contains("HTTP 403"));
}

View File

@@ -0,0 +1,240 @@
//! `pic scripts deploy` / `pic scripts ls` edge cases beyond the
//! smoke test: unknown app, name override, version bumping, missing
//! file, and the no-`--app` walk across every accessible app.
use predicates::prelude::*;
use crate::common;
use crate::common::cleanup::AppGuard;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn deploy_against_unknown_app_errors() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let fixture = common::fixture_path("hello.rhai");
let bogus_slug = common::unique_slug("nope");
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&bogus_slug,
])
.assert()
.failure()
// Specifically 404 — `apps_get` short-circuits before the deploy
// request even starts. Loose `"HTTP 4"` would have matched a
// regressed 401 from broken auth.
.stderr(predicate::str::contains("HTTP 404"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn deploy_with_name_override() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("scripts-named");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let fixture = common::fixture_path("hello.rhai");
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
"--name",
"custom-name",
])
.assert()
.success()
.stdout(predicate::str::contains("Created custom-name v1"));
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
"--name",
"custom-name",
])
.assert()
.success()
.stdout(predicate::str::contains("Updated custom-name v2"));
let out = common::pic_as(&env)
.args(["scripts", "ls", "--app", &slug])
.output()
.expect("scripts ls");
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout
.lines()
.map(common::cells)
.any(|c| c.get(2).copied() == Some("custom-name") && c.get(3).copied() == Some("2")),
"expected custom-name v2 row, got: {stdout}",
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn deploy_bumps_version_each_redeploy() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("scripts-bump");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let fixture = common::fixture_path("hello.rhai");
for expected in ["Created hello v1", "Updated hello v2", "Updated hello v3"] {
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.success()
.stdout(predicate::str::contains(expected));
}
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn deploy_missing_file_errors() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("scripts-missing");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let missing = std::env::temp_dir().join(common::unique_slug("ghost") + ".rhai");
common::pic_as(&env)
.args([
"scripts",
"deploy",
missing.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.failure()
.stderr(predicate::str::contains("reading"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn ls_without_app_walks_every_accessible_app() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug_a = common::unique_slug("scripts-walk-a");
let slug_b = common::unique_slug("scripts-walk-b");
common::pic_as(&env)
.args(["apps", "create", &slug_a])
.assert()
.success();
let _guard_a = AppGuard::new(&env.url, &env.token, &slug_a);
common::pic_as(&env)
.args(["apps", "create", &slug_b])
.assert()
.success();
let _guard_b = AppGuard::new(&env.url, &env.token, &slug_b);
let fixture = common::fixture_path("hello.rhai");
for slug in [&slug_a, &slug_b] {
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
slug,
])
.assert()
.success();
}
// `pic scripts ls` (no `--app`) issues a single `GET /admin/scripts`
// against the server now — there's nothing per-app to race against
// a concurrent AppGuard drop. The previous implementation walked
// `apps_list` followed by per-app `scripts_list_by_app` calls and
// aborted on the first 404, which forced this test to retry 5× to
// paper over the bug. Both the walk and the retry are gone.
let out = common::pic_as(&env)
.args(["scripts", "ls"])
.output()
.expect("scripts ls");
assert!(out.status.success(), "scripts ls failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let slugs: std::collections::HashSet<&str> = stdout
.lines()
.map(common::cells)
.filter_map(|c| c.get(1).copied())
.collect();
assert!(
slugs.contains(slug_a.as_str()),
"missing app A in: {stdout}"
);
assert!(
slugs.contains(slug_b.as_str()),
"missing app B in: {stdout}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn delete_removes_script_from_ls() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "scripts-del", "hello.rhai");
common::pic_as(&env)
.args(["scripts", "delete", &id])
.assert()
.success()
.stdout(predicate::str::contains(format!("Deleted script {id}")));
let out = common::pic_as(&env)
.args(["scripts", "ls"])
.output()
.expect("scripts ls");
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
!stdout.contains(&id),
"deleted script id should not appear in ls: {stdout}"
);
}

View File

@@ -11,21 +11,22 @@ use axum::{routing::get, Json, Router};
use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{
admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
auth_router, compile_routes, migrations, require_authenticated, route_admin_router,
AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, ApiKeyRepository,
ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository,
AppsState, AuthState, AuthzRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink,
PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState,
RouteRepository, SandboxCeiling,
attach_principal_if_present, auth_router, compile_routes, migrations, require_authenticated,
route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState,
AppRepository, AppsState, AuthState, AuthzRepo, PostgresAdminSessionRepository,
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
PostgresAppMembersRepository, PostgresAppRepository, PostgresExecutionLogRepository,
PostgresExecutionLogSink, PostgresRouteRepository, PostgresScriptRepository, RepoResolver,
RouteAdminState, RouteRepository, SandboxCeiling,
};
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
use picloud_orchestrator_core::{
data_plane_router, user_routes_router, DataPlaneState, LocalExecutorClient,
data_plane_router, user_routes_router, DataPlaneState, ExecutionGate, LocalExecutorClient,
};
use picloud_shared::{
ExecutionLogSink, ScriptValidator, API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION,
ExecutionLogSink, ScriptValidator, Services, API_VERSION, PRODUCT_VERSION, SDK_VERSION,
WIRE_VERSION,
};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
@@ -82,7 +83,9 @@ fn read_session_ttl() -> Duration {
/// `/version`) stays open — it's the public ingress for user scripts.
#[allow(clippy::too_many_lines)]
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
let engine = Arc::new(Engine::new(Limits::default()));
// `Services` is the SDK service bundle. Empty in v1.1.0; the
// v1.1.1 KV PR will populate it with `kv: Arc::new(...)` here.
let engine = Arc::new(Engine::new(Limits::default(), Services::new()));
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
@@ -126,7 +129,10 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
script_repo.clone(),
)));
let executor = Arc::new(LocalExecutorClient::new(engine.clone()));
// Single global gate — overflow is rejected with 503 + Retry-After.
// See `ExecutionGate` docs and `PICLOUD_MAX_CONCURRENT_EXECUTIONS`.
let gate = Arc::new(ExecutionGate::from_env());
let executor = Arc::new(LocalExecutorClient::new(engine.clone(), gate));
let admin = AdminState {
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
@@ -200,16 +206,31 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
// facade above; the bare module path is retained so it's discoverable.
let _ = apps_api::AppsState::clone;
// Opportunistic principal extraction on every data-plane request.
// Always inserts `Extension<Option<Principal>>`: Some for authed
// ingress (bearer / cookie), None otherwise. Handlers depend on
// this layer being applied — scoped to the data-plane routers so
// the admin path (which uses `require_authenticated`) doesn't
// double-resolve the same token.
let data_plane_routed = data_plane_router(data_plane.clone()).layer(from_fn_with_state(
auth_state.clone(),
attach_principal_if_present,
));
let user_routes = user_routes_router(data_plane).layer(from_fn_with_state(
auth_state.clone(),
attach_principal_if_present,
));
let api_v1 = Router::new()
.nest("/admin", auth_router(auth_state))
.nest("/admin", guarded_admin)
.merge(data_plane_router(data_plane.clone()));
.merge(data_plane_routed);
Ok(Router::new()
.route("/healthz", get(healthz))
.route("/version", get(version))
.nest(&format!("/api/v{API_VERSION}"), api_v1)
.merge(user_routes_router(data_plane))
.merge(user_routes)
.layer(TraceLayer::new_for_http()))
}

View File

@@ -293,7 +293,7 @@ async fn owner_access_matrix(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[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;
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).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
.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
.get("/api/v1/admin/apps/default")
.add_header("authorization", format!("Bearer {token}"))
.await
.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;
assert!(script["id"].is_string());
// Denied: delete the default app (AppAdmin only).
let denied = s
.server
.delete("/api/v1/admin/apps/default")
// Allowed: list app members (AppAdmin gate). Pre-3.5.x this
// 403'd; now it's the same allow as the owner sees.
s.server
.get("/api/v1/admin/apps/default/members")
.add_header("authorization", format!("Bearer {token}"))
.await;
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
.await
.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"]
@@ -735,7 +745,7 @@ async fn my_role_field_matches_caller_role(pool: PgPool) {
"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;
let admin_token = login_token(&s.server, "alice", "alice-pw").await;
let r = s
@@ -746,8 +756,8 @@ async fn my_role_field_matches_caller_role(pool: PgPool) {
r.assert_status_ok();
assert_eq!(
r.json::<Value>()["my_role"].as_str(),
Some("editor"),
"admin reports editor"
Some("app_admin"),
"admin reports app_admin"
);
// Member with explicit `viewer` membership → viewer.

119
crates/shared/src/events.rs Normal file
View File

@@ -0,0 +1,119 @@
//! `ServiceEventEmitter` — the contract every stateful SDK service uses
//! to publish events into the (future) triggers framework.
//!
//! v1.1.0 ships only the trait shape and a `NoopEventEmitter` that
//! drops every event. The real outbox-backed implementation lands with
//! the triggers PR in v1.1.1; locking the trait now means services
//! written in subsequent v1.1.x PRs (KV, docs, files, …) don't have to
//! re-thread their plumbing when the dispatcher arrives.
//!
//! Design rationale (full discussion: `docs/sdk-shape.md`):
//! * Async — outbox writes hit Postgres.
//! * Cx is passed in so the emitter can attribute the event to the
//! `app_id` / `principal` / `execution_id` that produced it.
//! * Events carry their semantic identity (`source` + `op`) plus
//! optional locator (`collection` + `key`) and optional payloads
//! (`payload` for the new value, `old_payload` for the previous on
//! updates). The dispatcher matches on (source, op, collection)
//! filters to decide which scripts to fan out to.
use async_trait::async_trait;
use thiserror::Error;
use crate::SdkCallCx;
/// Trait every stateful service depends on to emit events. The host
/// binary constructs one instance and clones the Arc into each service.
#[async_trait]
pub trait ServiceEventEmitter: Send + Sync {
/// Publish a single event. Implementations are expected to be
/// fire-and-forget from the caller's perspective: the outbox impl
/// will return `Ok(())` once the event is durably persisted, the
/// dispatcher reads it out-of-band.
async fn emit(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError>;
}
/// One service event. `source` and `op` are `&'static str` because they
/// come from a fixed enumeration baked into each service (`"kv"` +
/// `"insert"`/`"update"`/`"delete"`, etc.) — never from user data.
/// `collection`/`key`/payloads come from user data and are owned.
#[derive(Debug, Clone)]
pub struct ServiceEvent {
/// Service namespace. Matches the Rhai module name: `"kv"`,
/// `"docs"`, `"files"`, etc.
pub source: &'static str,
/// Operation verb. Each service defines its own vocabulary;
/// dispatcher filters match on the literal string.
pub op: &'static str,
/// Affected collection, when the service is collection-scoped
/// (`kv`, `docs`, `files`). `None` for collection-less events.
pub collection: Option<String>,
/// Affected key/id within the collection, when applicable.
pub key: Option<String>,
/// New value after the operation, when carrying it is cheap and
/// useful. `None` for deletes.
pub payload: Option<serde_json::Value>,
/// Previous value before the operation, populated on `update` /
/// `delete` so triggers can diff. `None` on `insert`.
pub old_payload: Option<serde_json::Value>,
}
/// Errors an emitter can surface upward. The noop impl never returns
/// these; the v1.1.1 outbox impl uses `Unavailable` for pool/connection
/// failures and `Rejected` for malformed payloads (e.g. event JSON too
/// large for the outbox row).
#[derive(Debug, Error)]
pub enum EmitError {
#[error("event sink unavailable: {0}")]
Unavailable(String),
#[error("event sink rejected event: {0}")]
Rejected(String),
}
/// Default emitter for v1.1.0. Accepts every event, persists nothing,
/// always returns `Ok(())`. Wired in the picloud binary; the v1.1.1
/// triggers PR swaps this for a Postgres outbox writer.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopEventEmitter;
#[async_trait]
impl ServiceEventEmitter for NoopEventEmitter {
async fn emit(&self, _cx: &SdkCallCx, _event: ServiceEvent) -> Result<(), EmitError> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
// Compile-time check that ServiceEventEmitter is dyn-safe — every
// service holds it as `Arc<dyn ServiceEventEmitter>` and would
// silently break the workspace if a non-object-safe method snuck
// in. Behavioural tests for the noop impl come for free once a
// service exercises it (v1.1.1+); avoid pulling tokio into
// `picloud-shared` just for a one-line `emit().await` check.
#[allow(dead_code)]
fn assert_dyn_compatible(_e: &dyn ServiceEventEmitter) {}
#[test]
fn service_event_construction_is_explicit() {
// Pin the field layout so a re-ordering in a future PR causes a
// compile failure here rather than silently misattributing
// events. Hash-derive isn't appropriate (serde_json::Value isn't
// Hash), so structural construction is the assertion.
let _ = ServiceEvent {
source: "kv",
op: "insert",
collection: Some("widgets".into()),
key: Some("k1".into()),
payload: Some(serde_json::json!({"v": 1})),
old_payload: None,
};
}
}

View File

@@ -7,23 +7,29 @@
pub mod app;
pub mod auth;
pub mod error;
pub mod events;
pub mod execution_log;
pub mod ids;
pub mod log_sink;
pub mod route;
pub mod sandbox;
pub mod script;
pub mod sdk_cx;
pub mod services;
pub mod validator;
pub mod version;
pub use app::{App, AppDomain, DomainShape};
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
pub use error::Error;
pub use events::{EmitError, NoopEventEmitter, ServiceEvent, ServiceEventEmitter};
pub use execution_log::{ExecutionLog, ExecutionStatus};
pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId};
pub use log_sink::{ExecutionLogSink, LogSinkError};
pub use route::{HostKind, PathKind, Route};
pub use sandbox::ScriptSandbox;
pub use script::Script;
pub use sdk_cx::SdkCallCx;
pub use services::Services;
pub use validator::{ScriptValidator, ValidationError};
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};

View File

@@ -0,0 +1,54 @@
//! `SdkCallCx` — per-call context every stateful SDK service receives.
//!
//! Service trait methods (added by subsequent v1.1.x PRs starting with
//! KV) all take `&SdkCallCx` so they can:
//! * scope by `app_id` for cross-app isolation,
//! * audit `principal` when authenticated,
//! * carry `execution_id` / `request_id` into emitted events,
//! * bound trigger chains via `trigger_depth` / `root_execution_id`.
//!
//! The struct lives in `picloud-shared` (not `executor-core`) because
//! future service impls live in `manager-core` and the trait that hands
//! the cx in is shared by both sides. Pure value type — no handles, no
//! DB pool references, no allocations beyond what's in `Principal`.
use crate::{AppId, ExecutionId, Principal, RequestId};
/// Per-invocation context for every stateful SDK service call.
///
/// Constructed once at the start of an invocation by `executor-core`
/// from the incoming `ExecRequest`, then handed (by reference) to every
/// service trait method the script triggers during execution. Services
/// MUST derive `app_id` from this struct — never from script-passed
/// arguments — to preserve cross-app isolation.
#[derive(Debug, Clone)]
pub struct SdkCallCx {
/// Owning application for this invocation. Source of truth for
/// every `(app_id, …)` storage lookup the script makes.
pub app_id: AppId,
/// Caller identity, when authenticated. `None` for unauthenticated
/// data-plane HTTP requests (the common case for public endpoints);
/// `Some` when the call came in via the dashboard, an API key, or a
/// future authed surface.
pub principal: Option<Principal>,
/// Unique id for THIS execution. Matches `ExecRequest.execution_id`.
pub execution_id: ExecutionId,
/// Unique id for the ingress request that started the chain. The
/// same `request_id` is shared across every execution triggered by
/// the same request (direct + trigger fan-out).
pub request_id: RequestId,
/// `0` for direct invocations (HTTP request, manual run). Each
/// indirect invocation through the triggers framework (v1.1.1)
/// increments this; the dispatcher rejects beyond a configured
/// ceiling to prevent runaway feedback loops.
pub trigger_depth: u32,
/// `== execution_id` when `trigger_depth == 0`; otherwise the
/// `execution_id` of the original ingress execution. Lets the audit
/// log group every fan-out execution under the originating event.
pub root_execution_id: ExecutionId,
}

View File

@@ -0,0 +1,38 @@
//! `Services` — bundle of stateful SDK service handles plumbed from the
//! host binary into every Rhai execution.
//!
//! v1.1.0 ships this struct empty. Subsequent PRs in the v1.1.x series
//! add one field per service:
//!
//! ```ignore
//! pub kv: Arc<dyn KvService>, // v1.1.1
//! pub docs: Arc<dyn DocsService>, // v1.1.2
//! pub http: Arc<dyn HttpService>, // v1.1.4
//! // …
//! ```
//!
//! The bundle is cheap to clone (`Arc` per service) and is constructed
//! once at startup in the picloud binary. The executor takes it by
//! reference per invocation, hands it (alongside an `SdkCallCx`) to
//! `executor-core::sdk::register_all`, which wires the corresponding
//! Rhai `::` namespace per service.
//!
//! `#[non_exhaustive]` so adding fields is a non-breaking change for
//! consumers that only *pattern-match* a `&Services`; only crates that
//! *construct* a `Services` (in practice, just the picloud binary) need
//! to update their constructor when new services land.
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
/// expansion plan.
#[non_exhaustive]
#[derive(Default)]
pub struct Services {}
impl Services {
/// Construct an empty bundle. Replaced by a fielded `::new(...)`
/// once the first service (KV, v1.1.1) lands.
#[must_use]
pub fn new() -> Self {
Self {}
}
}

View File

@@ -2,3 +2,9 @@
build
node_modules
package-lock.json
# Playwright generated artifacts
playwright-report
test-results
tests/e2e/.auth
tests/e2e/.results

View File

@@ -20,6 +20,7 @@
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@playwright/test": "^1.60.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.17.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
@@ -885,6 +886,22 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -3010,6 +3027,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",

View File

@@ -11,10 +11,14 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test": "vitest run"
"test": "vitest run",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:install": "playwright install --with-deps chromium"
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@playwright/test": "^1.60.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.17.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",

View File

@@ -0,0 +1,51 @@
import { defineConfig, devices } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DASHBOARD_PORT = Number(process.env.PICLOUD_DASHBOARD_PORT ?? 5173);
// baseURL is the origin only — the SvelteKit dashboard is mounted at
// `/admin` (svelte.config.js paths.base), so tests use full paths like
// `/admin/login` rather than relying on baseURL path resolution.
const DASHBOARD_BASE = process.env.E2E_BASE_URL ?? `http://localhost:${DASHBOARD_PORT}`;
export default defineConfig({
testDir: './tests/e2e',
outputDir: './tests/e2e/.results',
fullyParallel: true,
forbidOnly: !!process.env.CI,
// Local: 1 retry to absorb dev-server warmup flakiness. CI: 2.
retries: process.env.CI ? 2 : 1,
// Cap at 4 workers locally to keep the shared Vite dev server
// from getting stampeded during cold-start compiles.
workers: process.env.CI ? 2 : 4,
reporter: process.env.CI ? [['html'], ['github']] : 'html',
globalSetup: './tests/e2e/global-setup.ts',
expect: { timeout: 5_000 },
use: {
baseURL: DASHBOARD_BASE,
actionTimeout: 10_000,
navigationTimeout: 30_000,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: path.join(__dirname, 'tests/e2e/.auth/admin.json')
}
}
],
webServer: {
command: 'npm run dev',
url: `http://localhost:${DASHBOARD_PORT}/admin/`,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
stderr: 'pipe',
timeout: 60_000
}
});

View File

@@ -25,12 +25,18 @@
value = $bindable(''),
language = 'rhai' as Language,
placeholder = '',
minHeight = '12rem'
minHeight = '12rem',
readOnly = false
}: {
value?: string;
language?: Language;
placeholder?: 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();
let host: HTMLDivElement | null = null;
@@ -48,6 +54,12 @@
keymap.of([indentWithTab]),
dashboardSyntaxHighlighting,
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) => {
if (update.docChanged && !pushingFromOutside) {
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,
// avoidant of characters that ship awkwardly through chat clients
// (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!#$%&*+-?@';
@@ -15,11 +20,18 @@ export function generatePassword(length = 16): string {
if (length < 8) {
throw new Error('password length must be at least 8');
}
const buf = new Uint32Array(length);
crypto.getRandomValues(buf);
const n = CHARSET.length;
// 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 = '';
for (let i = 0; i < length; i++) {
out += CHARSET[buf[i] % CHARSET.length];
while (out.length < 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;
}

View File

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

View File

@@ -17,6 +17,7 @@
import ActionMenu from '$lib/ActionMenu.svelte';
import RoleChip from '$lib/RoleChip.svelte';
import { currentUser } from '$lib/auth';
import { canAdminApp, canWriteApp } from '$lib/capabilities';
const me = $derived($currentUser);
@@ -36,7 +37,12 @@
let domains = $state<AppDomain[]>([]);
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
let showCreateScript = $state(false);
@@ -102,7 +108,7 @@
editDescription = app.description ?? '';
editSlug = app.slug;
const loaders: Promise<unknown>[] = [loadScripts(app.id), loadDomains(app.id)];
if (canAdminMembers) {
if (canAdmin) {
loaders.push(loadMembers(app.id), loadEligibleUsers());
}
await Promise.all(loaders);
@@ -362,6 +368,16 @@
$effect(() => {
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>
{#if loading && !app}
@@ -394,33 +410,35 @@
class:active={activeTab === 'domains'}
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
>
{#if canAdminMembers}
{#if canAdmin}
<button
type="button"
class:active={activeTab === 'members'}
onclick={() => (activeTab = 'members')}>Members ({members.length})</button
>
{/if}
<button
type="button"
class:active={activeTab === 'settings'}
onclick={() => (activeTab = 'settings')}>Settings</button
>
{/if}
</nav>
{#if activeTab === 'scripts'}
<section>
<div class="row">
<h2>Scripts</h2>
{#if canWrite}
<button
type="button"
onclick={() => (showCreateScript = !showCreateScript)}
>
{showCreateScript ? 'Cancel' : 'New script'}
</button>
{/if}
</div>
{#if showCreateScript}
{#if showCreateScript && canWrite}
<form class="create-form" onsubmit={submitCreateScript}>
<div class="row">
<label>
@@ -473,6 +491,7 @@
these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for
wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture.
</p>
{#if canAdmin}
<form class="create-form inline" onsubmit={submitCreateDomain}>
<input
bind:value={createDomainPattern}
@@ -486,6 +505,7 @@
{#if createDomainError}
<div class="error">{createDomainError}</div>
{/if}
{/if}
{#if domains.length === 0}
<p class="muted">No domain claims yet.</p>
{:else}
@@ -496,6 +516,7 @@
<code>{d.pattern}</code>
<span class="muted">{d.shape}</span>
</div>
{#if canAdmin}
<button
type="button"
class="secondary danger"
@@ -503,12 +524,13 @@
>
Delete
</button>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'members' && canAdminMembers}
{:else if activeTab === 'members' && canAdmin}
<section>
<h2>Members</h2>
<p class="muted">
@@ -623,7 +645,7 @@
</div>
{/if}
</section>
{:else if activeTab === 'settings'}
{:else if activeTab === 'settings' && canAdmin}
<section>
<h2>Settings</h2>
<form class="create-form" onsubmit={(e) => saveSettings(e)}>

View File

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

View File

@@ -79,6 +79,13 @@
let deleteTarget = $state<AdminDto | null>(null);
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._-]) -------------------
const USERNAME_RE = /^[a-z0-9._-]{2,32}$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -219,19 +226,36 @@
}
}
async function toggleActive(row: AdminDto) {
async function reactivate(row: AdminDto) {
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));
flash(
'info',
`${updated.username} ${updated.is_active ? 'reactivated' : 'deactivated'}.`
);
flash('info', `${updated.username} reactivated.`);
} catch (e) {
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) {
deleteTarget = row;
}
@@ -353,7 +377,8 @@
{ label: 'Edit', onClick: () => openEdit(row) },
{
label: row.is_active ? 'Deactivate' : 'Reactivate',
onClick: () => toggleActive(row)
onClick: () =>
row.is_active ? askDeactivate(row) : reactivate(row)
},
{
label: 'Delete',
@@ -571,6 +596,30 @@
</div>
{/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 -->
{#if deleteTarget}
{@const dt = deleteTarget}

View File

@@ -0,0 +1,75 @@
# Dashboard E2E tests
Browser-driven tests for the PiCloud dashboard, powered by [Playwright].
## Prerequisites
The tests drive a real dashboard against a real backend. Bring up both
before running:
```sh
# 1. Postgres
docker compose up -d postgres
# 2. Backend (port 18080 matches dashboard/vite.config.ts dev proxy)
PICLOUD_BIND=127.0.0.1:18080 \
PICLOUD_ADMIN_USERNAME=admin \
PICLOUD_ADMIN_PASSWORD=admin \
DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \
cargo run -p picloud
# 3. Browser binaries (one-time, ~200 MB)
cd dashboard && npm run test:e2e:install
```
The Vite dev server is started automatically by Playwright's `webServer`
config — you do not need to run `npm run dev` yourself.
## Running
```sh
cd dashboard
npm run test:e2e # headless, full suite
npm run test:e2e:ui # interactive UI runner
npx playwright test smoke # run a single spec
npx playwright show-report
```
## Env vars
| Var | Default | Notes |
| ------------------------ | ------------------------ | ----------------------------------------------------------------- |
| `E2E_BASE_URL` | `http://localhost:5173` | Origin tests navigate against (dashboard is mounted at `/admin`). |
| `E2E_API_BASE` | `http://127.0.0.1:18080` | Backend used by globalSetup health probe + admin login. |
| `E2E_DASHBOARD_ORIGIN` | `http://localhost:5173` | Used to seed `localStorage` during globalSetup. |
| `E2E_ADMIN_USERNAME` | `admin` | Bootstrap admin to log in as. |
| `E2E_ADMIN_PASSWORD` | `admin` | Match `PICLOUD_ADMIN_PASSWORD` above. |
| `PICLOUD_DASHBOARD_PORT` | `5173` | Dev server port — picked up by both Vite and Playwright. |
## How isolation works
Tests share one backend + one Postgres. To avoid cross-test interference:
- A shared bootstrap admin session is captured once in
`tests/e2e/.auth/admin.json` (gitignored) and reused by every test via
`storageState`.
- Each test creates resources with a unique slug / username produced by
`fixtures/ids.ts` (`e2e-<prefix>-w<worker>-<random>`).
- Each test registers cleanup via `fixtures/cleanup.ts` and tears down
in `afterEach`. Cleanup is best-effort: a missing resource doesn't
fail the suite, so a test can pre-delete and still register the entry.
## Layout
```
tests/e2e/
global-setup.ts # health probe + admin login + storageState seed
smoke.spec.ts # A.5 smoke
fixtures/
auth.ts # UI login/logout helpers (for login-flow specs)
api.ts # bearer-token-backed APIRequestContext
ids.ts # unique slug/username generators (test-fixture)
cleanup.ts # afterEach resource teardown
```
[Playwright]: https://playwright.dev

View File

@@ -0,0 +1,335 @@
import { expect, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
const MEMBER_PW = 'e2e-member-pw';
async function seedAppAndMember(opts: {
slug: string;
username: string;
role: 'viewer' | 'editor' | 'app_admin';
}): Promise<{ appId: string; userId: string }> {
const api = await adminApi();
try {
const appRes = await api.post('/api/v1/admin/apps', {
data: { slug: opts.slug, name: opts.slug }
});
expect(appRes.ok()).toBe(true);
const appId = ((await appRes.json()) as { id: string }).id;
const userRes = await api.post('/api/v1/admin/admins', {
data: { username: opts.username, password: MEMBER_PW, instance_role: 'member' }
});
expect(userRes.ok()).toBe(true);
const userId = ((await userRes.json()) as { id: string }).id;
const memberRes = await api.post(`/api/v1/admin/apps/${opts.slug}/members`, {
data: { user_id: userId, role: opts.role }
});
expect(memberRes.ok()).toBe(true);
return { appId, userId };
} finally {
await api.dispose();
}
}
// Phase B2 — Apps Lifecycle. Create, view, edit, delete, plus the
// historical-slug takeover flow and adversarial inputs.
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
function failOnDialog(page: Page): void {
page.on('dialog', async (dialog) => {
await dialog.dismiss();
throw new Error(`Unexpected browser dialog fired: ${dialog.type()} — "${dialog.message()}"`);
});
}
async function openCreateForm(page: Page): Promise<void> {
await page.goto('/admin/apps');
await page.getByRole('button', { name: 'New app' }).click();
}
async function createApp(
page: Page,
opts: { name: string; slug: string; description?: string }
): Promise<void> {
await openCreateForm(page);
await page.getByLabel('Name').fill(opts.name);
// Clear the auto-derived slug and type the test-controlled one so
// we know exactly which slug we'll register for cleanup.
const slugInput = page.getByLabel('Slug');
await slugInput.fill('');
await slugInput.fill(opts.slug);
if (opts.description !== undefined) {
await page.getByLabel('Description').fill(opts.description);
}
await page.getByRole('button', { name: 'Create app' }).click();
}
test.describe('B2 apps lifecycle', () => {
test('create app: slug auto-derives from name, app appears in list', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('lifecycle');
const displayName = slug.replace(/-/g, ' ');
await openCreateForm(page);
await page.getByLabel('Name').fill(displayName);
// Slug auto-derives — the input value is set, no extra typing.
const slugInput = page.getByLabel('Slug');
await expect(slugInput).toHaveValue(slug);
await page.getByRole('button', { name: 'Create app' }).click();
cleanup.app(slug);
await expect(page.getByRole('link', { name: new RegExp(displayName) })).toBeVisible();
});
test('edit name + description in settings persists across reload', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('edit');
await createApp(page, { name: slug, slug });
cleanup.app(slug);
await page.getByRole('link', { name: new RegExp(slug) }).click();
await expect(page).toHaveURL(new RegExp(`/admin/apps/${slug}$`));
await page.getByRole('button', { name: 'Settings' }).click();
const newName = `${slug} renamed`;
const newDesc = 'updated description';
await page.getByLabel('Name').fill(newName);
await page.getByLabel('Description').fill(newDesc);
await page.getByRole('button', { name: 'Save changes' }).click();
// Wait for the network round-trip to settle — the busy label
// flips back to "Save changes" when done.
await expect(page.getByRole('button', { name: 'Save changes' })).toBeEnabled();
await page.reload();
await page.getByRole('button', { name: 'Settings' }).click();
await expect(page.getByLabel('Name')).toHaveValue(newName);
await expect(page.getByLabel('Description')).toHaveValue(newDesc);
});
test('delete: wrong phrase keeps button disabled, right phrase removes app', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('delete');
await createApp(page, { name: slug, slug });
cleanup.app(slug); // belt-and-braces; cleanup is best-effort
await page.getByRole('link', { name: new RegExp(slug) }).click();
await page.getByRole('button', { name: 'Settings' }).click();
await page.getByRole('button', { name: 'Delete app' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
const phraseInput = dialog.getByRole('textbox');
const confirmBtn = dialog.getByRole('button', { name: 'Delete app' });
await expect(confirmBtn).toBeDisabled();
await phraseInput.fill('wrong-phrase');
await expect(confirmBtn).toBeDisabled();
await phraseInput.fill(slug);
await expect(confirmBtn).toBeEnabled();
await confirmBtn.click();
await expect(page).toHaveURL(/\/admin\/apps$/);
await expect(page.getByRole('link', { name: new RegExp(slug) })).toHaveCount(0);
});
test('historical slug warning surfaces; force-takeover succeeds', async ({
page,
uniqueSlug
}) => {
const origSlug = uniqueSlug('hist');
const renamedSlug = `${origSlug}-r`;
// Historical-redirect rows are created on RENAME, not on
// delete. So: create app, rename it, original slug now lives
// in app_slug_history.
const api = await adminApi();
try {
const created = await api.post('/api/v1/admin/apps', {
data: { slug: origSlug, name: origSlug }
});
expect(created.ok()).toBe(true);
const renamed = await api.patch(
`/api/v1/admin/apps/${encodeURIComponent(origSlug)}`,
{ data: { slug: renamedSlug } }
);
expect(renamed.ok()).toBe(true);
} finally {
await api.dispose();
}
cleanup.app(renamedSlug); // the renamed app still exists
await openCreateForm(page);
await page.getByLabel('Name').fill(origSlug);
await page.getByLabel('Slug').fill('');
await page.getByLabel('Slug').fill(origSlug);
await page.getByRole('button', { name: 'Create app' }).click();
await expect(page.locator('.warning')).toBeVisible();
await expect(page.locator('.warning')).toContainText(/previously redirected/i);
await page.getByRole('button', { name: /claim slug anyway/i }).click();
cleanup.app(origSlug); // the takeover created a new app
await expect(page.getByRole('link', { name: new RegExp(origSlug) })).toBeVisible();
});
});
test.describe('B2 apps adversarial', () => {
test('slug with uppercase + spaces is normalized in-place', async ({ page, uniqueSlug }) => {
const base = uniqueSlug('norm');
await openCreateForm(page);
await page.getByLabel('Name').fill(base);
const slugInput = page.getByLabel('Slug');
await slugInput.fill('');
// Simulate the user typing/pasting an invalid slug. The
// oninput handler runs slugify() and rewrites the input value.
await slugInput.fill(` Hello WORLD ${base}!`);
await expect(slugInput).toHaveValue(`hello-world-${base}`);
});
test('xss in name and description renders as text everywhere', async ({ page, uniqueSlug }) => {
failOnDialog(page);
const slug = uniqueSlug('xss');
const payload = '<img src=x onerror=alert(1)><script>window.__xss=true;</script>';
await createApp(page, { name: payload, slug, description: payload });
cleanup.app(slug);
// List page — the link's accessible name contains the literal
// payload text, not the parsed HTML.
await expect(page.getByRole('link', { name: new RegExp('img src=x') })).toBeVisible();
// Detail page — open it; payload renders in the breadcrumb /
// header as text only.
await page.goto(`/admin/apps/${slug}`);
const xssRan = await page.evaluate(
() => (window as unknown as { __xss?: boolean }).__xss === true
);
expect(xssRan).toBe(false);
expect(await page.locator('script:has-text("__xss")').count()).toBe(0);
});
test('very long name does not crash the dashboard', async ({ page, uniqueSlug }) => {
// The backend currently has no name length cap; the dashboard
// just needs to keep rendering when handed an unusually long
// value. Guards against layout / locator regressions when a
// future test or user creates an oversized app.
const slug = uniqueSlug('long');
const longName = 'A'.repeat(10_000);
await openCreateForm(page);
await page.getByLabel('Name').fill(longName);
await page.getByLabel('Slug').fill('');
await page.getByLabel('Slug').fill(slug);
await page.getByRole('button', { name: 'Create app' }).click();
const errorVisible = await page
.locator('.create-form .error')
.isVisible()
.catch(() => false);
if (errorVisible) {
// Server rejected — fine, no cleanup needed.
await expect(page.getByRole('link', { name: new RegExp(slug) })).toHaveCount(0);
return;
}
// Server accepted — confirm the dashboard still renders and is
// navigable. Detail page must load too.
cleanup.app(slug);
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
await page.goto(`/admin/apps/${slug}`);
await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible();
});
});
test.describe('B2 apps role shadowing', () => {
test('viewer member sees no "New app" on the apps list', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('vlist');
const username = uniqueUsername('viewer');
const { userId } = await seedAppAndMember({ slug, username, role: 'viewer' });
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto('/admin/apps');
// Member can see the apps list (just the one they belong to)
// but the create-app affordance is hidden.
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
await expect(page.getByRole('button', { name: /^New app$/ })).toHaveCount(0);
} finally {
await page.context().close();
}
});
test('viewer sees no Add domain form and no Settings tab on app detail', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('vdom');
const username = uniqueUsername('viewer');
const { userId } = await seedAppAndMember({ slug, username, role: 'viewer' });
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/apps/${slug}`);
await expect(
page.getByRole('button', { name: /^Scripts \(\d+\)$/ })
).toBeVisible();
// Settings tab is absent.
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
// Domains tab still listable, but no Add-domain submit.
await page.getByRole('button', { name: /^Domains \(\d+\)$/ }).click();
await expect(page.getByRole('button', { name: /^Add domain$/ })).toHaveCount(0);
} finally {
await page.context().close();
}
});
test('editor sees New script but no Settings tab', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('edit');
const username = uniqueUsername('editor');
const { userId } = await seedAppAndMember({ slug, username, role: 'editor' });
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/apps/${slug}`);
await expect(page.getByRole('button', { name: /^New script$/ })).toBeVisible();
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
await expect(
page.getByRole('button', { name: /^Members \(\d+\)$/ })
).toHaveCount(0);
} finally {
await page.context().close();
}
});
});

View File

@@ -0,0 +1,118 @@
import { expect, test, type Page } from '@playwright/test';
import { loginAsAdmin, logout } from '../fixtures/auth';
// Phase B1 — Auth & Navigation. Every interaction with the login form
// and the layout-level redirects, plus the obvious adversarial inputs.
const VALID_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const VALID_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
function failOnDialog(page: Page): void {
page.on('dialog', async (dialog) => {
await dialog.dismiss();
throw new Error(`Unexpected browser dialog fired: ${dialog.type()} — "${dialog.message()}"`);
});
}
test.describe('B1 auth — unauthenticated', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('valid credentials land on the apps list', async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByRole('heading', { name: 'Apps', level: 1 })).toBeVisible();
});
test('wrong password shows an inline error and stays on /login', async ({ page }) => {
await page.goto('/admin/login');
await page.getByLabel('Username').fill(VALID_USERNAME);
await page.getByLabel('Password').fill('definitely-not-the-password');
await page.getByRole('button', { name: /sign in/i }).click();
const error = page.locator('.error');
await expect(error).toBeVisible();
await expect(error).not.toHaveText('');
await expect(page).toHaveURL(/\/admin\/login$/);
// localStorage must remain empty — a failed login should not
// leak a session token.
const token = await page.evaluate(() => localStorage.getItem('picloud.admin.token'));
expect(token).toBeNull();
});
test('empty submit is blocked by the browser and does not navigate', async ({ page }) => {
await page.goto('/admin/login');
await page.getByRole('button', { name: /sign in/i }).click();
// HTML5 validation prevents submission; URL is unchanged and the
// username input is reported invalid.
await expect(page).toHaveURL(/\/admin\/login$/);
const usernameInvalid = await page
.getByLabel('Username')
.evaluate((el: HTMLInputElement) => !el.validity.valid);
expect(usernameInvalid).toBe(true);
await expect(page.locator('.error')).toBeHidden();
});
test('visiting an authed route redirects to /login', async ({ page }) => {
await page.goto('/admin/apps');
await expect(page).toHaveURL(/\/admin\/login$/);
await expect(page.getByLabel('Username')).toBeVisible();
});
test('password field is type=password (no plaintext echo)', async ({ page }) => {
await page.goto('/admin/login');
await expect(page.getByLabel('Password')).toHaveAttribute('type', 'password');
});
test('xss payload in username is escaped and does not execute', async ({ page }) => {
failOnDialog(page);
const payload = '<script>window.__xss = true;</script><img src=x onerror=alert(1)>';
await page.goto('/admin/login');
await page.getByLabel('Username').fill(payload);
await page.getByLabel('Password').fill('whatever');
await page.getByRole('button', { name: /sign in/i }).click();
// Whatever the API does with that input, the page must remain
// safe: no script tag injected into the DOM, no global side
// effect, and a visible error (since the credentials don't
// match any user).
await expect(page.locator('.error')).toBeVisible();
const xssRan = await page.evaluate(
() => (window as unknown as { __xss?: boolean }).__xss === true
);
expect(xssRan).toBe(false);
const injectedScript = await page.locator('script:has-text("__xss")').count();
expect(injectedScript).toBe(0);
// The form must still be functional after the rejected attempt.
await page.getByLabel('Username').fill('');
await page.getByLabel('Username').fill(VALID_USERNAME);
await page.getByLabel('Password').fill('');
await page.getByLabel('Password').fill(VALID_PASSWORD);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/admin\/apps$/);
});
});
test.describe('B1 auth — authenticated', () => {
test('visiting /login while signed in bounces to /apps', async ({ page }) => {
await page.goto('/admin/login');
await expect(page).toHaveURL(/\/admin\/apps$/);
});
});
test.describe('B1 auth — logout', () => {
// Logout must NOT use the shared storageState token, or it would
// invalidate the session every other test relies on. Each run
// here logs in fresh so its session is disposable.
test.use({ storageState: { cookies: [], origins: [] } });
test('logout clears the session and lands on /login', async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByRole('heading', { name: 'Apps', level: 1 })).toBeVisible();
await logout(page);
const token = await page.evaluate(() => localStorage.getItem('picloud.admin.token'));
expect(token).toBeNull();
// And the authed area is now gated again.
await page.goto('/admin/apps');
await expect(page).toHaveURL(/\/admin\/login$/);
});
});

View File

@@ -0,0 +1,47 @@
import { request, type APIRequestContext } from '@playwright/test';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
const STATE_PATH = path.join(__dirname, '..', '.auth', 'admin.json');
interface StoredState {
origins: Array<{
origin: string;
localStorage: Array<{ name: string; value: string }>;
}>;
}
let cachedToken: string | null = null;
async function readAdminToken(): Promise<string> {
if (cachedToken) return cachedToken;
const raw = await fs.readFile(STATE_PATH, 'utf8');
const state = JSON.parse(raw) as StoredState;
for (const origin of state.origins) {
const entry = origin.localStorage.find((e) => e.name === 'picloud.admin.token');
if (entry) {
cachedToken = entry.value;
return entry.value;
}
}
throw new Error(`No picloud.admin.token in ${STATE_PATH} — did globalSetup run?`);
}
// Thin wrapper around Playwright's request context that injects the
// admin bearer token from the shared storageState. Use this for
// setup/teardown shortcuts when the *test itself* is about something
// else (e.g., a script-editor test that just needs an app to exist).
export async function adminApi(): Promise<APIRequestContext> {
const token = await readAdminToken();
return request.newContext({
baseURL: API_BASE,
extraHTTPHeaders: {
authorization: `Bearer ${token}`,
'content-type': 'application/json'
}
});
}

View File

@@ -0,0 +1,21 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
// Drive the login form like a real user. globalSetup already saves a
// storageState for the shared admin, so most tests don't need this —
// it's reserved for specs that explicitly cover the login UI.
export async function loginAsAdmin(page: Page): Promise<void> {
await page.goto('/admin/login');
await page.getByLabel('Username').fill(ADMIN_USERNAME);
await page.getByLabel('Password').fill(ADMIN_PASSWORD);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/admin\/apps$/);
}
export async function logout(page: Page): Promise<void> {
await page.getByRole('button', { name: /logout/i }).click();
await expect(page).toHaveURL(/\/admin\/login$/);
}

View File

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

View File

@@ -0,0 +1,42 @@
/* eslint-disable no-empty-pattern -- Playwright fixtures require an
object-pattern first arg; these fixtures don't depend on any other
fixture so the pattern is intentionally empty. */
import { test as base } from '@playwright/test';
import { randomBytes } from 'node:crypto';
// Tests share a single backend/Postgres. To avoid collisions we tag
// every resource the test creates with a short random suffix plus the
// Playwright worker index. This way two workers running the same spec
// in parallel never fight over the same slug or username.
export function shortId(): string {
return randomBytes(3).toString('hex');
}
export function uniqueSlug(prefix: string, workerIndex: number): string {
const cleaned = prefix
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
return `e2e-${cleaned}-w${workerIndex}-${shortId()}`;
}
export function uniqueUsername(prefix: string, workerIndex: number): string {
// Username regex is [a-z0-9._-]{2,32}. Mirror the slug format.
const cleaned = prefix.toLowerCase().replace(/[^a-z0-9]+/g, '');
return `e2e${cleaned}w${workerIndex}${shortId()}`.slice(0, 32);
}
export const test = base.extend<{
uniqueSlug: (prefix: string) => string;
uniqueUsername: (prefix: string) => string;
}>({
uniqueSlug: async ({}, use, testInfo) => {
await use((prefix) => uniqueSlug(prefix, testInfo.workerIndex));
},
uniqueUsername: async ({}, use, testInfo) => {
await use((prefix) => uniqueUsername(prefix, testInfo.workerIndex));
}
});
export { expect } from '@playwright/test';

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

@@ -0,0 +1,146 @@
import { chromium, request } from '@playwright/test';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
const DASHBOARD_PORT = Number(process.env.PICLOUD_DASHBOARD_PORT ?? 5173);
const DASHBOARD_ORIGIN = process.env.E2E_DASHBOARD_ORIGIN ?? `http://localhost:${DASHBOARD_PORT}`;
const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
const AUTH_DIR = path.join(__dirname, '.auth');
const ADMIN_STATE_PATH = path.join(AUTH_DIR, 'admin.json');
export default async function globalSetup(): Promise<void> {
await assertBackendUp();
await fs.mkdir(AUTH_DIR, { recursive: true });
const token = await loginAsAdmin();
await sweepOrphans(token);
await persistAdminStorageState(token);
}
async function assertBackendUp(): Promise<void> {
const probe = await request.newContext();
try {
const res = await probe.get(`${API_BASE}/healthz`, { timeout: 5_000 });
if (!res.ok()) {
throw new Error(
`backend /healthz returned ${res.status()} — is \`cargo run -p picloud\` listening on ${API_BASE}?`
);
}
} catch (err) {
throw new Error(
`Could not reach backend at ${API_BASE}/healthz. ` +
`Bring it up before running E2E tests:\n\n` +
` docker compose up -d postgres\n` +
` PICLOUD_BIND=127.0.0.1:18080 \\\n` +
` PICLOUD_ADMIN_USERNAME=${ADMIN_USERNAME} \\\n` +
` PICLOUD_ADMIN_PASSWORD=${ADMIN_PASSWORD} \\\n` +
` DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \\\n` +
` cargo run -p picloud\n\n` +
`Underlying error: ${(err as Error).message}`
);
} finally {
await probe.dispose();
}
}
async function loginAsAdmin(): Promise<string> {
const ctx = await request.newContext();
try {
const res = await ctx.post(`${API_BASE}/api/v1/admin/auth/login`, {
data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD },
headers: { 'content-type': 'application/json' }
});
if (!res.ok()) {
const body = await res.text();
throw new Error(
`Admin login failed (${res.status()}): ${body}. ` +
`Verify PICLOUD_ADMIN_USERNAME / PICLOUD_ADMIN_PASSWORD match the seeded bootstrap admin.`
);
}
const payload = (await res.json()) as { token?: string };
if (!payload.token) {
throw new Error('Admin login response missing token field');
}
return payload.token;
} finally {
await ctx.dispose();
}
}
// Clean up apps + admin users left over from a previous crashed run.
// The convention is that every e2e-created resource has a slug
// starting with `e2e-` (apps) or a username starting with `e2e`
// (admins) — see fixtures/ids.ts. Best-effort: a sweep failure must
// not stop the suite from running.
async function sweepOrphans(token: string): Promise<void> {
const ctx = await request.newContext({
baseURL: API_BASE,
extraHTTPHeaders: { authorization: `Bearer ${token}` }
});
try {
try {
const res = await ctx.get('/api/v1/admin/apps');
if (res.ok()) {
const apps = (await res.json()) as Array<{ slug: string }>;
for (const app of apps) {
if (!app.slug.startsWith('e2e-')) continue;
try {
await ctx.delete(
`/api/v1/admin/apps/${encodeURIComponent(app.slug)}?force=true`
);
} catch {
// Individual delete failure is non-fatal — the per-test
// cleanup will catch it on the next run.
}
}
}
} catch {
// Listing failed; nothing to do but proceed.
}
try {
const res = await ctx.get('/api/v1/admin/admins');
if (res.ok()) {
const admins = (await res.json()) as Array<{ id: string; username: string }>;
for (const a of admins) {
if (!/^e2e/.test(a.username)) continue;
try {
await ctx.delete(`/api/v1/admin/admins/${a.id}`);
} catch {
// Same per-row tolerance as above.
}
}
}
} catch {
// Listing failed; same as above.
}
} finally {
await ctx.dispose();
}
}
// The dashboard reads its session from localStorage under the key
// `picloud.admin.token` (see src/lib/auth.ts). We can't write to
// localStorage without a browser context, so launch a throwaway one,
// seed the value, then save storageState for every test to reuse.
async function persistAdminStorageState(token: string): Promise<void> {
const browser = await chromium.launch();
try {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(`${DASHBOARD_ORIGIN}/admin/login`);
await page.evaluate(
([key, value]) => {
localStorage.setItem(key, value);
},
['picloud.admin.token', token]
);
await context.storageState({ path: ADMIN_STATE_PATH });
} finally {
await browser.close();
}
}

View File

@@ -0,0 +1,158 @@
import { expect, request, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
// Full-stack integration scenarios. Unlike the per-page B1B8 specs,
// these drive a complete user journey across multiple pages and then
// verify the data plane / API surface behaves the way the dashboard
// promised it would.
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
async function fillCodeMirror(page: Page, locator: string, text: string): Promise<void> {
const cm = page.locator(locator).first();
await cm.click();
await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.press('Delete');
await page.keyboard.type(text);
}
test('end-to-end: app + domain + script + route via dashboard → invoke via public URL', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('public');
const domain = `${slug}.local`;
const routePath = `/${slug}/hello`;
const scriptName = `${slug}-hello`;
const scriptSource = `return #{ statusCode: 200, body: #{ source: "public", slug: "${slug}" } };`;
// 1. Create the app from the apps list.
await page.goto('/admin/apps');
await page.getByRole('button', { name: 'New app' }).click();
await page.getByLabel('Name').fill(slug);
const slugInput = page.getByLabel('Slug');
await slugInput.fill('');
await slugInput.fill(slug);
await page.getByRole('button', { name: 'Create app' }).click();
cleanup.app(slug);
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
// 2. Open the app and claim the domain on the Domains tab.
await page.getByRole('link', { name: new RegExp(slug) }).click();
await expect(page).toHaveURL(new RegExp(`/admin/apps/${slug}$`));
await page.getByRole('button', { name: /^Domains \(\d+\)$/ }).click();
const domainForm = page.locator('form.create-form.inline');
await domainForm.getByPlaceholder(/app\.example\.com/).fill(domain);
await domainForm.getByRole('button', { name: /^Add domain$/ }).click();
await expect(page.locator('.domain-row')).toContainText(domain);
// 3. Create the script on the Scripts tab.
await page.getByRole('button', { name: /^Scripts \(\d+\)$/ }).click();
await page.getByRole('button', { name: /^New script$/ }).click();
await page.getByLabel('Name').fill(scriptName);
await fillCodeMirror(page, '.cm-content', scriptSource);
await page.getByRole('button', { name: /^Create script$/ }).click();
// 4. Open the script and bind a route on the Routing tab.
await page.getByRole('link', { name: new RegExp(scriptName) }).click();
await page.getByRole('button', { name: 'Routing' }).click();
await page.getByRole('button', { name: '+ Add route' }).click();
const routeForm = page.locator('form.route-form');
await routeForm.getByLabel('Path', { exact: true }).fill(routePath);
await routeForm.getByLabel('Method').selectOption('GET');
await routeForm.getByLabel(/^Host/).fill(domain);
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-list')).toContainText(routePath);
// 5. Invoke via the public URL, with the Host header pointing at
// the claimed domain. The dev backend listens on 127.0.0.1; the
// orchestrator resolves the app from Host, then the route.
const publicCtx = await request.newContext({ baseURL: API_BASE });
try {
const res = await publicCtx.get(routePath, { headers: { host: domain } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { source: string; slug: string };
expect(body.source).toBe('public');
expect(body.slug).toBe(slug);
} finally {
await publicCtx.dispose();
}
});
test('api key minted via dashboard works as a CLI bearer, then revoke disables it', async ({
page,
uniqueUsername
}) => {
// Worker-aware unique helper instead of Date.now() — keeps two
// workers from minting the same name on the same millisecond.
const name = uniqueUsername('cli');
// 1. Mint the key from /profile and capture the revealed token.
await page.goto('/admin/profile');
await page.getByRole('button', { name: /\+ Mint API key/ }).click();
const mintForm = page.locator('form.mint');
await mintForm.getByPlaceholder('e.g. ci-deploy').fill(name);
// script:read is enough to read the scripts list — that's our
// "CLI verb" below.
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
await page.getByRole('button', { name: /^Mint key$/ }).click();
const reveal = page.locator('.reveal');
await expect(reveal).toBeVisible();
const rawToken = (await reveal.locator('code.token').textContent())?.trim();
expect(rawToken).toBeTruthy();
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
await reveal.getByRole('button', { name: /^Done$/ }).click();
// 2. Act like a CLI: call the API directly with Bearer <token>.
const cli = await request.newContext({
baseURL: API_BASE,
extraHTTPHeaders: { authorization: `Bearer ${rawToken}` }
});
try {
const ok = await cli.get('/api/v1/admin/scripts');
expect(ok.status()).toBe(200);
const body = (await ok.json()) as unknown;
expect(Array.isArray(body)).toBe(true);
// Sanity: a route the scope doesn't cover must reject.
// `script:read` cannot list instance admins (that's
// instance:admin territory).
const denied = await cli.get('/api/v1/admin/admins');
expect(denied.status()).toBe(403);
// 3. Revoke via the dashboard.
await page.reload();
const revokeBtn = page.getByRole('button', { name: `Revoke ${name}` });
await expect(revokeBtn).toBeVisible();
await revokeBtn.click();
await page.getByRole('dialog').getByRole('button', { name: /^Revoke$/ }).click();
await expect(revokeBtn).toHaveCount(0);
// 4. Same CLI call must now fail auth.
const afterRevoke = await cli.get('/api/v1/admin/scripts');
expect(afterRevoke.status()).toBe(401);
} finally {
await cli.dispose();
}
// Belt-and-braces cleanup: if the UI revoke missed, drop via API.
const api = await adminApi();
try {
const list = await api.get('/api/v1/admin/api-keys');
if (list.ok()) {
const all = (await list.json()) as Array<{ id: string; name: string }>;
const k = all.find((x) => x.name === name);
if (k) cleanup.apiKey(k.id);
}
} finally {
await api.dispose();
}
});

View File

@@ -0,0 +1,168 @@
import { expect } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
// Phase B5 — App Members. Setup creates one or two extra admin
// users via the API; tests drive the Members tab through the
// dashboard like a real app admin would.
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
async function createApp(slug: string): Promise<string> {
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/apps', { data: { slug, name: slug } });
expect(res.ok()).toBe(true);
return ((await res.json()) as { id: string }).id;
} finally {
await api.dispose();
}
}
async function createMemberUser(username: string): Promise<string> {
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/admins', {
data: { username, password: 'e2e-member-pw', instance_role: 'member' }
});
expect(res.ok()).toBe(true);
return ((await res.json()) as { id: string }).id;
} finally {
await api.dispose();
}
}
test.describe('B5 app members', () => {
test('invite a member-role user, then remove them', async ({ page, uniqueSlug, uniqueUsername }) => {
const slug = uniqueSlug('mem');
const username = uniqueUsername('inv');
await createApp(slug);
const userId = await createMemberUser(username);
cleanup.app(slug);
cleanup.adminUser(userId);
await page.goto(`/admin/apps/${slug}`);
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
// Invite. Both selects sit in `form.create-form`; locate them
// by position to avoid getByLabel ambiguity (the Svelte
// markup nests both labels in a flex row, which makes their
// accessible names overlap).
const form = page.locator('form.create-form');
await form.locator('select').nth(0).selectOption({ label: username });
await form.locator('select').nth(1).selectOption('editor');
await page.getByRole('button', { name: /^Add member$/ }).click();
await expect(page.locator('.member-row')).toContainText(username);
// Remove via action menu + confirm modal.
await page.getByRole('button', { name: new RegExp(`Member actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Remove from app$/ }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Remove member$/ }).click();
await expect(page.locator('.member-row')).toHaveCount(0);
});
test('role change via action menu updates the role chip', async ({
page,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('mem');
const username = uniqueUsername('role');
await createApp(slug);
const userId = await createMemberUser(username);
cleanup.app(slug);
cleanup.adminUser(userId);
// Seed the membership via API to skip the invite UI.
const api = await adminApi();
try {
const res = await api.post(`/api/v1/admin/apps/${slug}/members`, {
data: { user_id: userId, role: 'viewer' }
});
expect(res.ok()).toBe(true);
} finally {
await api.dispose();
}
await page.goto(`/admin/apps/${slug}`);
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
await page.getByRole('button', { name: new RegExp(`Member actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Make editor$/ }).click();
const row = page.locator('.member-row', { hasText: username });
await expect(row).toContainText(/editor/i);
});
test('non-app-admin viewers do not see the Members tab', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('mem');
const username = uniqueUsername('viewer');
const password = 'e2e-member-pw';
await createApp(slug);
const userId = await createMemberUser(username);
cleanup.app(slug);
cleanup.adminUser(userId);
// Grant viewer membership (not app_admin) so the user can see
// the app at all.
const api = await adminApi();
try {
const res = await api.post(`/api/v1/admin/apps/${slug}/members`, {
data: { user_id: userId, role: 'viewer' }
});
expect(res.ok()).toBe(true);
} finally {
await api.dispose();
}
const token = await loginAsUserToken(username, password);
const viewerPage = await pageWithUserToken(browser, token);
try {
await viewerPage.goto(`/admin/apps/${slug}`);
// Scripts tab loads — that's what a viewer sees.
await expect(
viewerPage.getByRole('button', { name: /^Scripts \(\d+\)$/ })
).toBeVisible();
// Members tab button is absent for non-app-admins.
await expect(
viewerPage.getByRole('button', { name: /^Members \(\d+\)$/ })
).toHaveCount(0);
} finally {
await viewerPage.context().close();
}
});
});
test.describe('B5 app members adversarial', () => {
test('role dropdown exposes only the documented values', async ({
page,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('mem');
const username = uniqueUsername('rolelist');
await createApp(slug);
const userId = await createMemberUser(username);
cleanup.app(slug);
cleanup.adminUser(userId);
await page.goto(`/admin/apps/${slug}`);
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
const form = page.locator('form.create-form');
const roleSelect = form.locator('select').nth(1);
const optionValues = await roleSelect.evaluate((el: HTMLSelectElement) =>
Array.from(el.options).map((o) => o.value)
);
expect(optionValues.sort()).toEqual(['app_admin', 'editor', 'viewer']);
});
});

View File

@@ -0,0 +1,150 @@
import { expect, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
// Phase B7 — Profile + API Keys (/admin/profile). Covers the
// mint/reveal/revoke flow, the app-binding mutual-exclusion guard,
// and adversarial inputs.
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
async function createApp(slug: string): Promise<string> {
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/apps', { data: { slug, name: slug } });
expect(res.ok()).toBe(true);
return ((await res.json()) as { id: string }).id;
} finally {
await api.dispose();
}
}
async function openMintForm(page: Page): Promise<void> {
await page.goto('/admin/profile');
await page.getByRole('button', { name: /\+ Mint API key/ }).click();
}
async function registerKeyCleanupByName(name: string): Promise<void> {
const api = await adminApi();
try {
const res = await api.get('/api/v1/admin/api-keys');
const all = (await res.json()) as Array<{ id: string; name: string }>;
const k = all.find((x) => x.name === name);
if (k) cleanup.apiKey(k.id);
} finally {
await api.dispose();
}
}
test.describe('B7 profile + API keys', () => {
test('mint instance-wide key: reveal → ack → key appears in list', async ({ page }) => {
const name = `e2e-mint-${Date.now()}`;
await openMintForm(page);
await page.locator('form.mint').getByPlaceholder('e.g. ci-deploy').fill(name);
// Pick a non-instance scope so we don't need to worry about
// mutual exclusion here. The scope-chip is a <label> wrapping
// the checkbox — clicking the label toggles it.
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
await page.getByRole('button', { name: /^Mint key$/ }).click();
const reveal = page.locator('.reveal');
await expect(reveal).toBeVisible();
await expect(reveal.locator('code.token')).toContainText(/\S{16,}/);
await expect(reveal.getByRole('button', { name: /^Done$/ })).toBeDisabled();
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
await reveal.getByRole('button', { name: /^Done$/ }).click();
await registerKeyCleanupByName(name);
await expect(page.getByText(name)).toBeVisible();
});
test('binding to an app disables instance scopes', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('keyapp');
const appId = await createApp(slug);
cleanup.app(slug);
await openMintForm(page);
// Default binding is Instance-wide — instance scopes are
// enabled.
const instChip = page.locator('label.scope-chip', { hasText: 'instance:admin' });
await expect(instChip).not.toHaveClass(/disabled/);
// Switch binding to the app. The chip becomes disabled.
await page.getByLabel(/Binding/i).selectOption(appId);
await expect(instChip).toHaveClass(/disabled/);
});
test('revoke key removes it from the list', async ({ page }) => {
const name = `e2e-revoke-${Date.now()}`;
// Seed a key via API so the test focuses on the revoke UI.
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/api-keys', {
data: { name, scopes: ['script:read'] }
});
expect(res.ok()).toBe(true);
const body = (await res.json()) as { id: string };
cleanup.apiKey(body.id);
} finally {
await api.dispose();
}
await page.goto('/admin/profile');
const revokeBtn = page.getByRole('button', { name: `Revoke ${name}` });
await expect(revokeBtn).toBeVisible();
await revokeBtn.click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: /^Revoke$/ }).click();
// Assert the row's revoke button is gone (the flash banner
// also mentions the name, so a plain getByText would still
// match — anchor on the row-scoped button instead).
await expect(revokeBtn).toHaveCount(0);
});
test('denied=users banner shows when arriving from the users redirect', async ({ page }) => {
await page.goto('/admin/profile?denied=users');
await expect(page.getByText(/don.?t have access to the Users page/i)).toBeVisible();
});
});
test.describe('B7 profile adversarial', () => {
test('empty name keeps the mint button disabled', async ({ page }) => {
await openMintForm(page);
// Trying to click would HTML5-validate; instead verify the
// button is disabled while name is empty.
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
await expect(page.getByRole('button', { name: /^Mint key$/ })).toBeDisabled();
});
test('copy-token button copies the full token, not a truncated form', async ({
page,
context
}) => {
// Permission must be granted explicitly; chromium will throw
// otherwise when calling navigator.clipboard.readText().
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
const name = `e2e-copy-${Date.now()}`;
await openMintForm(page);
await page.locator('form.mint').getByPlaceholder('e.g. ci-deploy').fill(name);
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
await page.getByRole('button', { name: /^Mint key$/ }).click();
const reveal = page.locator('.reveal');
const tokenInDom = await reveal.locator('code.token').textContent();
expect(tokenInDom).toBeTruthy();
await reveal.getByRole('button', { name: /^Copy$/ }).click();
const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied).toBe(tokenInDom);
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
await reveal.getByRole('button', { name: /^Done$/ }).click();
await registerKeyCleanupByName(name);
});
});

View File

@@ -0,0 +1,189 @@
import { expect, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
// Phase B4 — Routing tab in the script editor. Add / remove / match
// preview + validation paths (host check, path-kind mismatch, reserved
// prefix, duplicate conflict, adversarial paths).
const HELLO_RHAI = `return #{ statusCode: 200, body: #{ ok: true } };`;
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
async function makeAppWithScript(slug: string): Promise<{ appId: string; scriptId: string }> {
const api = await adminApi();
try {
const appRes = await api.post('/api/v1/admin/apps', {
data: { slug, name: slug }
});
expect(appRes.ok()).toBe(true);
const appBody = (await appRes.json()) as { id: string };
const scriptRes = await api.post('/api/v1/admin/scripts', {
data: { app_id: appBody.id, name: 'route-target', source: HELLO_RHAI }
});
expect(scriptRes.ok()).toBe(true);
const scriptBody = (await scriptRes.json()) as { id: string };
return { appId: appBody.id, scriptId: scriptBody.id };
} finally {
await api.dispose();
}
}
async function gotoRoutingTab(page: Page, scriptId: string): Promise<void> {
await page.goto(`/admin/scripts/${scriptId}`);
await page.getByRole('button', { name: 'Routing' }).click();
}
async function addRoute(
page: Page,
opts: { path: string; pathKind?: 'exact' | 'param' | 'prefix'; method?: string; host?: string }
): Promise<void> {
await page.getByRole('button', { name: '+ Add route' }).click();
const form = page.locator('form.route-form');
await form.getByLabel('Path', { exact: true }).fill(opts.path);
if (opts.pathKind) {
await form.getByLabel('Path kind').selectOption(opts.pathKind);
}
if (opts.method !== undefined) {
await form.getByLabel('Method').selectOption(opts.method);
}
if (opts.host !== undefined) {
await form.getByLabel(/^Host/).fill(opts.host);
}
}
test.describe('B4 routing', () => {
test('add route appears in list and matches in the preview', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('addr');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, { path: '/greet', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-list')).toContainText('/greet');
// Match preview confirms the route resolves.
await page.getByLabel('URL').fill('http://localhost/greet');
await page.locator('.actions').getByRole('button', { name: 'Match' }).click();
await expect(page.locator('pre.preview')).toContainText('script_id');
});
test('remove route updates the list', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('remr');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, { path: '/transient', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-list')).toContainText('/transient');
// removeRoute() uses window.confirm — accept it.
page.once('dialog', (d) => void d.accept());
await page.locator('.route-list').getByRole('button', { name: 'remove' }).click();
await expect(page.locator('.route-list')).toHaveCount(0);
await expect(page.getByText(/no routes yet/i)).toBeVisible();
});
test('duplicate route surfaces a 409 conflict error inline', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('dupr');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, { path: '/twice', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-list')).toContainText('/twice');
// Same path + method again — must conflict.
await addRoute(page, { path: '/twice', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-form .error.inline')).toBeVisible();
});
test('path-kind mismatch warns inline when /:name is set to exact', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('mism');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await page.getByRole('button', { name: '+ Add route' }).click();
await page.getByLabel('Path', { exact: true }).fill('/users/:id');
// Override to a wrong kind — auto-detect would have picked
// `param`; selecting `exact` should fire the warning.
await page.getByLabel('Path kind').selectOption('exact');
await expect(page.locator('.route-form .warning.inline')).toBeVisible();
});
test('host validation warns when the host is not a claimed domain', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('unclaim');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await page.getByRole('button', { name: '+ Add route' }).click();
await page.getByLabel('Path', { exact: true }).fill('/x');
await page.getByLabel(/^Host/).fill('example.test-not-claimed.local');
// One of the inline warnings is the unclaimed-host explainer.
await expect(page.locator('.route-form .warning.inline').first()).toBeVisible();
});
});
test.describe('B4 routing adversarial', () => {
test('reserved prefix /api/ is rejected with a visible error', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('reserv');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, { path: '/api/v9/oops', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-form .error.inline')).toBeVisible();
await expect(page.locator('.route-form .error.inline')).toContainText(
/reserved|api|prefix/i
);
// Empty-state copy renders when no routes exist; the path
// itself must not appear anywhere on the routing tab.
await expect(page.getByText(/no routes yet/i)).toBeVisible();
});
test('xss payload in path stored or rejected — never executes on render', async ({
page,
uniqueSlug
}) => {
page.on('dialog', async (d) => {
await d.dismiss();
throw new Error(`Unexpected dialog: ${d.message()}`);
});
const slug = uniqueSlug('pxss');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, {
path: '/<script>alert(1)</script>',
method: 'GET'
});
await page.getByRole('button', { name: /^Create route$/ }).click();
// Either accepted (rendered as text in the list) or rejected
// (error inline). Both fine — what's NOT fine is an alert
// dialog or an injected <script> tag in the list.
const xssScripts = await page.locator('.route-list script:has-text("alert")').count();
expect(xssScripts).toBe(0);
});
});

View File

@@ -0,0 +1,337 @@
import { expect, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
const MEMBER_PW = 'e2e-member-pw';
async function seedAppScriptAndMember(opts: {
slug: string;
username: string;
role: 'viewer' | 'editor';
}): Promise<{ scriptId: string; userId: string }> {
const api = await adminApi();
try {
const appRes = await api.post('/api/v1/admin/apps', {
data: { slug: opts.slug, name: opts.slug }
});
expect(appRes.ok()).toBe(true);
const appId = ((await appRes.json()) as { id: string }).id;
const scriptRes = await api.post('/api/v1/admin/scripts', {
data: { app_id: appId, name: `${opts.slug}-sc`, source: HELLO_RHAI }
});
expect(scriptRes.ok()).toBe(true);
const scriptId = ((await scriptRes.json()) as { id: string }).id;
const userRes = await api.post('/api/v1/admin/admins', {
data: { username: opts.username, password: MEMBER_PW, instance_role: 'member' }
});
expect(userRes.ok()).toBe(true);
const userId = ((await userRes.json()) as { id: string }).id;
const memberRes = await api.post(`/api/v1/admin/apps/${opts.slug}/members`, {
data: { user_id: userId, role: opts.role }
});
expect(memberRes.ok()).toBe(true);
return { scriptId, userId };
} finally {
await api.dispose();
}
}
// Phase B3 — Scripts CRUD + Editor. The script editor lives at
// /admin/scripts/{id}. Setup uses the API to create the app (and
// sometimes a baseline script) so each test can focus on the editor
// flow it actually covers.
const HELLO_RHAI = `return #{ statusCode: 200, body: #{ ok: true } };`;
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
async function createAppViaApi(slug: string): Promise<string> {
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/apps', {
data: { slug, name: slug }
});
expect(res.ok()).toBe(true);
const body = (await res.json()) as { id: string };
return body.id;
} finally {
await api.dispose();
}
}
async function createScriptViaApi(
appId: string,
name: string,
source = HELLO_RHAI
): Promise<string> {
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/scripts', {
data: { app_id: appId, name, source }
});
expect(res.ok()).toBe(true);
const body = (await res.json()) as { id: string };
return body.id;
} finally {
await api.dispose();
}
}
async function fillCodeMirror(page: Page, locator: string, text: string): Promise<void> {
const cm = page.locator(locator).first();
await cm.click();
await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.press('Delete');
await page.keyboard.type(text);
}
test.describe('B3 scripts CRUD', () => {
test('create script via UI navigates to scripts list with the new entry', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('cscr');
await createAppViaApi(slug);
cleanup.app(slug);
await page.goto(`/admin/apps/${slug}`);
await page.getByRole('button', { name: /^New script$/ }).click();
await page.getByLabel('Name').fill('echo');
// The CodeMirror editor starts empty in create mode; type a
// minimal valid script.
await fillCodeMirror(page, '.cm-content', HELLO_RHAI);
await page.getByRole('button', { name: 'Create script' }).click();
await expect(page.getByRole('link', { name: /echo/i })).toBeVisible();
});
test('edit + save Rhai source persists across reload', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('edit');
const appId = await createAppViaApi(slug);
const scriptId = await createScriptViaApi(appId, 'edit-target');
cleanup.app(slug);
await page.goto(`/admin/scripts/${scriptId}`);
await expect(page.locator('.cm-content').first()).toContainText('statusCode');
const updated = `// edited by e2e\nreturn #{ statusCode: 201, body: #{ edited: true } };`;
await fillCodeMirror(page, '.cm-content', updated);
await page.getByRole('button', { name: /^Save$/ }).click();
// Save button becomes disabled once the buffer matches the
// just-saved source — that's our settle signal.
await expect(page.getByRole('button', { name: /^Save$/ })).toBeDisabled();
await page.reload();
await expect(page.locator('.cm-content').first()).toContainText('edited by e2e');
});
test('invalid Rhai source: Format shows a parse error', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('invrhai');
const appId = await createAppViaApi(slug);
const scriptId = await createScriptViaApi(appId, 'bad-syntax');
cleanup.app(slug);
await page.goto(`/admin/scripts/${scriptId}`);
await fillCodeMirror(page, '.cm-content', 'this is not rhai @@@ {{{');
await page
.locator('.editor-header')
.getByRole('button', { name: 'Format' })
.click();
await expect(page.locator('.error.inline').first()).toBeVisible();
});
});
test.describe('B3 test-invoke', () => {
test('valid JSON body returns status + body in the result panel', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('inv-ok');
const appId = await createAppViaApi(slug);
const scriptId = await createScriptViaApi(appId, 'invoke-ok');
cleanup.app(slug);
await page.goto(`/admin/scripts/${scriptId}`);
// Body editor is the second .cm-content (source is first).
const bodyEditor = page.locator('.cm-content').nth(1);
await bodyEditor.click();
await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.press('Delete');
await page.keyboard.type('{"hello":"world"}');
await page.getByRole('button', { name: /^Send$/ }).click();
await expect(page.locator('.status')).toContainText('HTTP 200');
await expect(page.locator('.result pre')).toContainText('ok');
});
test('malformed JSON body: Format surfaces the parse error', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('inv-bad');
const appId = await createAppViaApi(slug);
const scriptId = await createScriptViaApi(appId, 'invoke-bad');
cleanup.app(slug);
await page.goto(`/admin/scripts/${scriptId}`);
const bodyEditor = page.locator('.cm-content').nth(1);
await bodyEditor.click();
await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.press('Delete');
await page.keyboard.type('{not valid json,');
// The Format button for the request body sits inside the
// Test-invoke card next to the body editor.
await page
.locator('.json-block')
.first()
.getByRole('button', { name: 'Format' })
.click();
await expect(page.locator('.error.inline').first()).toBeVisible();
});
});
test.describe('B3 settings', () => {
test('timeout input rejects zero and non-positive values', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('settz');
const appId = await createAppViaApi(slug);
const scriptId = await createScriptViaApi(appId, 'settings-target');
cleanup.app(slug);
await page.goto(`/admin/scripts/${scriptId}`);
await page.getByRole('button', { name: 'Settings' }).click();
const timeout = page.getByLabel(/Timeout/);
await timeout.fill('0');
const invalid = await timeout.evaluate((el: HTMLInputElement) => !el.validity.valid);
expect(invalid).toBe(true);
});
});
test.describe('B3 scripts role shadowing', () => {
test('viewer: no Delete header, no Save/Format on Edit, no Add route on Routing', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('vscr');
const username = uniqueUsername('viewer');
const { scriptId, userId } = await seedAppScriptAndMember({
slug,
username,
role: 'viewer'
});
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/scripts/${scriptId}`);
// Header Delete is hidden for non-admins.
await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0);
// Save/Format on the Edit tab are hidden for viewers.
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
await expect(
page.locator('.editor-header').getByRole('button', { name: 'Format' })
).toHaveCount(0);
// Test invoke is still visible (everyone with read access).
await expect(page.getByRole('button', { name: /^Send$/ })).toBeVisible();
// Routing tab loads, no +Add route.
await page.getByRole('button', { name: /Routing/ }).click();
await expect(page.getByRole('button', { name: /\+ Add route/ })).toHaveCount(0);
// Settings tab is absent for non-admins.
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
} finally {
await page.context().close();
}
});
test('viewer: CodeMirror is read-only', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('vro');
const username = uniqueUsername('viewer');
const { scriptId, userId } = await seedAppScriptAndMember({
slug,
username,
role: 'viewer'
});
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/scripts/${scriptId}`);
const cm = page.locator('.cm-content').first();
await expect(cm).toBeVisible();
// CodeMirror sets contenteditable=false when EditorView.editable.of(false)
// is in effect; that's the canonical signal for read-only mode.
await expect(cm).toHaveAttribute('contenteditable', 'false');
} finally {
await page.context().close();
}
});
test('editor: Save visible, Delete header hidden', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('escr');
const username = uniqueUsername('editor');
const { scriptId, userId } = await seedAppScriptAndMember({
slug,
username,
role: 'editor'
});
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/scripts/${scriptId}`);
// Editor sees Save (disabled until the buffer changes — that's fine).
await expect(page.getByRole('button', { name: /^Save$/ })).toBeVisible();
// Delete stays admin-only.
await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0);
// Settings stays admin-only.
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
} finally {
await page.context().close();
}
});
});
test.describe('B3 adversarial', () => {
test('infinite loop script hits the sandbox timeout', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('loop');
const appId = await createAppViaApi(slug);
const scriptId = await createScriptViaApi(
appId,
'inf-loop',
'loop { let x = 1; }'
);
cleanup.app(slug);
await page.goto(`/admin/scripts/${scriptId}`);
await page.getByRole('button', { name: /^Send$/ }).click();
// Either the status renders with a 5xx code, or an error
// banner shows up. Either way, the page recovers.
await Promise.race([
expect(page.locator('.status')).toBeVisible({ timeout: 30_000 }),
expect(page.locator('.error.inline').last()).toBeVisible({ timeout: 30_000 })
]);
// The dashboard must remain interactive after the timeout.
await page.getByRole('button', { name: 'Settings' }).click();
await expect(page.getByLabel(/Timeout/)).toBeVisible();
});
});

View File

@@ -0,0 +1,81 @@
import { expect, test } from '@playwright/test';
// Phase B8 — Cross-cutting security. Things that aren't tied to a
// single page: session handling, secret leakage, error states for
// missing resources, and a sanity check that no XSS sink fires
// anywhere in the dashboard's main authed routes.
const VALID_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const VALID_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
test.describe('B8 cross-cutting security', () => {
test('expired/stale token: any authed call redirects to /login', async ({ page }) => {
// Replace the storageState token with an obvious garbage
// value; the fetch wrapper treats 401 as "go to /login".
await page.goto('/admin/login');
await page.evaluate(() => {
localStorage.setItem('picloud.admin.token', 'expired-or-bogus-token');
});
await page.goto('/admin/apps');
await expect(page).toHaveURL(/\/admin\/login$/);
});
test('login response cookie is HttpOnly', async ({ request }) => {
const res = await request.post('/api/v1/admin/auth/login', {
data: { username: VALID_USERNAME, password: VALID_PASSWORD },
headers: { 'content-type': 'application/json' }
});
expect(res.ok()).toBe(true);
const headers = res.headers();
const setCookie = headers['set-cookie'];
// Backend may or may not set a cookie (the dashboard primarily
// uses bearer-in-localStorage). If it does, it must be
// HttpOnly so XSS can't exfiltrate it.
if (setCookie) {
expect(setCookie.toLowerCase()).toContain('httponly');
}
});
test('bootstrap password is not present in the DOM after login', async ({ page }) => {
await page.goto('/admin/apps');
const body = await page.locator('body').innerText();
expect(body).not.toContain(VALID_PASSWORD);
});
test('non-existent app slug shows a recoverable error, not a crash', async ({ page }) => {
await page.goto('/admin/apps/does-not-exist-e2e-9999');
// Page must render *something* and the layout must remain
// intact (header link to Apps still works).
await expect(page.getByRole('link', { name: 'Apps' })).toBeVisible();
// And surface the failure to the user — either a "couldn't
// load" message or a "back to apps" link.
const errorOrBack = page.locator('.error, a[href$="/admin/apps"]');
await expect(errorOrBack.first()).toBeVisible();
});
test('xss probe across major surfaces never fires a dialog', async ({ page }) => {
page.on('dialog', async (dialog) => {
await dialog.dismiss();
throw new Error(
`XSS sink fired — got a ${dialog.type()} dialog: "${dialog.message()}"`
);
});
// Cover each main authed route. None should evaluate any
// payload that earlier tests may have stored, and none should
// inject inline <script> tags from server responses.
for (const path of ['/admin/apps', '/admin/profile', '/admin/users']) {
await page.goto(path);
await page.waitForLoadState('domcontentloaded');
const inlineScripts = await page.locator('script[src=""], script:not([src])').count();
// Svelte itself injects no inline <script> in the
// production bundle; vite dev does, but never with
// onerror/alert payload text in them.
const evilInline = await page
.locator('script:has-text("alert"), script:has-text("__xss")')
.count();
expect(evilInline, `evil inline script tag on ${path}`).toBe(0);
expect(inlineScripts).toBeGreaterThanOrEqual(0); // sanity assertion, no crash
}
});
});

View File

@@ -0,0 +1,28 @@
import { expect, test } from '@playwright/test';
import { loginAsAdmin } from './fixtures/auth';
// A1 smoke: prove globalSetup + webServer + fixtures + proxy all work.
test.describe('smoke', () => {
test.describe('unauthenticated', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('root redirects to login and shows the form', async ({ page }) => {
await page.goto('/admin/');
await expect(page).toHaveURL(/\/admin\/login$/);
await expect(page.getByLabel('Username')).toBeVisible();
await expect(page.getByLabel('Password')).toBeVisible();
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
});
test('valid credentials land on the apps page', async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByRole('link', { name: 'Apps' })).toBeVisible();
});
});
test('admin storageState already lands on apps', async ({ page }) => {
await page.goto('/admin/');
await expect(page).toHaveURL(/\/admin\/apps$/);
});
});

View File

@@ -0,0 +1,224 @@
import { expect, type Browser, type Page, request } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
// Phase B6 — Instance Users (/admin/users). Covers the bootstrap
// admin's view of the user directory: invite, edit, deactivate,
// search, delete, plus the member-role redirect and adversarial
// inputs to the invite form.
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
async function createMember(username: string, password = 'e2e-member-pw'): Promise<string> {
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/admins', {
data: { username, password, instance_role: 'member' }
});
expect(res.ok()).toBe(true);
return ((await res.json()) as { id: string }).id;
} finally {
await api.dispose();
}
}
async function loginToken(username: string, password: string): Promise<string> {
const ctx = await request.newContext({ baseURL: API_BASE });
try {
const res = await ctx.post('/api/v1/admin/auth/login', {
data: { username, password },
headers: { 'content-type': 'application/json' }
});
expect(res.ok()).toBe(true);
return ((await res.json()) as { token: string }).token;
} finally {
await ctx.dispose();
}
}
async function pageWithToken(browser: Browser, token: string): Promise<Page> {
const ctx = await browser.newContext({ storageState: undefined });
const page = await ctx.newPage();
await page.goto('/admin/login');
await page.evaluate(
([key, value]) => {
localStorage.setItem(key, value);
},
['picloud.admin.token', token]
);
return page;
}
test.describe('B6 instance users', () => {
test('invite happy path: form → reveal modal → user in list', async ({
page,
uniqueUsername
}) => {
const username = uniqueUsername('inv');
await page.goto('/admin/users');
await page.getByRole('button', { name: '+ Invite user' }).click();
const modal = page.locator('form.modal');
await modal.getByLabel('Username').fill(username);
await modal.getByRole('radio', { name: /^Member/ }).check();
await modal.getByRole('button', { name: /^Create user$/ }).click();
// Reveal modal shows the one-time password.
const reveal = page.locator('.reveal-modal');
await expect(reveal).toBeVisible();
await expect(reveal).toContainText(/User created — /);
await expect(reveal.getByRole('button', { name: /^Done$/ })).toBeDisabled();
await reveal.getByRole('checkbox', { name: /shared this/i }).check();
await reveal.getByRole('button', { name: /^Done$/ }).click();
// Now in the table.
await expect(page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username })).toBeVisible();
// API cleanup — we don't have the user id from the UI alone.
const api = await adminApi();
try {
const list = await api.get('/api/v1/admin/admins');
const all = (await list.json()) as Array<{ id: string; username: string }>;
const u = all.find((x) => x.username === username);
if (u) cleanup.adminUser(u.id);
} finally {
await api.dispose();
}
});
test('username live validation: bad chars → submit disabled', async ({ page }) => {
await page.goto('/admin/users');
await page.getByRole('button', { name: '+ Invite user' }).click();
const modal = page.locator('form.modal');
await modal.getByLabel('Username').fill('UPPER_CASE_invalid');
await expect(modal.locator('small.invalid')).toContainText(/allowed pattern/i);
await modal.getByRole('radio', { name: /^Member/ }).check();
await expect(modal.getByRole('button', { name: /^Create user$/ })).toBeDisabled();
});
test('search filters the table by username', async ({ page, uniqueUsername }) => {
const target = uniqueUsername('hit');
const decoy = uniqueUsername('miss');
const ids = await Promise.all([createMember(target), createMember(decoy)]);
ids.forEach((id) => cleanup.adminUser(id));
await page.goto('/admin/users');
await page.getByPlaceholder(/Search by username/).fill(target);
await expect(page.locator('.row', { hasText: target })).toBeVisible();
await expect(page.locator('.row', { hasText: decoy })).toHaveCount(0);
});
test('deactivate confirm modal: Cancel keeps active, Deactivate flips, reactivate is one click', async ({
page,
uniqueUsername
}) => {
const username = uniqueUsername('toggle');
const userId = await createMember(username);
cleanup.adminUser(userId);
await page.goto('/admin/users');
await page.getByPlaceholder(/Search by username/).fill(username);
const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username });
await expect(row).toBeVisible();
// Deactivate opens the confirm modal.
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(username);
// Cancel leaves the user active.
await dialog.getByRole('button', { name: /^Cancel$/ }).click();
await expect(dialog).toHaveCount(0);
await expect(row).not.toContainText(/inactive/i);
// Open again and confirm — user becomes inactive.
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
await page.getByRole('dialog').getByRole('button', { name: /^Deactivate$/ }).click();
await expect(row).toContainText(/inactive/i);
// Reactivate is still one-click (non-destructive — no modal).
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Reactivate$/ }).click();
await expect(row).not.toContainText(/inactive/i);
});
test('delete: wrong phrase keeps disabled, right phrase removes the user', async ({
page,
uniqueUsername
}) => {
const username = uniqueUsername('del');
const userId = await createMember(username);
cleanup.adminUser(userId);
await page.goto('/admin/users');
await page.getByPlaceholder(/Search by username/).fill(username);
const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username });
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Delete$/ }).click();
const dialog = page.getByRole('dialog');
const confirm = dialog.getByRole('button', { name: /^Delete user$/ });
await expect(confirm).toBeDisabled();
await dialog.getByRole('textbox').fill('not-the-username');
await expect(confirm).toBeDisabled();
await dialog.getByRole('textbox').fill(username);
await expect(confirm).toBeEnabled();
await confirm.click();
await expect(page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username })).toHaveCount(0);
});
test('member-role user visiting /admin/users is bounced to profile with denied banner', async ({
browser,
uniqueUsername
}) => {
const username = uniqueUsername('memvw');
const password = 'e2e-member-pw';
const userId = await createMember(username, password);
cleanup.adminUser(userId);
const token = await loginToken(username, password);
const memberPage = await pageWithToken(browser, token);
try {
await memberPage.goto('/admin/users');
await expect(memberPage).toHaveURL(/\/admin\/profile\?denied=users$/);
await expect(memberPage.getByText(/don.?t have access to the Users page/i)).toBeVisible();
} finally {
await memberPage.context().close();
}
});
});
test.describe('B6 instance users adversarial', () => {
test('username too short: live invalid + submit disabled', async ({ page }) => {
await page.goto('/admin/users');
await page.getByRole('button', { name: '+ Invite user' }).click();
const modal = page.locator('form.modal');
await modal.getByLabel('Username').fill('a'); // 1 char — minimum is 2
await expect(modal.locator('small.invalid')).toBeVisible();
await modal.getByRole('radio', { name: /^Member/ }).check();
await expect(modal.getByRole('button', { name: /^Create user$/ })).toBeDisabled();
});
test('email with script tag fails validation, never executes', async ({ page }) => {
page.on('dialog', async (d) => {
await d.dismiss();
throw new Error(`Unexpected dialog: ${d.message()}`);
});
await page.goto('/admin/users');
await page.getByRole('button', { name: '+ Invite user' }).click();
const modal = page.locator('form.modal');
await modal.getByLabel(/Email/).fill('<script>alert(1)</script>@x');
await expect(modal.locator('small.invalid')).toContainText(/email/i);
});
});

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More