90 Commits

Author SHA1 Message Date
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
MechaCat02
e6fc6e6a0e test(picloud): close two app_members test gaps
- `membership_makes_app_appear_in_members_app_list` previously seeded
  the membership via the repo helper; switch to the public POST
  endpoint so the test actually exercises the full HTTP round-trip
  the dashboard depends on.
- Add `add_member_with_missing_user_id_is_rejected` to pin the
  Axum-JsonRejection 4xx contract on malformed POST bodies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:00:28 +02:00
MechaCat02
66b84abf6d refactor(manager-core): share resolve_app helper across handlers
apps_api.rs and app_members_api.rs each grew a near-identical local
`resolve_app` that parses an id-or-slug param and translates None into
their own AppNotFound variant. Promote the lookup half to
`app_repo::resolve_app` (returns `Result<Option<AppLookup>, ...>`) and
let callers handle the None → not-found mapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:00:21 +02:00
MechaCat02
a9fc838577 fix(dashboard): redirect after a member removes themselves
A member-with-app_admin who removes their own membership keeps a now-
broken Members tab open until reload — `myRole` is only computed once
in `loadApp`, and the next `/apps/{slug}` fetch would 403 anyway.

After the DELETE succeeds, if the removed user is the caller, navigate
back to /apps instead of refreshing the local member list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:00:13 +02:00
MechaCat02
2948875a96 fix(api): make app_members POST and PATCH atomic
The previous handlers did `find()` then `upsert()` in two round-trips:

- POST: two concurrent grants both pass the duplicate check; the
  second `upsert` silently rewrites the role instead of returning
  409, weakening the "409 on duplicate" contract under load.
- PATCH: a concurrent DELETE between `find` and `upsert` makes PATCH
  silently re-create a row instead of returning 404, weakening the
  "404 if no existing membership" contract.

Adds two repo primitives that fold the check into the write:

- `try_insert` — `INSERT ... ON CONFLICT DO NOTHING RETURNING`; None
  return ⇒ already exists ⇒ 409.
- `update_role` — `UPDATE ... WHERE app_id AND user_id RETURNING`;
  None return ⇒ no row ⇒ 404.

Handlers use these directly; existing `upsert` stays for test helpers
that genuinely want upsert semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:00:04 +02:00
MechaCat02
b7175cc581 chore: rustfmt fixups for app_members files
Trailing-comma format! cleanup from `cargo fmt --all`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:39:25 +02:00
MechaCat02
d40ebf65a2 docs(blueprint): document app members CRUD endpoints in §11.6
Adds a new "App Member Management Endpoints" subsection covering the
shipped CRUD surface, the `my_role` field on the app lookup response,
and the no-last-app-admin-guard decision (with the corrected rationale
that owners — not admins — are what makes orphaning impossible).

Also updates the deferred-surfaces line so it stops claiming dashboard
member management is still curl-only, and bumps the Last Updated header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:38:36 +02:00
MechaCat02
816a13b920 feat(dashboard): Members tab on the app detail page
A new "Members" tab is rendered between Domains and Settings for
callers whose `my_role` on the app is `app_admin` (owners always;
explicit member-app_admins; admins do not see it — they're only
implicit editors and can't manage memberships).

The tab lets the caller:

- See every explicit member of the app with username, email, instance-
  role chip, app-role chip, and joined date. Inactive users render
  greyed-out so admins know the row exists.
- Pick a `member`-instance user from a dropdown and grant viewer /
  editor / app_admin access. The dropdown is populated from
  `/admin/admins` filtered to active members not already on the app.
- Promote / demote / remove existing members via the shared
  `ActionMenu` kebab. Removal goes through `ConfirmModal`.

Member-with-app_admin callers see a disabled add form with an
explanatory message — they have authority to manage memberships but
can't browse the user directory (gated on `InstanceManageUsers`),
which is a known phase-3.5 caveat to revisit in a follow-up.

Also extends `RoleChip` with an `appRole` prop and palette for app
roles, and adds an `appMembers` namespace to api.ts mirroring the
`domains` shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:37:36 +02:00
MechaCat02
248571dcde test(picloud): authz coverage for app members CRUD
Adds 16 integration tests against a real Postgres covering the new
/api/v1/admin/apps/{id_or_slug}/members surface:

- list / add / patch / remove against an explicit member row
- 409 on duplicate, 422 on inactive target, 422 on owner/admin target
- 404 on PATCH without an existing row; 204 idempotent DELETE
- viewer-as-bob receives 403 on every mutating verb
- both slug and UUID paths resolve to the same body
- bob-with-app_admin can manage the member list, including removing
  himself (load-bearing for the no-last-app-admin-guard decision)
- granting a `member` user a viewer membership makes the app appear
  in their `GET /admin/apps` list (was empty before)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:33:59 +02:00
MechaCat02
85bbabcbdf feat(api): app members CRUD endpoints
Adds /api/v1/admin/apps/{id_or_slug}/members[/{user_id}]:

- GET    list members (joined with admin_users via list_for_app_enriched)
- POST   grant membership — 201 with enriched DTO
         409 on duplicate (promotions go through PATCH on purpose so
         the UI can surface "already a member" cleanly)
         422 if the target user is deactivated
         422 if the target's instance_role isn't `member` — owners and
         admins already have implicit authority, so an explicit row
         would be dead weight
- PATCH  change role — 200 with enriched DTO
         404 if no existing membership (use POST to create)
- DELETE remove — 204, idempotent (matches the repo's `remove`
         contract; 204 also when the row never existed)

All four gated on `Capability::AppAdmin(app_id)`. Editors and viewers
get 403 from list and never see the dashboard's Members tab.

No last-app-admin guard: owners implicitly satisfy AppAdmin via
`role_grants`, so removing the last explicit app_admin row cannot
permanently orphan an app — an owner can always re-issue grants.

Wires through picloud/src/lib.rs by splitting the Postgres app_members
repo Arc into two trait views (AppMembersRepository for CRUD, AuthzRepo
for the existing capability lookups) without re-instantiating against
the pool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:31:08 +02:00
MechaCat02
1314420fca feat(repo): join app_members with admin_users via list_for_app_enriched
Adds `AppMembershipDetail` (membership row + joined username, email,
instance_role, is_active) and `list_for_app_enriched` on
`AppMembersRepository`. The Postgres impl does a single JOIN on
admin_users ordered by username, so the upcoming `GET
/apps/{id}/members` handler can render its table without an N+1 fetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:27:02 +02:00
MechaCat02
33697a2766 feat(api): expose caller's effective app role via my_role
GET /api/v1/admin/apps/{id_or_slug} now returns an `AppRole`-typed
`my_role` alongside the existing app fields, computed server-side from
the Principal: `Owner → app_admin` and `Admin → editor` (both
implicit per blueprint §11.6), `Member → app_members.role` (looked up
via the existing `AuthzRepo::membership` already in `AppsState`).

The dashboard uses this single field to decide whether to render
admin-only surfaces (Members tab, etc.) instead of duplicating the
implicit-grant rules on the client side — keeps API and UI gate logic
identical with one round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:25:23 +02:00
MechaCat02
6eb32a78bf feat(dashboard): adopt ActionMenu for user row actions
Replaces the inline row-action buttons on the Users page with the new
shared ActionMenu kebab. Drops the redundant `is_active` toggle from the
edit form (Activate/Deactivate already lives in the kebab).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:21:08 +02:00
MechaCat02
fc35d59236 fix(dashboard): show pic_ prefix on API-key rows
The backend's ApiKeyDto.prefix is just the 8-char public head
(e.g. "PKXPCPH3"); the actual token the user pastes into their
CLI is "pic_PKXPCPH3…". Display the full visible identifier so
operators can match a row against the token in their notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:27:55 +02:00
MechaCat02
0c9f11558a feat(manager-core,picloud): accept email on admin create + patch
The /admins create/patch endpoints now plumb email through to the
repo so the dashboard's invite + edit forms aren't silently dropping
it on the floor. Discovered during smoke testing — the database
column existed and was exposed in the response DTO, but neither
the request DTO nor the repo's create() accepted it.

CreateAdminRequest gains optional email; PatchAdminRequest gains
email with JSON Merge Patch semantics:
  absent     → don't change
  null       → clear (write NULL)
  "<string>" → set to that value

The tri-state needs Option<Option<String>> with a tiny custom
deserializer; serde collapses absent and null otherwise.

normalize_email() trims, treats blanks as None, and rejects
obviously bogus values (no '@', >254 chars) with a 422. Real
email verification is a future concern.

Repo trait gains an email parameter on create() and a new
update_email() method. The unique-violation branch in create now
inspects constraint() to distinguish duplicate username from
duplicate email.

Integration test exercises create-with-email, PATCH null clears,
PATCH value sets, PATCH without email key no-ops on email.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:27:52 +02:00
MechaCat02
39a6df2bfe fix(picloud): use is_some_and in /auth/me test (clippy)
clippy::map_unwrap_or — drop the map().unwrap_or(false) for the
flatter is_some_and(Value::is_null).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:08:09 +02:00
MechaCat02
d21cbdb164 chore(dashboard): remove superseded /admins page
/admin/users is a strict superset of the pre-3.5 /admin/admins
page (adds role chip, email column, search, role-aware affordance
hiding, and the password-reveal flow), so the old page would only
split traffic and confuse muscle memory.

Also drops the AdminUserRecord type alias kept in place to ease
the transition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:05:57 +02:00
MechaCat02
700ae7b7d1 feat(dashboard): users admin page with invite/edit/delete + password reveal
/admin/users is the owner+admin surface for managing the platform's
user list. Members get bounced to /profile?denied=users.

Invite generates a random 16-char password client-side, POSTs the
new user, and surfaces the cleartext exactly once in a yellow-
bordered reveal modal with a Copy button and an "I've shared it"
acknowledgement gate. Owner role is intentionally not in the create
form — promote via Edit after creation, matching the backend's
deliberate-step comment.

Edit handles username, email, role (with affordance hiding: admins
see admin/member only), is_active toggle, and a separate "Reset
password" button that re-uses the same reveal flow. Delete uses
ConfirmModal with confirmPhrase=username and explains the
last-owner/last-admin 422s up front.

Username + email validated client-side against the same patterns
the backend enforces so the form fails fast rather than always
on the round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:05:02 +02:00
MechaCat02
f16ff22a5a feat(dashboard): profile page with API-key list, mint, and revoke
/admin/profile is the per-principal page available to every
authenticated user (owner, admin, member). Shows the caller's
identity (username, role chip, email, id) plus a full API-key
list/mint/revoke surface.

Minting reveals the raw token exactly once in a yellow-bordered
panel with a Copy button and an "I've saved it" acknowledgement
gate before the Done button enables, matching the spec's one-shot
secret-display pattern.

Live mirrors the backend bound-key guard: picking an app from the
binding dropdown drops any instance:* scopes from the selection and
greys out their checkboxes with a tooltip, so submit never hits a
422 on that case.

Also surfaces a one-shot info banner when /admin/users redirects a
member here with ?denied=users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:02:40 +02:00
MechaCat02
bd2258499e feat(dashboard): role-gated Users link and profile chip in nav
The header nav now shows a Users link only for owners/admins, and
the username block becomes a profile-link chip rendering the role
pill next to the name. Both react to the currentUser store, so they
update on login without an extra fetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:00:20 +02:00
MechaCat02
df691038d7 feat(dashboard): add MeDto, AdminDto, apiKeys + role/password helpers
Extends api.ts with the Phase 3.5 wire types (InstanceRole, Scope,
MeDto, AdminDto, ApiKeyDto, MintApiKey*) and the matching apiKeys
namespace. AdminUser in auth.ts now carries instance_role and email,
so layout/store consumers see the role without a separate fetch.

Adds two tiny lib helpers used by the upcoming profile/users pages:
RoleChip.svelte for the colored owner/admin/member pill, and
password-gen.ts for crypto.getRandomValues-backed temporary
passwords used in user-invite + reset-password reveals.

AdminUserRecord stays as a deprecated alias until /admins is
retired in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:00:06 +02:00
MechaCat02
3688c26cb4 feat(manager-core,picloud): expose instance_role + email on /auth/me
Login and /auth/me now return the same shape — id, username,
instance_role, email — so the dashboard can gate UI on role from
either the login response or the layout's me() refetch without an
extra round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:39:06 +02:00
MechaCat02
2aab92af31 style: cargo fmt across Phase 3.5 changes
Pure formatting pass — no behavior changes. Catches the line-wrapping
drift across the new authz / api_keys / middleware / handler edits
that piled up during the implementation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:21:37 +02:00
MechaCat02
063595be31 test(picloud): integration tests for Phase 3.5 authz (11 cases)
Covers the matrix laid out in the plan:
* bootstrap admin lands as Owner
* owner / admin / member access matrices on the default app
* bearer pic_ key and cookie session resolve to the same Principal
* read-only key cannot write (scope intersection)
* bound key cannot escape its app
* member listing isolation at SQL for /admin/apps + /admin/scripts
* deactivating a user expires every API key for them
* mint rejects bound key carrying instance:* scopes (422)
* list_active_owners returns the right set for the startup warning

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:19:24 +02:00
MechaCat02
30a1584667 chore: bump product to 0.6.0; multi-owner startup warning
Phase 3.5 ships → product minor bump under pre-1.0 rules (any surface
bump triggers minor). Schema is now 6 (0006_users_authz.sql); API
remains 1 (additive endpoints + new credential type, no breaking
shape changes). docs/versioning.md updated.

main.rs gets warn_on_multi_owner_install() which fires once after
bootstrap when more than one active owner exists — points the
operator at PATCH /admin/admins/{id} for cleanup. Soft-fail on DB
error (does not block startup).

The api-test schema assertion was updated to expect 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:15:45 +02:00
MechaCat02
d229120df6 feat(manager-core,picloud): per-handler require(capability) checks
Every admin endpoint now resolves Capability for the loaded resource
and calls authz::require(...) before mutating. Forbidden → 403; every
handler State carries an Arc<dyn AuthzRepo>, plumbed from the new
PostgresAppMembersRepository in the picloud binary.

* api.rs (scripts): AppRead/AppWriteScript/AppLogRead bound to
  script.app_id after load. List branches on instance_role:
  Member → list_for_user, others → list (or ?app= filtered).
* apps_api.rs: InstanceCreateApp on POST; AppRead on get/list_domains;
  AppAdmin on patch/delete/slug:check; AppManageDomains on
  create_domain/delete_domain. list_apps membership-filters for Member.
* admin_users_api.rs: InstanceManageUsers on every endpoint. Mint +
  PATCH refuse to grant Owner unless the caller is already Owner
  (CannotEscalate / 422), on top of the existing last-owner guard.
* route_admin.rs: AppRead on list/check/match; AppWriteRoute on
  create/delete bound to the route's actual app_id (added a
  RouteRepository::get(uuid) lookup so delete binds correctly).
* AppRepository + ScriptRepository gain list_for_user(user_id) for
  membership-filtered listings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:13:45 +02:00
MechaCat02
8659a58eb2 feat(manager-core,picloud): api_keys_api + deactivation cascade
* auth: generate_api_key() mints pic_<base32(32 bytes)>, splits the
  indexed 8-char prefix, and Argon2-hashes the body. Adds the
  data-encoding workspace dep for unpadded base32.
* api_keys_api: POST /api/v1/admin/api-keys (mint, returns raw_token
  exactly once), GET (caller's own, no raw), DELETE {id} (caller's
  own; 404 deliberately covers both 'missing' and 'not yours').
  Mint validation rejects bound keys carrying instance:* scopes (422).
* AdminsState gains the api keys repo; PATCH set_active(false) now
  expires every active key for that user alongside session wipe —
  Phase 3.5 deactivation symmetry.
* picloud lib wires PostgresApiKeyRepository through AuthDeps into
  AdminsState + ApiKeysState; api_keys_router merges into the
  guarded_admin layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:00:33 +02:00
MechaCat02
5f7ddd23ab feat(manager-core,picloud): bearer pic_ keys land in Principal
* auth_middleware: split into resolve_principal → verify_session OR
  verify_api_key (selected by the pic_ prefix). Both paths converge on
  Principal as the request extension; require_admin keeps working as
  a #[deprecated] alias for require_authenticated. AuthState gains an
  api_keys repo; the cookie path is unchanged.
* api-key path takes the first 8 chars after pic_ as the indexed
  lookup key, Argon2-verifies each candidate, soft-rejects deactivated
  users, and updates last_used_at inline.
* auth_api: /auth/me now consumes Extension<Principal> and re-fetches
  the user row so username updates surface immediately.
* picloud: AuthDeps + AuthState wired with PostgresApiKeyRepository;
  the layer call switches to require_authenticated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:55:38 +02:00
MechaCat02
44db8d107a feat(manager-core): repos + admin patch for Phase 3.5 schema
* admin_user_repo: surface instance_role + email on AdminUserRow /
  Credentials; create() now takes instance_role; add
  update_instance_role, list_active_owners, count_other_active_owners.
* admin_users_api: DTO + create/patch accept instance_role (defaults
  to Admin on create — only env-var bootstrap defaults to Owner).
  PATCH and DELETE enforce the last-owner guard alongside the
  existing last-active-admin guard.
* app_members_repo: new — implements AuthzRepo::membership via the
  app_members table plus upsert/remove/list_for_user/list_for_app.
* api_key_repo: new — create / find_active_by_prefix / touch_last_used
  / list_for_user / get / delete_by_id_and_user / expire_all_for_user.
  Separates ApiKeyRow (no hash) from ApiKeyVerification (hash, for
  the middleware verifier) so handlers can't leak the hash.
* auth_bootstrap + picloud tests: pass Owner on the bootstrap seed
  and on the test admin seed respectively; in-memory test repo
  implements the new trait methods.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:49:54 +02:00
MechaCat02
abaabb68d8 feat(manager-core): add authz module with can() / require()
Implements the three-layer capability check from blueprint §11.6:
role grant (instance role + app_members) ∩ scope intersection (for
API keys) ∩ app binding (for bound keys). Capabilities are finer than
scopes (AppWriteScript vs AppWriteRoute, AppManageDomains vs
AppAdmin) so a script:write-only key cannot mutate routes; scopes
stay at the seven values the blueprint locks down.

In-memory AuthzRepo fixture in the test module covers the full
matrix: owner / admin / member behavior, scope intersection, bound
key isolation, and instance:* denial on bound keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:40:04 +02:00
MechaCat02
fd6f2b1f13 feat(shared): add Principal, InstanceRole, AppRole, Scope, ApiKeyId
Cross-crate authn/authz data types for Phase 3.5. The Principal struct
is the resolved caller identity that auth_middleware will produce for
both cookie sessions and bearer API keys; the role/scope enums mirror
the DB CHECK constraints from migration 0006 and round-trip through
their stable string forms.

UserId is a type alias for AdminUserId — the auth layer treats an
admin row as the principal identity, so the alias avoids a rename of
the existing id type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:35:25 +02:00
MechaCat02
d435322f9c feat(manager-core): add 0006 users_authz migration
Adds instance_role + reserved email/mfa_secret columns to admin_users,
creates app_members for per-app role grants, and creates api_keys for
bearer-token credentials. Schema snapshot re-blessed.

Reserves invites and service_accounts shapes in a trailing comment
block — both land in their own migrations when those flows ship.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:33:40 +02:00
MechaCat02
5546323cdc docs(blueprint): add §11.6 Phase 3.5 users, roles, and bearer-token auth
Specifies the unified can(principal, capability) gate, instance roles
(owner/admin/member), per-app memberships (app_admin/editor/viewer),
pic_-prefixed API keys, and the schema rooms for invites / MFA /
service accounts. Updates §12 Phase 3 to add 3c as a third foundation
piece alongside 3a (admin auth) and 3b (multi-app scoping).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:31:25 +02:00
MechaCat02
a393f11344 feat(dashboard): auto-slug app names and infer route host kind from input
Two related polish passes on forms the operator hits most.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:03:05 +02:00
MechaCat02
6891496589 feat(manager-core): admin auth gate (Phase 3a)
Closes the regression risk of the admin API and dashboard being open
to anyone reaching the bound port. Required foundation before v1.1
data-plane services land.

Per-user accounts (admin_users), Argon2id passwords, env-var bootstrap
of the first admin that becomes inert once any admin exists, opaque
32-byte session token doubling as bearer credential, 24h sliding TTL
configurable via PICLOUD_SESSION_TTL_HOURS. is_active column lets
admins be deactivated without losing audit history; last-active-admin
guard on DELETE and on PATCH that flips is_active to false (sessions
also wiped on deactivation).

require_admin middleware fronts every /api/v1/admin/* route. The data
plane (/api/v1/execute/{id}), /healthz, /version, and user routes
stay open. picloud admin reset-password <username> subcommand handles
recovery without going through HTTP.

Dashboard gains /admin/login and /admin/admins surfaces, a top-bar
user menu, and a token store with a localStorage echo so refreshes
don't sign you out. Cookie-based auth works in parallel for non-SPA
clients.

Forward compatibility: future RBAC tables (admin_roles,
admin_user_roles) join on admin_users.id; the auth middleware is the
seam where role checks slot in. Email, 2FA, passkeys, and personal
API tokens are all additive without touching admin_users.

Blueprint §11.4 updated to reflect what actually shipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:30:25 +02:00
MechaCat02
646bd55174 docs: design Phase 3 admin auth and multi-app scoping
Adds blueprint sections 11.4 (admin auth) and 11.5 (app scoping) and
restructures the section 12 roadmap to put both ahead of v1.1, since
retrofitting app_id into KV/docs/users schemas after they ship is far
more expensive than adding it now.

Admin auth: per-user admin_users (not a shared secret), Argon2id,
env-var bootstrap that becomes inert after first admin exists, session
token doubling as bearer token, 24h sliding TTL. Schema designed
forward-compatible with later RBAC.

App scoping: apps own scripts/routes/domains. Domain claims at app
level (exact / wildcard / {param} parameterized) with collision check
at claim time, so route-conflict errors stay strictly intra-app.
Two-phase orchestrator dispatch (Host → app → route trie). Slug rename
keeps the old slug as a permanent redirect until another app claims
it. Fresh-install migration seeds a Hello World app; upgrades go into
a default app instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:58:37 +02:00
MechaCat02
56de652f7a fix(dashboard): keep selection visible against active-line tint
CodeMirror layers the active-line background above the selection layer,
so the previous opaque active-line color hid selections on the current
line. Bumps selection alpha and switches active-line to a subtle sky
tint, with the brighter gutter line number as the primary cue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:58:27 +02:00
MechaCat02
3d4c7b160b fix(dashboard): preserve blank lines and improve Rhai parser errors
Two follow-ups on the Rhai formatter shipped in 0.5.1.

* Formatter no longer collapses user-intent blank lines between
  statements. The lexer now records a side-channel list of offsets
  where the source contained two-or-more consecutive newlines; the
  formatter consults it and emits a single blank in the same spot
  (rustfmt's `blank_lines_upper_bound = 1` policy applied strictly —
  the prior forced blank between top-level `fn` decls is dropped, so
  the formatter never *adds* a blank the user didn't write).
* Parse errors now read like Rhai's own diagnostics. `expect()` takes
  an optional `role` hint and each call site supplies a domain phrase
  (`name of a variable`, `function name in function declaration`,
  `'{' to begin a block`, `name of a property`, …). End-of-input is
  reported as `script is incomplete`. The dashboard banner renders
  `Parse error: {message} (line L, position C)` with 1-based
  coordinates, matching Rhai's format exactly.

The FormatError payload also keeps the byte `offset` so callers that
want to drive the editor cursor (CodeMirror works in offsets) still
have it.

Also folds the workspace Cargo.lock version bumps for 0.5.1 — the
lock-file rewrite that should have travelled with the prior commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:26:42 +02:00
MechaCat02
267c40f59c feat(dashboard): Rhai source formatter with Format button
AST-based pretty-printer: tab-indented, 100-col print width, normalized
operator spacing, predictable reflow of long argument lists, comments
preserved verbatim. Refuses to emit on a parse failure and returns the
first error, so the Edit-tab button mirrors the JSON Format UX —
inline `.error.inline` banner; doc untouched on failure.

Patch bump to `0.5.1` across Cargo.toml workspace.package, the
dashboard package.json, and the docs/versioning.md Current versions
table.

Bundle delta versus the previous build: +6 KB raw, +1.5 KB gzipped.
Cumulative since the start of this work: +28 KB raw, +7.3 KB gzipped —
well under the +100 KB budget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:51:19 +02:00
MechaCat02
1dc53a0226 feat(dashboard): go-to-definition, Ctrl+Click, and find-usages panel
`F12` jumps the cursor to the declaration of the identifier under the
caret; `Shift+F12` opens a CodeMirror panel listing every range that
resolves to the same declaration (declaration site plus all usages),
with line-number snippets that click to jump. `Ctrl+Click` (Cmd+Click
on macOS) on an identifier is wired to the same goto path. `Esc`
closes the panel.

All three features read from `rhaiAnalysisField`, so they automatically
follow the cached parse + symbol table. The panel's styling lives in a
CodeMirror `baseTheme` keyed to the dashboard's slate palette.

Bundle delta: +3 KB raw, +1 KB gzipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:46:30 +02:00
MechaCat02
6cdb1244b8 feat(dashboard): scope-aware autocomplete for user-defined symbols
Adds a second CompletionSource that reads the Rhai parser's symbol
table. On a plain word it surfaces in-scope `let`/`const`/`fn` names
(with the function signature in the popup's detail line); on `obj.`
it suggests the field names of an object-map literal that initialized
`obj`. Composes with the existing static `ctx.*` / `log::*` source via
`autocompletion({ override: [scopeCompletionSource, rhaiCompletions] })`,
which CodeMirror merges. The static source now bows out on generic
`name.` rather than flooding the popup with keywords.

A new StateField caches one parse + symbol-table per editor state and
rebuilds on doc change. Bundle delta: +18 KB raw, +4.7 KB gzipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:44:41 +02:00
MechaCat02
bc8b512b56 feat(dashboard): hand-rolled Rhai parser + symbol table + Vitest
Foundation for upcoming editor features (scope-aware autocomplete,
goto-def / find-usages, source formatter). Hand-rolled recursive
descent in TypeScript with Pratt precedence climbing for expressions,
error-tolerant so partial trees stay usable while the user is typing.
Symbol table walks the AST to produce per-scope declarations, usage
sites, and object-literal field maps. Vitest added as a dev-only
runner; no editor wiring in this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:38:15 +02:00
MechaCat02
a80e6d1ca4 feat(dashboard): CodeMirror editors for Rhai source + JSON
Replaces the four <textarea> usages with a CodeMirror 6 editor that
brings, just by being a real editor: syntax highlighting, line
numbers, bracket matching, multi-cursor, proper undo/redo, and
search/replace (Ctrl+F / Ctrl+H). Plus a Rhai-aware autocomplete and
a "Format JSON" button on the test-invoke panels.

Per discussion, deliberately did NOT add: LSP, go-to-definition,
Rhai formatter (none exists), or anything else IDE-shaped. The
existing CodeEditor component is wired so swapping the language
extension later is a one-line change.

Lay of the land (from the research pass):
  * No CodeMirror Rhai package exists on npm.
  * No Rhai formatter exists anywhere.
  * The Rhai authors publish a TextMate grammar at
    rhaiscript/vscode-rhai (MPL-2.0). We don't load the full
    grammar (would cost ~250KB of vscode-textmate + oniguruma);
    we cite it as the source-of-truth for our keyword/operator
    lists in a small custom StreamLanguage.
  * rhaiscript/lsp exists but is experimental + unmaintained
    since 2023; skipped.

Files:
  * dashboard/src/lib/editor-theme.ts — CodeMirror theme +
    HighlightStyle wired to the existing slate/sky palette so the
    editor blends into the cards instead of looking transplanted.
  * dashboard/src/lib/rhai-mode.ts — StreamLanguage tokenizer for
    Rhai with the upstream grammar's keyword/operator lists, plus
    a completion source pulling ctx.* / log::* from our SDK
    contract suite (the authoritative list).
  * dashboard/src/lib/CodeEditor.svelte — wraps EditorView with
    two-way $bindable() value, language picker ('rhai' | 'json'),
    placeholder, minHeight props. Guards against the update
    listener echoing parent-driven changes back as edits.
  * Replaces textareas in:
      routes/+page.svelte                 — create form source
      routes/scripts/[id]/+page.svelte    — Edit tab source +
                                            Test invoke body +
                                            headers
  * Format buttons next to the body/headers editors run
    JSON.stringify(JSON.parse(value), null, 2); errors surface
    inline next to the button without trashing the field.

Bundle:
  * +~430KB to the CodeMirror chunk in dashboard build (~150KB
    gzipped on the wire). Lazy-loaded — only fetched when a route
    that uses CodeEditor renders.
  * `npm install` clean, 0 vulnerabilities, `npm run check`
    clean, `npm run build` clean.

No backend / API / SDK / schema / wire changes. No version bumps.
2026-05-23 22:52:07 +02:00
MechaCat02
0eaf4aee69 chore: versioning guardrail script for the structural checks
scripts/check-versioning.sh — POSIX sh, no dependencies, runs in
under a second. Three structural checks that don't need git
history (the parts that do need it stay deferred until we have CI
and a CHANGELOG file):

  1. Migration filenames are sequential 0001_*.sql, 0002_*.sql, ...
     with no gaps or duplicates. Catches "added migration with
     the wrong number" before it reaches review.
  2. SDK_VERSION in shared::version parses as MAJOR.MINOR
     (numeric, no extra components). Catches accidental
     PATCH-style bumps like "1.1.0" that the SemVer-for-SDKs
     rule in docs/versioning.md forbids.
  3. [workspace.package].version parses as MAJOR.MINOR.PATCH
     (numeric). Catches typos in the product version bump
     that would silently downgrade everywhere.

Each check prints a precise FAIL message identifying the
offending file/value when it trips. Verified by deliberately
breaking each one and confirming exit=1.

Run manually as `bash scripts/check-versioning.sh` for now; wires
into CI as soon as we have one. Docs/versioning.md updated to
reflect that items (3) and (4) are now in place and (5) is partly
implemented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:21:37 +02:00
156 changed files with 27416 additions and 785 deletions

View File

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

11
.gitignore vendored
View File

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

View File

@@ -8,6 +8,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint. Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
**Current focus (Phase 4, v1.1.0):** SDK foundation + stdlib utilities — the shape every v1.1.x service module hangs off, see [docs/sdk-shape.md](docs/sdk-shape.md). Subsequent v1.1.x releases (KV in v1.1.1, docs in v1.1.2, …) fill it in; see blueprint §12 for the full table. Phase 3 shipped end-to-end: admin auth, multi-app scoping, and Phase 3.5 capability gating (`manager-core::authz::{can, require, Capability}` + migration `0006_users_authz.sql`). Every v1.1+ table starts with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE` and every Rhai SDK call resolves its app from the execution context.
## Three-Service Architecture ## Three-Service Architecture
The platform splits into three logical services, each backed by a `*-core` library crate so the same logic runs in single-process MVP mode and split-process cluster mode: The platform splits into three logical services, each backed by a `*-core` library crate so the same logic runs in single-process MVP mode and split-process cluster mode:
@@ -26,7 +28,7 @@ In MVP, all three run in one process (`picloud` binary). In cluster mode, each r
Versioned API surfaces live under `/api/v{N}/...`. See [docs/versioning.md](docs/versioning.md) for the full scheme. Versioned API surfaces live under `/api/v{N}/...`. See [docs/versioning.md](docs/versioning.md) for the full scheme.
- `/api/v1/admin/*` — manager (control plane: script CRUD, routes CRUD + check + match, logs, config) - `/api/v1/admin/*` — manager (control plane: script CRUD, routes CRUD + check + match, logs, config; apps CRUD once Phase 3b lands)
- `/api/v1/execute/{id}` — orchestrator (data plane: invoke a script by ID, always-available bypass) - `/api/v1/execute/{id}` — orchestrator (data plane: invoke a script by ID, always-available bypass)
- `/admin/*` — dashboard SPA (SvelteKit, `paths.base = '/admin'`) - `/admin/*` — dashboard SPA (SvelteKit, `paths.base = '/admin'`)
- `/healthz` — liveness (string `"ok"`) - `/healthz` — liveness (string `"ok"`)
@@ -37,12 +39,16 @@ Reserved path prefixes (rejected at route creation): `/api/`, `/admin/`, `/healt
Caddy fronts everything. Same Caddyfile shape works for single-node and cluster — only upstream targets change. Caddy fronts everything. Same Caddyfile shape works for single-node and cluster — only upstream targets change.
**Param syntax convention:** route paths use `:name` (e.g., `/users/:id`); domains (once apps land) use `{name}` (e.g., `{tenant}.example.com`). These are deliberately distinct — never use `:` in a domain context or `{}` in a route-path context.
**Two-phase dispatch (Phase 3b onward):** the orchestrator first resolves `Host` → app (most-specific domain claim wins), then runs that app's route trie. The route matcher itself is unchanged and never sees other apps' routes.
## Tech Stack ## Tech Stack
- **Rust 1.92+** workspace, pinned via `rust-toolchain.toml` - **Rust 1.92+** workspace, pinned via `rust-toolchain.toml`
- **Axum** for HTTP, **Tokio** async, **sqlx** for Postgres - **Axum** for HTTP, **Tokio** async, **sqlx** for Postgres
- **Rhai** embedded scripting (in `executor-core`) - **Rhai** embedded scripting (in `executor-core`)
- **PostgreSQL 15+** with `pgcrypto` and (v1.1+) `hstore` - **PostgreSQL 15+** with `pgcrypto`. v1.1+ data-plane tables use JSONB for value columns (hstore was considered for KV and rejected — see blueprint §8.1).
- **SvelteKit** dashboard, static adapter, CodeMirror 6 for the script editor - **SvelteKit** dashboard, static adapter, CodeMirror 6 for the script editor
- **Caddy 2** reverse proxy (auto-HTTPS in prod) - **Caddy 2** reverse proxy (auto-HTTPS in prod)
- **Docker Compose** for dev and single-node prod - **Docker Compose** for dev and single-node prod
@@ -97,9 +103,24 @@ docs/
- **Honor the three-service boundary.** Don't reach across `*-core` crates. If `orchestrator-core` needs something from `manager-core`, define a trait in `shared` and inject the impl. - **Honor the three-service boundary.** Don't reach across `*-core` crates. If `orchestrator-core` needs something from `manager-core`, define a trait in `shared` and inject the impl.
- **`executor-core` has no Postgres dependency.** Data-plane services (kv, docs, users — v1.1+) come in via injected `ServiceProvider` traits. - **`executor-core` has no Postgres dependency.** Data-plane services (kv, docs, users — v1.1+) come in via injected `ServiceProvider` traits.
- **Database writes only from `manager-core`.** `orchestrator-core` reads scripts (cached); `executor-core` doesn't touch the DB. - **Database writes only from `manager-core`.** `orchestrator-core` reads scripts (cached); `executor-core` doesn't touch the DB.
- **Stateful SDK services use the handle pattern + `SdkCallCx`.** Collection-scoped surfaces look like `kv::collection("x").get(k)`, not `kv::get("x", k)`. Every service trait method takes `&SdkCallCx` and **MUST** derive `app_id` from `cx.app_id` — never trust a script-passed `app_id`. That is the cross-app isolation boundary. See [docs/sdk-shape.md](docs/sdk-shape.md).
- **MVP builds only the `picloud` all-in-one binary.** The three split binaries exist as skeletons so the crate boundaries stay honest; flesh them out only when cluster mode is being implemented. - **MVP builds only the `picloud` all-in-one binary.** The three split binaries exist as skeletons so the crate boundaries stay honest; flesh them out only when cluster mode is being implemented.
- **Trunk-based dev.** See [docs/git-workflow.md](docs/git-workflow.md). No long-lived branches. Feature flags for incomplete work. - **Trunk-based dev.** See [docs/git-workflow.md](docs/git-workflow.md). No long-lived branches. Feature flags for incomplete work.
## Runtime configuration
Environment variables consumed by the `picloud` binary:
| Variable | Default | Purpose |
|---|---|---|
| `PICLOUD_BIND` | `0.0.0.0:8080` | HTTP listen address. Port 8080 is owned by another process on this host — override locally. |
| `PICLOUD_MAX_CONCURRENT_EXECUTIONS` | `32` | Global concurrency cap on data-plane script executions. Overflow returns HTTP 503 with `Retry-After: 1` immediately (no queue). |
| `DATABASE_URL` | — | Required. Postgres connection string. |
| `PICLOUD_SESSION_TTL_HOURS` | `24` | Sliding-window session lifetime. |
| `PICLOUD_SANDBOX_MAX_*` | conservative defaults | Per-knob admin ceilings on Rhai sandbox overrides. See `manager-core::sandbox::SandboxCeiling`. |
## Out of MVP ## Out of MVP
Queue triggers, cron triggers, SMTP ingress, KV / docs / email / users / HTTP SDKs in scripts, interceptors, workflows, function-to-function `invoke()`, auth, multi-tenancy, secrets, metrics dashboard. All deferred to v1.1+ per the blueprint. Don't pre-build for them — but don't make decisions that close the door on them either. Queue triggers, cron triggers, SMTP ingress, KV / docs / email / users / HTTP SDKs in scripts, interceptors, workflows, function-to-function `invoke()`, secrets, metrics dashboard. All deferred to v1.1+ per the blueprint. Don't pre-build for them — but don't make decisions that close the door on them either.
**Pulled forward to Phase 3 (pre-v1.1):** admin auth, multi-app scoping. Cross-app data sharing (export/import) stays at v1.3+; the initial cut enforces strict isolation. See blueprint §11.5.

415
Cargo.lock generated
View File

@@ -40,12 +40,74 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]] [[package]]
name = "assert-json-diff" name = "assert-json-diff"
version = "2.0.2" version = "2.0.2"
@@ -56,6 +118,21 @@ dependencies = [
"serde_json", "serde_json",
] ]
[[package]]
name = "assert_cmd"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6"
dependencies = [
"anstyle",
"bstr",
"libc",
"predicates",
"predicates-core",
"predicates-tree",
"wait-timeout",
]
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -206,6 +283,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -215,6 +301,17 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "bstr"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
dependencies = [
"memchr",
"regex-automata",
"serde",
]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.3" version = "3.20.3"
@@ -281,6 +378,52 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@@ -387,6 +530,12 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
@@ -413,6 +562,12 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "difflib"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -425,6 +580,27 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "directories"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@@ -489,6 +665,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]] [[package]]
name = "figment" name = "figment"
version = "0.10.19" version = "0.10.19"
@@ -509,6 +691,15 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "float-cmp"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "flume" name = "flume"
version = "0.11.1" version = "0.11.1"
@@ -983,6 +1174,12 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -1050,6 +1247,12 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.2" version = "0.8.2"
@@ -1134,6 +1337,12 @@ dependencies = [
"spin 0.5.2", "spin 0.5.2",
] ]
[[package]]
name = "normalize-line-endings"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -1204,6 +1413,18 @@ dependencies = [
"portable-atomic", "portable-atomic",
] ]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@@ -1233,6 +1454,17 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]] [[package]]
name = "pear" name = "pear"
version = "0.2.9" version = "0.2.9"
@@ -1273,12 +1505,13 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "picloud" name = "picloud"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"axum", "axum",
"axum-test", "axum-test",
"chrono",
"figment", "figment",
"picloud-executor-core", "picloud-executor-core",
"picloud-manager-core", "picloud-manager-core",
@@ -1293,11 +1526,33 @@ dependencies = [
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid",
]
[[package]]
name = "picloud-cli"
version = "0.6.0"
dependencies = [
"anyhow",
"assert_cmd",
"chrono",
"clap",
"directories",
"libc",
"picloud-shared",
"predicates",
"reqwest",
"rpassword",
"serde",
"serde_json",
"tempfile",
"tokio",
"toml",
] ]
[[package]] [[package]]
name = "picloud-executor" name = "picloud-executor"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"picloud-executor-core", "picloud-executor-core",
@@ -1309,7 +1564,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-executor-core" name = "picloud-executor-core"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"picloud-shared", "picloud-shared",
@@ -1323,7 +1578,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-manager" name = "picloud-manager"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"picloud-manager-core", "picloud-manager-core",
@@ -1335,17 +1590,23 @@ dependencies = [
[[package]] [[package]]
name = "picloud-manager-core" name = "picloud-manager-core"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"argon2",
"async-trait", "async-trait",
"axum", "axum",
"base64",
"chrono", "chrono",
"data-encoding",
"picloud-orchestrator-core", "picloud-orchestrator-core",
"picloud-shared", "picloud-shared",
"rand 0.8.6",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"sqlx", "sqlx",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio",
"tracing", "tracing",
"url", "url",
"uuid", "uuid",
@@ -1353,7 +1614,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-orchestrator" name = "picloud-orchestrator"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"picloud-orchestrator-core", "picloud-orchestrator-core",
@@ -1365,7 +1626,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-orchestrator-core" name = "picloud-orchestrator-core"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -1384,7 +1645,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-shared" name = "picloud-shared"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
@@ -1463,6 +1724,36 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "predicates"
version = "3.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"
dependencies = [
"anstyle",
"difflib",
"float-cmp",
"normalize-line-endings",
"predicates-core",
"regex",
]
[[package]]
name = "predicates-core"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144"
[[package]]
name = "predicates-tree"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2"
dependencies = [
"predicates-core",
"termtree",
]
[[package]] [[package]]
name = "pretty_assertions" name = "pretty_assertions"
version = "1.4.1" version = "1.4.1"
@@ -1658,6 +1949,29 @@ dependencies = [
"bitflags", "bitflags",
] ]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.14" version = "0.4.14"
@@ -1683,7 +1997,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"futures-channel",
"futures-core", "futures-core",
"futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
@@ -1766,6 +2082,17 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rpassword"
version = "7.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "835a57a69104632d64deb0df2e09a69945cd7a6eab4070fc9b1d7e50cf6c3edc"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.10" version = "0.9.10"
@@ -1786,6 +2113,16 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rtoolbox"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "rust-multipart-rfc7578_2" name = "rust-multipart-rfc7578_2"
version = "0.8.0" version = "0.8.0"
@@ -1807,6 +2144,19 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.40" version = "0.23.40"
@@ -2281,6 +2631,12 @@ dependencies = [
"unicode-properties", "unicode-properties",
] ]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@@ -2318,6 +2674,25 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "termtree"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
[[package]] [[package]]
name = "thin-vec" name = "thin-vec"
version = "0.2.18" version = "0.2.18"
@@ -2737,6 +3112,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.1" version = "1.23.1"
@@ -2767,6 +3148,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
@@ -3020,6 +3410,15 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.60.2" version = "0.60.2"

View File

@@ -9,10 +9,11 @@ members = [
"crates/picloud-manager", "crates/picloud-manager",
"crates/picloud-orchestrator", "crates/picloud-orchestrator",
"crates/picloud-executor", "crates/picloud-executor",
"crates/picloud-cli",
] ]
[workspace.package] [workspace.package]
version = "0.5.0" version = "0.6.0"
edition = "2021" edition = "2021"
rust-version = "1.92" rust-version = "1.92"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -66,6 +67,13 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
url = "2" url = "2"
urlencoding = "2" urlencoding = "2"
# Auth (admin users + sessions + API keys)
argon2 = "0.5"
rand = { version = "0.8", features = ["getrandom"] }
sha2 = "0.10"
base64 = "0.22"
data-encoding = "2.6"
[workspace.lints.rust] [workspace.lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
-- Phase 3a admin auth — see blueprint §11.4.
--
-- Per-user platform-operator accounts (distinct from the v1.1+ `users`
-- table, which is for script-end users). Every authenticated admin is a
-- full admin in this cut; role/permission tables will be added later
-- without touching this schema.
--
-- `admin_sessions.token_hash` stores SHA-256 of the raw token; the raw
-- value only ever exists in the login response, the HttpOnly cookie, and
-- bearer-token requests. Cascade on user delete kills the user's sessions
-- automatically — which is also why deactivating a user can simply wipe
-- their rows instead of marking each session expired.
CREATE TABLE admin_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ
);
CREATE TABLE admin_sessions (
token_hash TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX admin_sessions_user_idx ON admin_sessions (user_id);
CREATE INDEX admin_sessions_expiry_idx ON admin_sessions (expires_at);

View File

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

View File

@@ -0,0 +1,112 @@
-- Phase 3.5 users, roles, and bearer-token auth — see blueprint §11.6.
--
-- Lays down the schema that the unified can(principal, capability) gate
-- runs against, plus the api_keys table that backs `Authorization: Bearer
-- pic_…` credentials. No data-plane impact; Phase 4 SDKs (KV, docs, HTTP,
-- cron) will plug into this same authz pipeline.
--
-- Three changes:
-- 1. admin_users gains instance_role ('owner'/'admin'/'member') plus a
-- reserved email column and mfa_secret slot (neither is read yet).
-- Every pre-existing row becomes 'owner' via the DEFAULT — Phase 3a
-- had no role concept, so promoting all current admins to owner is
-- the only safe interpretation (and matches the spec). The Rust
-- startup path logs a warning when more than one active owner
-- exists, so operators can demote extras via the admin PATCH.
-- 2. app_members records explicit per-app grants for 'member' users.
-- Owners and admins get implicit grants in code (owner→app_admin
-- everywhere, admin→editor everywhere); no rows here.
-- 3. api_keys holds Argon2id-hashed bearer credentials. Lookup is
-- prefix-indexed (first 8 chars after `pic_`) then hash-verified;
-- raw token only ever exists in the POST response. Optional
-- expires_at / app_id implement TTL and app-binding respectively.
ALTER TABLE admin_users
-- DEFAULT 'owner' so the Phase 3a bootstrap admin (and any other
-- pre-existing rows) become full owners without a backfill step.
-- Multi-owner installs are flagged at startup; demotion is a
-- deliberate PATCH, not an automatic migration choice.
ADD COLUMN instance_role TEXT NOT NULL DEFAULT 'owner'
CHECK (instance_role IN ('owner', 'admin', 'member')),
-- Reserved for the eventual invite flow + Phase 4 user-management
-- SDK. UNIQUE so we never end up with two rows claiming the same
-- contact. Nullable because pre-existing admins have no email on
-- file and we don't want to force a backfill.
ADD COLUMN email TEXT UNIQUE,
-- Reserved slot for TOTP secrets. Not read in Phase 3.5 — present
-- now only to avoid a schema bump when MFA lands.
ADD COLUMN mfa_secret TEXT;
CREATE INDEX admin_users_instance_role_idx ON admin_users (instance_role);
-- Per-(user, app) explicit grant. Owners and admins do NOT appear here;
-- their app authority is implicit in their instance_role and resolved in
-- code. Only 'member' users need rows in this table — without one, a
-- member has no access to the app at all.
CREATE TABLE app_members (
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('app_admin', 'editor', 'viewer')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (app_id, user_id)
);
-- Lookup pattern is "what apps can this user see?" — needed for the
-- membership-filtered GET /admin/apps and GET /admin/scripts.
CREATE INDEX app_members_user_id_idx ON app_members (user_id);
-- Bearer API keys. Format on the wire: `pic_<base32(32 random bytes)>`.
-- prefix = first 8 chars after `pic_` (indexed for O(1) candidate lookup)
-- hash = Argon2id PHC of the full body after `pic_`
-- Raw value is returned exactly once at mint time and never persisted.
--
-- Optional fields:
-- expires_at: TTL. Lookup always filters `expires_at IS NULL OR > NOW()`.
-- app_id : "bound key" — capability checks deny any App*(other_app),
-- regardless of the owning user's role. Cannot combine with
-- instance:* scopes (validated in the mint handler, not SQL).
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
hash TEXT NOT NULL,
prefix TEXT NOT NULL,
name TEXT NOT NULL,
-- TEXT[] keeps the scope set open to additions without a migration;
-- the seven legal values are validated at mint time in Rust, not by
-- a CHECK constraint here (so new scopes can land without a schema
-- bump).
scopes TEXT[] NOT NULL,
app_id UUID NULL REFERENCES apps(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NULL,
last_used_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX api_keys_prefix_idx ON api_keys (prefix);
CREATE INDEX api_keys_user_id_idx ON api_keys (user_id);
-- ---------------------------------------------------------------------
-- Reserved schema room (not built in Phase 3.5)
-- ---------------------------------------------------------------------
-- These tables are deliberately commented out, not created. They are
-- listed here so the design intent is visible at the migration boundary
-- and future authors don't reinvent the shape. Each lands in its own
-- numbered migration when the corresponding flow ships.
--
-- CREATE TABLE invites (
-- token TEXT PRIMARY KEY, -- raw at email-link time, hashed at rest
-- email TEXT NOT NULL,
-- instance_role TEXT NULL CHECK (instance_role IN ('owner','admin','member')),
-- app_id UUID NULL REFERENCES apps(id) ON DELETE CASCADE,
-- app_role TEXT NULL CHECK (app_role IN ('app_admin','editor','viewer')),
-- invited_by UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
-- expires_at TIMESTAMPTZ NOT NULL,
-- consumed_at TIMESTAMPTZ NULL
-- );
--
-- CREATE TABLE service_accounts (
-- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- name TEXT NOT NULL,
-- owning_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE RESTRICT,
-- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- );

View File

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

View File

@@ -0,0 +1,152 @@
//! CRUD over the `admin_sessions` table.
//!
//! The token never appears in this module — only its SHA-256 hash. The
//! raw value lives in `auth::GeneratedToken` long enough to hit the
//! cookie and the JSON response, then is forgotten. Lookups also filter
//! expired rows at query time so a delayed prune sweep can never extend
//! a session's life.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::AdminUserId;
use sqlx::PgPool;
#[derive(Debug, thiserror::Error)]
pub enum AdminSessionRepositoryError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
}
/// Result of a session lookup. Includes the user id (for auth context)
/// and the existing `expires_at` so the middleware can decide whether
/// the sliding window bump is worth a write.
#[derive(Debug, Clone)]
pub struct AdminSessionLookup {
pub user_id: AdminUserId,
pub expires_at: DateTime<Utc>,
}
#[async_trait]
pub trait AdminSessionRepository: Send + Sync {
async fn create(
&self,
user_id: AdminUserId,
token_hash: &str,
expires_at: DateTime<Utc>,
) -> Result<(), AdminSessionRepositoryError>;
/// Look up a session by token hash. Returns `None` for missing or
/// already-expired rows (the query filters them).
async fn lookup(
&self,
token_hash: &str,
) -> Result<Option<AdminSessionLookup>, AdminSessionRepositoryError>;
/// Sliding-window bump. Sets `last_used_at = NOW()` and `expires_at`
/// to the supplied value.
async fn touch(
&self,
token_hash: &str,
new_expires_at: DateTime<Utc>,
) -> Result<(), AdminSessionRepositoryError>;
async fn delete(&self, token_hash: &str) -> Result<(), AdminSessionRepositoryError>;
/// Delete every session belonging to a user. Used when the user is
/// deactivated or has their password reset out-of-band — both
/// invalidate all current logins for that account.
async fn delete_for_user(
&self,
user_id: AdminUserId,
) -> Result<u64, AdminSessionRepositoryError>;
/// Sweep expired rows. The auth middleware filters expired rows on
/// lookup, so this is just bounded-growth hygiene, not correctness.
async fn prune_expired(&self) -> Result<u64, AdminSessionRepositoryError>;
}
pub struct PostgresAdminSessionRepository {
pool: PgPool,
}
impl PostgresAdminSessionRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl AdminSessionRepository for PostgresAdminSessionRepository {
async fn create(
&self,
user_id: AdminUserId,
token_hash: &str,
expires_at: DateTime<Utc>,
) -> Result<(), AdminSessionRepositoryError> {
sqlx::query(
"INSERT INTO admin_sessions (token_hash, user_id, expires_at) \
VALUES ($1, $2, $3)",
)
.bind(token_hash)
.bind(user_id.into_inner())
.bind(expires_at)
.execute(&self.pool)
.await?;
Ok(())
}
async fn lookup(
&self,
token_hash: &str,
) -> Result<Option<AdminSessionLookup>, AdminSessionRepositoryError> {
let row: Option<(uuid::Uuid, DateTime<Utc>)> = sqlx::query_as(
"SELECT user_id, expires_at FROM admin_sessions \
WHERE token_hash = $1 AND expires_at > NOW()",
)
.bind(token_hash)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|(uid, exp)| AdminSessionLookup {
user_id: uid.into(),
expires_at: exp,
}))
}
async fn touch(
&self,
token_hash: &str,
new_expires_at: DateTime<Utc>,
) -> Result<(), AdminSessionRepositoryError> {
sqlx::query(
"UPDATE admin_sessions SET last_used_at = NOW(), expires_at = $2 \
WHERE token_hash = $1",
)
.bind(token_hash)
.bind(new_expires_at)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete(&self, token_hash: &str) -> Result<(), AdminSessionRepositoryError> {
sqlx::query("DELETE FROM admin_sessions WHERE token_hash = $1")
.bind(token_hash)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete_for_user(
&self,
user_id: AdminUserId,
) -> Result<u64, AdminSessionRepositoryError> {
let res = sqlx::query("DELETE FROM admin_sessions WHERE user_id = $1")
.bind(user_id.into_inner())
.execute(&self.pool)
.await?;
Ok(res.rows_affected())
}
async fn prune_expired(&self) -> Result<u64, AdminSessionRepositoryError> {
let res = sqlx::query("DELETE FROM admin_sessions WHERE expires_at <= NOW()")
.execute(&self.pool)
.await?;
Ok(res.rows_affected())
}
}

View File

@@ -0,0 +1,466 @@
//! CRUD over the `admin_users` table.
//!
//! Password hashes go in and come out as opaque strings — this module
//! never inspects or computes them; that's `auth.rs`'s job. The "must
//! keep at least one active admin" guard is implemented as a separate
//! count query the API layer composes around `set_active` / `delete`.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, InstanceRole};
use sqlx::PgPool;
#[derive(Debug, thiserror::Error)]
pub enum AdminUserRepositoryError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("not found: {0}")]
NotFound(AdminUserId),
#[error("username already taken: {0}")]
DuplicateUsername(String),
#[error("email already taken: {0}")]
DuplicateEmail(String),
#[error("invalid instance_role stored in DB: {0}")]
InvalidInstanceRole(String),
}
/// Row returned to handlers and bootstrap. Never includes the password
/// hash by accident — that lives in `AdminUserCredentials` (separate
/// fetch from `get_credentials_by_username`).
#[derive(Debug, Clone)]
pub struct AdminUserRow {
pub id: AdminUserId,
pub username: String,
pub is_active: bool,
pub instance_role: InstanceRole,
pub email: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
}
/// Credentials fetched for the login path only. Splitting the hash off
/// from the public row makes it obvious in handler code which calls
/// touch a secret.
#[derive(Debug, Clone)]
pub struct AdminUserCredentials {
pub id: AdminUserId,
pub username: String,
pub password_hash: String,
pub is_active: bool,
pub instance_role: InstanceRole,
}
#[async_trait]
pub trait AdminUserRepository: Send + Sync {
async fn get(&self, id: AdminUserId) -> Result<Option<AdminUserRow>, AdminUserRepositoryError>;
async fn get_by_username(
&self,
username: &str,
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError>;
async fn get_credentials_by_username(
&self,
username: &str,
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError>;
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
/// Create a new admin. `instance_role` defaults to `Owner` for the
/// env-var bootstrap path; admin-creates-admin flows pass an
/// explicit role. `email` is optional — pass `None` to leave the
/// column NULL.
async fn create(
&self,
username: &str,
password_hash: &str,
instance_role: InstanceRole,
email: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError>;
async fn update_username(
&self,
id: AdminUserId,
username: &str,
) -> Result<AdminUserRow, AdminUserRepositoryError>;
async fn update_password_hash(
&self,
id: AdminUserId,
password_hash: &str,
) -> Result<AdminUserRow, AdminUserRepositoryError>;
/// Set or clear the email address. `None` writes NULL to the column.
async fn update_email(
&self,
id: AdminUserId,
email: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError>;
/// Update the instance_role. Used by `PATCH /api/v1/admin/admins/{id}`;
/// callers enforce the last-owner guard (`count_other_active_owners`)
/// before invoking when role transitions away from `Owner`.
async fn update_instance_role(
&self,
id: AdminUserId,
instance_role: InstanceRole,
) -> Result<AdminUserRow, AdminUserRepositoryError>;
async fn set_active(
&self,
id: AdminUserId,
is_active: bool,
) -> Result<AdminUserRow, AdminUserRepositoryError>;
async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError>;
async fn touch_last_login(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError>;
/// Count of `is_active = true` rows. Used at bootstrap to decide
/// whether to seed the first admin.
async fn count_active(&self) -> Result<i64, AdminUserRepositoryError>;
/// Count of `is_active = true` rows excluding the given id. Used by
/// last-admin protection: "would deactivating / deleting this user
/// leave zero active admins?"
async fn count_active_excluding(
&self,
id: AdminUserId,
) -> Result<i64, AdminUserRepositoryError>;
/// All active owners — used for the multi-owner startup warning.
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
/// Count of active owners excluding the given id. Used by the
/// last-owner guard when demoting / deactivating / deleting an
/// owner: "would this leave zero owners?"
async fn count_other_active_owners(
&self,
id: AdminUserId,
) -> Result<i64, AdminUserRepositoryError>;
}
pub struct PostgresAdminUserRepository {
pool: PgPool,
}
impl PostgresAdminUserRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl AdminUserRepository for PostgresAdminUserRepository {
async fn get(&self, id: AdminUserId) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
let row = sqlx::query_as::<_, AdminUserRecord>(
"SELECT id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at \
FROM admin_users WHERE id = $1",
)
.bind(id.into_inner())
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn get_by_username(
&self,
username: &str,
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
let row = sqlx::query_as::<_, AdminUserRecord>(
"SELECT id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at \
FROM admin_users WHERE username = $1",
)
.bind(username)
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn get_credentials_by_username(
&self,
username: &str,
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError> {
let row = sqlx::query_as::<_, AdminCredsRecord>(
"SELECT id, username, password_hash, is_active, instance_role \
FROM admin_users WHERE username = $1",
)
.bind(username)
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
let rows = sqlx::query_as::<_, AdminUserRecord>(
"SELECT id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at \
FROM admin_users ORDER BY username",
)
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn create(
&self,
username: &str,
password_hash: &str,
instance_role: InstanceRole,
email: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let res = sqlx::query_as::<_, AdminUserRecord>(
"INSERT INTO admin_users (username, password_hash, instance_role, email) \
VALUES ($1, $2, $3, $4) \
RETURNING id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at",
)
.bind(username)
.bind(password_hash)
.bind(instance_role.as_str())
.bind(email)
.fetch_one(&self.pool)
.await;
match res {
Ok(row) => row.try_into(),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
// username and email both have unique constraints; the
// create path can collide on either, so peek at the
// constraint name to surface the right error.
if e.constraint() == Some("admin_users_email_key") {
Err(AdminUserRepositoryError::DuplicateEmail(
email.unwrap_or("").to_string(),
))
} else {
Err(AdminUserRepositoryError::DuplicateUsername(
username.to_string(),
))
}
}
Err(e) => Err(e.into()),
}
}
async fn update_username(
&self,
id: AdminUserId,
username: &str,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let res = sqlx::query_as::<_, AdminUserRecord>(
"UPDATE admin_users SET username = $2, updated_at = NOW() \
WHERE id = $1 \
RETURNING id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at",
)
.bind(id.into_inner())
.bind(username)
.fetch_optional(&self.pool)
.await;
match res {
Ok(Some(row)) => row.try_into(),
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
),
Err(e) => Err(e.into()),
}
}
async fn update_password_hash(
&self,
id: AdminUserId,
password_hash: &str,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let row = sqlx::query_as::<_, AdminUserRecord>(
"UPDATE admin_users SET password_hash = $2, updated_at = NOW() \
WHERE id = $1 \
RETURNING id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at",
)
.bind(id.into_inner())
.bind(password_hash)
.fetch_optional(&self.pool)
.await?;
row.ok_or(AdminUserRepositoryError::NotFound(id))
.and_then(TryInto::try_into)
}
async fn update_email(
&self,
id: AdminUserId,
email: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let res = sqlx::query_as::<_, AdminUserRecord>(
"UPDATE admin_users SET email = $2, updated_at = NOW() \
WHERE id = $1 \
RETURNING id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at",
)
.bind(id.into_inner())
.bind(email)
.fetch_optional(&self.pool)
.await;
match res {
Ok(Some(row)) => row.try_into(),
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
AdminUserRepositoryError::DuplicateEmail(email.unwrap_or("").to_string()),
),
Err(e) => Err(e.into()),
}
}
async fn update_instance_role(
&self,
id: AdminUserId,
instance_role: InstanceRole,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let row = sqlx::query_as::<_, AdminUserRecord>(
"UPDATE admin_users SET instance_role = $2, updated_at = NOW() \
WHERE id = $1 \
RETURNING id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at",
)
.bind(id.into_inner())
.bind(instance_role.as_str())
.fetch_optional(&self.pool)
.await?;
row.ok_or(AdminUserRepositoryError::NotFound(id))
.and_then(TryInto::try_into)
}
async fn set_active(
&self,
id: AdminUserId,
is_active: bool,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let row = sqlx::query_as::<_, AdminUserRecord>(
"UPDATE admin_users SET is_active = $2, updated_at = NOW() \
WHERE id = $1 \
RETURNING id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at",
)
.bind(id.into_inner())
.bind(is_active)
.fetch_optional(&self.pool)
.await?;
row.ok_or(AdminUserRepositoryError::NotFound(id))
.and_then(TryInto::try_into)
}
async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> {
let res = sqlx::query("DELETE FROM admin_users WHERE id = $1")
.bind(id.into_inner())
.execute(&self.pool)
.await?;
if res.rows_affected() == 0 {
return Err(AdminUserRepositoryError::NotFound(id));
}
Ok(())
}
async fn touch_last_login(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> {
sqlx::query("UPDATE admin_users SET last_login_at = NOW() WHERE id = $1")
.bind(id.into_inner())
.execute(&self.pool)
.await?;
Ok(())
}
async fn count_active(&self) -> Result<i64, AdminUserRepositoryError> {
let (count,): (i64,) =
sqlx::query_as("SELECT COUNT(*)::BIGINT FROM admin_users WHERE is_active")
.fetch_one(&self.pool)
.await?;
Ok(count)
}
async fn count_active_excluding(
&self,
id: AdminUserId,
) -> Result<i64, AdminUserRepositoryError> {
let (count,): (i64,) =
sqlx::query_as("SELECT COUNT(*)::BIGINT FROM admin_users WHERE is_active AND id <> $1")
.bind(id.into_inner())
.fetch_one(&self.pool)
.await?;
Ok(count)
}
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
let rows = sqlx::query_as::<_, AdminUserRecord>(
"SELECT id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at \
FROM admin_users \
WHERE is_active AND instance_role = 'owner' \
ORDER BY username",
)
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn count_other_active_owners(
&self,
id: AdminUserId,
) -> Result<i64, AdminUserRepositoryError> {
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*)::BIGINT FROM admin_users \
WHERE is_active AND instance_role = 'owner' AND id <> $1",
)
.bind(id.into_inner())
.fetch_one(&self.pool)
.await?;
Ok(count)
}
}
#[derive(sqlx::FromRow)]
struct AdminUserRecord {
id: uuid::Uuid,
username: String,
is_active: bool,
instance_role: String,
email: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
last_login_at: Option<DateTime<Utc>>,
}
impl TryFrom<AdminUserRecord> for AdminUserRow {
type Error = AdminUserRepositoryError;
fn try_from(r: AdminUserRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: r.id.into(),
username: r.username,
is_active: r.is_active,
instance_role: InstanceRole::from_db_str(&r.instance_role).ok_or(
AdminUserRepositoryError::InvalidInstanceRole(r.instance_role),
)?,
email: r.email,
created_at: r.created_at,
updated_at: r.updated_at,
last_login_at: r.last_login_at,
})
}
}
#[derive(sqlx::FromRow)]
struct AdminCredsRecord {
id: uuid::Uuid,
username: String,
password_hash: String,
is_active: bool,
instance_role: String,
}
impl TryFrom<AdminCredsRecord> for AdminUserCredentials {
type Error = AdminUserRepositoryError;
fn try_from(r: AdminCredsRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: r.id.into(),
username: r.username,
password_hash: r.password_hash,
is_active: r.is_active,
instance_role: InstanceRole::from_db_str(&r.instance_role).ok_or(
AdminUserRepositoryError::InvalidInstanceRole(r.instance_role),
)?,
})
}
}

View File

@@ -0,0 +1,533 @@
//! `/api/v1/admin/admins/*` — admin user CRUD. Guarded by
//! `require_admin`; every authenticated admin can call all of these.
//! Role/permission walls land later (see blueprint §11.4 — no
//! privilege levels in this cut).
//!
//! "Last active admin" protection lives at the service layer (not just
//! the DB) so it can produce a clean 422 with a human-readable message
//! rather than a SQL constraint violation. Deactivating a user also
//! wipes their sessions; deleting cascades through the FK.
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::get;
use axum::{Extension, Router};
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, InstanceRole, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::admin_session_repo::AdminSessionRepository;
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
use crate::api_key_repo::ApiKeyRepository;
use crate::auth::hash_password;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
/// Validation knobs are tuned by NIST 800-63B-ish guidance: username is
/// a strict ASCII subset so the lookup column stays predictable, and
/// password has a minimum length but no complexity rules (complexity
/// rules push users to predictable patterns).
const USERNAME_MIN: usize = 2;
const USERNAME_MAX: usize = 32;
const PASSWORD_MIN: usize = 8;
#[derive(Clone)]
pub struct AdminsState {
pub users: Arc<dyn AdminUserRepository>,
pub sessions: Arc<dyn AdminSessionRepository>,
/// Phase 3.5 deactivation symmetry — flipping `is_active = false`
/// also expires every active API key for that user so cookie and
/// bearer credentials become inert at the same moment.
pub keys: Arc<dyn ApiKeyRepository>,
/// Capability gate: every endpoint here requires
/// `InstanceManageUsers` (owner / admin).
pub authz: Arc<dyn AuthzRepo>,
}
pub fn admins_router(state: AdminsState) -> Router {
Router::new()
.route("/admins", get(list_admins).post(create_admin))
.route(
"/admins/{id}",
get(get_admin).patch(patch_admin).delete(delete_admin),
)
.with_state(state)
}
// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------
#[derive(Debug, Serialize)]
pub struct AdminDto {
pub id: AdminUserId,
pub username: String,
pub is_active: bool,
pub instance_role: InstanceRole,
pub email: Option<String>,
pub created_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
}
impl From<AdminUserRow> for AdminDto {
fn from(r: AdminUserRow) -> Self {
Self {
id: r.id,
username: r.username,
is_active: r.is_active,
instance_role: r.instance_role,
email: r.email,
created_at: r.created_at,
last_login_at: r.last_login_at,
}
}
}
#[derive(Debug, Deserialize)]
pub struct CreateAdminRequest {
pub username: String,
pub password: String,
/// Defaults to `Admin` when absent — minting an owner via the API
/// is a deliberate step. The env-var bootstrap path is the only
/// channel that defaults to `Owner`.
#[serde(default = "default_create_role")]
pub instance_role: InstanceRole,
/// Optional contact email. Blank/whitespace is normalized to None.
#[serde(default)]
pub email: Option<String>,
}
const fn default_create_role() -> InstanceRole {
InstanceRole::Admin
}
#[derive(Debug, Deserialize, Default)]
pub struct PatchAdminRequest {
pub username: Option<String>,
pub password: Option<String>,
pub is_active: Option<bool>,
pub instance_role: Option<InstanceRole>,
/// JSON Merge Patch (RFC 7396) semantics for email:
/// absent → don't change
/// null → clear (set DB column to NULL)
/// "<string>" → set to that string
/// `Option<Option<T>>` is the idiomatic Rust shape for that
/// tri-state; the custom deserializer below distinguishes the
/// "missing" case from the "present-and-null" case that serde
/// would otherwise collapse together.
#[allow(clippy::option_option)]
#[serde(default, deserialize_with = "deserialize_present_optional")]
pub email: Option<Option<String>>,
}
#[allow(clippy::option_option)]
fn deserialize_present_optional<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
T: serde::Deserialize<'de>,
D: serde::Deserializer<'de>,
{
Ok(Some(Option::<T>::deserialize(deserializer)?))
}
// ----------------------------------------------------------------------------
// Handlers
// ----------------------------------------------------------------------------
async fn list_admins(
State(state): State<AdminsState>,
Extension(principal): Extension<Principal>,
) -> Result<Json<Vec<AdminDto>>, AdminApiError> {
require(
state.authz.as_ref(),
&principal,
Capability::InstanceManageUsers,
)
.await?;
let rows = state.users.list().await?;
Ok(Json(rows.into_iter().map(Into::into).collect()))
}
async fn get_admin(
State(state): State<AdminsState>,
Extension(principal): Extension<Principal>,
Path(id): Path<AdminUserId>,
) -> Result<Json<AdminDto>, AdminApiError> {
require(
state.authz.as_ref(),
&principal,
Capability::InstanceManageUsers,
)
.await?;
state
.users
.get(id)
.await?
.map(AdminDto::from)
.map(Json)
.ok_or(AdminApiError::NotFound(id))
}
async fn create_admin(
State(state): State<AdminsState>,
Extension(principal): Extension<Principal>,
Json(input): Json<CreateAdminRequest>,
) -> Result<(StatusCode, Json<AdminDto>), AdminApiError> {
require(
state.authz.as_ref(),
&principal,
Capability::InstanceManageUsers,
)
.await?;
// Minting an owner via the API requires the caller to ALSO be an
// owner — admin cannot self-elevate (or elevate someone else)
// beyond their own ceiling. Owner-creation by env-var bootstrap
// bypasses this path.
if input.instance_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner
{
return Err(AdminApiError::CannotEscalate);
}
let username = input.username.trim();
validate_username(username)?;
validate_password(&input.password)?;
let email = normalize_email(input.email.as_deref())?;
let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
let row = state
.users
.create(username, &hash, input.instance_role, email.as_deref())
.await?;
Ok((StatusCode::CREATED, Json(row.into())))
}
async fn patch_admin(
State(state): State<AdminsState>,
Extension(principal): Extension<Principal>,
Path(id): Path<AdminUserId>,
Json(input): Json<PatchAdminRequest>,
) -> Result<Json<AdminDto>, AdminApiError> {
require(
state.authz.as_ref(),
&principal,
Capability::InstanceManageUsers,
)
.await?;
// Verify the target exists upfront — keeps the error path uniform
// for "rename a missing user" etc.
let current = state
.users
.get(id)
.await?
.ok_or(AdminApiError::NotFound(id))?;
let mut latest: Option<AdminUserRow> = None;
if let Some(raw_username) = input.username.as_deref() {
let new_username = raw_username.trim();
validate_username(new_username)?;
latest = Some(state.users.update_username(id, new_username).await?);
}
if let Some(new_password) = input.password.as_deref() {
validate_password(new_password)?;
let hash = hash_password(new_password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
latest = Some(state.users.update_password_hash(id, &hash).await?);
// Best practice: rotating your own password should still keep
// your session alive, so we don't wipe sessions here. (If we
// wanted "log everyone else out on password change", that'd be
// a `delete_for_user` + re-issue current session. Out of scope
// for the initial cut.)
}
if let Some(email_patch) = input.email.as_ref() {
// email_patch is Some(None) → clear, Some(Some(s)) → set.
let normalized = normalize_email(email_patch.as_deref())?;
latest = Some(state.users.update_email(id, normalized.as_deref()).await?);
}
if let Some(new_role) = input.instance_role {
// Self-elevation guard: only an owner can promote anyone TO
// owner. An admin cannot turn themselves (or anyone else)
// into one.
if new_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner {
return Err(AdminApiError::CannotEscalate);
}
// Last-active-owner guard: a transition off of `Owner` cannot
// leave the install with zero owners. The check is on the
// source role (current.instance_role) so demoting an
// already-non-owner is always fine.
if current.instance_role == InstanceRole::Owner && new_role != InstanceRole::Owner {
let remaining = state.users.count_other_active_owners(id).await?;
if remaining == 0 {
return Err(AdminApiError::LastActiveOwner);
}
}
latest = Some(state.users.update_instance_role(id, new_role).await?);
}
if let Some(new_active) = input.is_active {
// Last-active-admin guard: only when transitioning to inactive.
if !new_active {
let remaining = state.users.count_active_excluding(id).await?;
if remaining == 0 {
return Err(AdminApiError::LastActiveAdmin);
}
// ALSO: if the target is currently the last active owner,
// deactivating them leaves no owner. Belt-and-suspenders to
// the role guard above (which only triggers on an explicit
// role transition).
let target_role = latest
.as_ref()
.map_or(current.instance_role, |r| r.instance_role);
if target_role == InstanceRole::Owner {
let remaining_owners = state.users.count_other_active_owners(id).await?;
if remaining_owners == 0 {
return Err(AdminApiError::LastActiveOwner);
}
}
}
latest = Some(state.users.set_active(id, new_active).await?);
// Deactivation invalidates BOTH credential surfaces — sessions
// (cookie / session bearer) and API keys. Both writes are
// logged on failure but do not undo the deactivation; the
// alternative (leaving the user active when one cascade fails)
// is worse than slightly stale credential rows on a DB blip.
if !new_active {
if let Err(err) = state.sessions.delete_for_user(id).await {
tracing::error!(?err, "failed to delete sessions for deactivated admin");
}
match state.keys.expire_all_for_user(id).await {
Ok(n) => {
if n > 0 {
tracing::info!(user_id = %id, expired = n, "expired api keys on deactivation");
}
}
Err(err) => {
tracing::error!(?err, "failed to expire api keys for deactivated admin");
}
}
}
}
let row = match latest {
Some(r) => r,
None => state
.users
.get(id)
.await?
.ok_or(AdminApiError::NotFound(id))?,
};
Ok(Json(row.into()))
}
async fn delete_admin(
State(state): State<AdminsState>,
Extension(principal): Extension<Principal>,
Path(id): Path<AdminUserId>,
) -> Result<StatusCode, AdminApiError> {
require(
state.authz.as_ref(),
&principal,
Capability::InstanceManageUsers,
)
.await?;
let target = state
.users
.get(id)
.await?
.ok_or(AdminApiError::NotFound(id))?;
if target.is_active {
let remaining = state.users.count_active_excluding(id).await?;
if remaining == 0 {
return Err(AdminApiError::LastActiveAdmin);
}
// Last-owner guard mirrors the role-transition guard in
// patch_admin — deleting the only owner is just as bad as
// demoting them.
if target.instance_role == InstanceRole::Owner {
let remaining_owners = state.users.count_other_active_owners(id).await?;
if remaining_owners == 0 {
return Err(AdminApiError::LastActiveOwner);
}
}
}
state.users.delete(id).await?;
// Sessions + api_keys cascade via FK; no explicit delete needed.
Ok(StatusCode::NO_CONTENT)
}
// ----------------------------------------------------------------------------
// Validation
// ----------------------------------------------------------------------------
fn validate_username(s: &str) -> Result<(), AdminApiError> {
if s.len() < USERNAME_MIN || s.len() > USERNAME_MAX {
return Err(AdminApiError::InvalidUsername(format!(
"username must be {USERNAME_MIN}-{USERNAME_MAX} characters"
)));
}
if !s
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-'))
{
return Err(AdminApiError::InvalidUsername(
"username may contain only lowercase letters, digits, dot, underscore, and hyphen"
.to_string(),
));
}
Ok(())
}
fn validate_password(s: &str) -> Result<(), AdminApiError> {
if s.chars().count() < PASSWORD_MIN {
return Err(AdminApiError::InvalidPassword(format!(
"password must be at least {PASSWORD_MIN} characters"
)));
}
Ok(())
}
/// Trim and reject empty / pathological emails, returning the
/// canonical form (or None when the input was blank). The shape
/// check is intentionally loose — we mainly want to reject blanks
/// and obvious junk; real verification is a future concern.
fn normalize_email(raw: Option<&str>) -> Result<Option<String>, AdminApiError> {
let Some(raw) = raw else {
return Ok(None);
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
if trimmed.len() > 254 || !trimmed.contains('@') {
return Err(AdminApiError::InvalidEmail(
"email must contain '@' and be at most 254 characters".to_string(),
));
}
Ok(Some(trimmed.to_string()))
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum AdminApiError {
#[error("admin user not found: {0}")]
NotFound(AdminUserId),
#[error("{0}")]
InvalidUsername(String),
#[error("{0}")]
InvalidPassword(String),
#[error("{0}")]
InvalidEmail(String),
#[error("cannot leave the system with zero active admins")]
LastActiveAdmin,
#[error("cannot leave the system with zero active owners")]
LastActiveOwner,
#[error("only an owner can grant the owner role")]
CannotEscalate,
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("failed to hash password: {0}")]
Hash(String),
#[error("repository error: {0}")]
Repo(#[from] AdminUserRepositoryError),
}
impl From<AuthzDenied> for AdminApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl IntoResponse for AdminApiError {
fn into_response(self) -> Response {
let (status, message) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
Self::Repo(
AdminUserRepositoryError::DuplicateUsername(_)
| AdminUserRepositoryError::DuplicateEmail(_),
) => (StatusCode::CONFLICT, self.to_string()),
Self::InvalidUsername(_)
| Self::InvalidPassword(_)
| Self::InvalidEmail(_)
| Self::LastActiveAdmin
| Self::LastActiveOwner
| Self::CannotEscalate
| Self::Repo(AdminUserRepositoryError::InvalidInstanceRole(_)) => {
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
}
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "admin_users authz error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
Self::Repo(AdminUserRepositoryError::NotFound(_)) => {
(StatusCode::NOT_FOUND, self.to_string())
}
Self::Repo(AdminUserRepositoryError::Db(e)) => {
tracing::error!(error = %e, "admin_users db error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
Self::Hash(_) => {
tracing::error!(error = %self, "password hashing failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn username_validation_accepts_valid() {
for u in ["ab", "alice", "user.name", "a_b-c", "00bot00"] {
assert!(validate_username(u).is_ok(), "should accept {u}");
}
}
#[test]
fn username_validation_rejects_invalid() {
for u in ["", "a", "Alice", "user name", "user@domain", "user!"] {
assert!(validate_username(u).is_err(), "should reject {u:?}");
}
let too_long = "x".repeat(33);
assert!(validate_username(&too_long).is_err());
}
#[test]
fn password_validation_enforces_min_length() {
assert!(validate_password("1234567").is_err());
assert!(validate_password("12345678").is_ok());
assert!(validate_password("a-very-long-password-with-spaces and stuff").is_ok());
}
}

View File

@@ -5,17 +5,20 @@
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::get, routing::get,
Json, Router, Extension, Json, Router,
}; };
use picloud_shared::{ use picloud_shared::{
ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError, AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptSandbox, ScriptValidator,
ValidationError,
}; };
use serde::Deserialize; use serde::Deserialize;
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::repo::{ use crate::repo::{
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError, ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
}; };
@@ -27,6 +30,13 @@ use crate::sandbox::{CeilingError, SandboxCeiling};
pub struct AdminState<R, L> { pub struct AdminState<R, L> {
pub repo: Arc<R>, pub repo: Arc<R>,
pub logs: Arc<L>, pub logs: Arc<L>,
/// App lookups: validates `app_id` on create, resolves `?app=<slug>`
/// filter on list. Trait-object so apps_repo can stay separate.
pub apps: Arc<dyn AppRepository>,
/// Phase 3.5 capability checks — every script handler resolves
/// `AppRead/Write/LogRead(script.app_id)` against this repo after
/// loading the resource.
pub authz: Arc<dyn AuthzRepo>,
pub validator: Arc<dyn ScriptValidator>, pub validator: Arc<dyn ScriptValidator>,
pub sandbox_ceiling: SandboxCeiling, pub sandbox_ceiling: SandboxCeiling,
} }
@@ -36,6 +46,8 @@ impl<R, L> Clone for AdminState<R, L> {
Self { Self {
repo: self.repo.clone(), repo: self.repo.clone(),
logs: self.logs.clone(), logs: self.logs.clone(),
apps: self.apps.clone(),
authz: self.authz.clone(),
validator: self.validator.clone(), validator: self.validator.clone(),
sandbox_ceiling: self.sandbox_ceiling, sandbox_ceiling: self.sandbox_ceiling,
} }
@@ -70,6 +82,9 @@ where
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CreateScriptRequest { pub struct CreateScriptRequest {
/// Owning app. Required since Phase 3b — scripts cannot exist
/// outside an app. Use `/api/v1/admin/apps` to list known ids.
pub app_id: AppId,
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
pub source: String, pub source: String,
@@ -82,6 +97,14 @@ pub struct CreateScriptRequest {
pub sandbox: ScriptSandbox, pub sandbox: ScriptSandbox,
} }
#[derive(Debug, Deserialize)]
pub struct ListScriptsQuery {
/// Optional filter: list scripts belonging to a single app, by id
/// or slug. Absent = all scripts across all apps (admin-global view).
#[serde(default)]
pub app: Option<String>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateScriptRequest { pub struct UpdateScriptRequest {
pub name: Option<String>, pub name: Option<String>,
@@ -113,31 +136,83 @@ where
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>( async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>, State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Query(q): Query<ListScriptsQuery>,
) -> Result<Json<Vec<Script>>, ApiError> { ) -> Result<Json<Vec<Script>>, ApiError> {
// Membership filter: `member` users see only scripts in apps they
// belong to. `?app=` filters further by app and additionally
// requires the member to belong to that app (the read check uses
// the resource's app_id).
if let Some(ident) = q.app {
let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
require(state.authz.as_ref(), &principal, Capability::AppRead(app)).await?;
return Ok(Json(state.repo.list_for_app(app).await?));
}
if principal.instance_role == InstanceRole::Member {
return Ok(Json(state.repo.list_for_user(principal.user_id).await?));
}
Ok(Json(state.repo.list().await?)) Ok(Json(state.repo.list().await?))
} }
/// Accept `?app=<uuid>` OR `?app=<slug>`. Slugs route through history
/// for redirects, but here we just need the live current id; if a
/// retired slug is given, we follow it to the current app silently.
async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApiError> {
if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
let id = AppId::from(uuid);
apps.get_by_id(id)
.await?
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
return Ok(id);
}
let lookup = apps
.get_by_slug_or_history(ident)
.await?
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
Ok(lookup.app.id)
}
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>( async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>, State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Path(id): Path<ScriptId>, Path(id): Path<ScriptId>,
) -> Result<Json<Script>, ApiError> { ) -> Result<Json<Script>, ApiError> {
state let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
.repo require(
.get(id) state.authz.as_ref(),
.await? &principal,
.map(Json) Capability::AppRead(script.app_id),
.ok_or(ApiError::NotFound(id)) )
.await?;
Ok(Json(script))
} }
async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>( async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>, State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Json(input): Json<CreateScriptRequest>, Json(input): Json<CreateScriptRequest>,
) -> Result<(StatusCode, Json<Script>), ApiError> { ) -> Result<(StatusCode, Json<Script>), ApiError> {
// Capability is bound to the *requested* app_id since there's no
// resource to load yet. If the app doesn't exist we 422 below;
// checking authz first means a Member trying to create against an
// unknown app gets 403 (no enumeration of app existence).
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteScript(input.app_id),
)
.await?;
state.validator.validate(&input.source)?; state.validator.validate(&input.source)?;
state.sandbox_ceiling.check(&input.sandbox)?; state.sandbox_ceiling.check(&input.sandbox)?;
// Refuse early if the app_id doesn't exist — a clean 422 beats a
// raw FK violation surfacing as 500.
if state.apps.get_by_id(input.app_id).await?.is_none() {
return Err(ApiError::AppNotFound(input.app_id.to_string()));
}
let created = state let created = state
.repo .repo
.create(NewScript { .create(NewScript {
app_id: input.app_id,
name: input.name, name: input.name,
description: input.description, description: input.description,
source: input.source, source: input.source,
@@ -155,9 +230,17 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>( async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>, State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Path(id): Path<ScriptId>, Path(id): Path<ScriptId>,
Json(input): Json<UpdateScriptRequest>, Json(input): Json<UpdateScriptRequest>,
) -> Result<Json<Script>, ApiError> { ) -> Result<Json<Script>, ApiError> {
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteScript(script.app_id),
)
.await?;
if let Some(src) = input.source.as_deref() { if let Some(src) = input.source.as_deref() {
state.validator.validate(src)?; state.validator.validate(src)?;
} }
@@ -183,8 +266,19 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>( async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>, State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Path(id): Path<ScriptId>, Path(id): Path<ScriptId>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
// Delete is gated tighter than Save: editors can edit scripts but
// only app_admin / instance admin / owner can remove them. See
// blueprint §11.6.
require(
state.authz.as_ref(),
&principal,
Capability::AppAdmin(script.app_id),
)
.await?;
state.repo.delete(id).await?; state.repo.delete(id).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -203,9 +297,17 @@ const fn default_limit() -> i64 {
async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>( async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>, State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Path(id): Path<ScriptId>, Path(id): Path<ScriptId>,
axum::extract::Query(q): axum::extract::Query<LogsQuery>, axum::extract::Query(q): axum::extract::Query<LogsQuery>,
) -> Result<Json<Vec<ExecutionLog>>, ApiError> { ) -> Result<Json<Vec<ExecutionLog>>, ApiError> {
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
require(
state.authz.as_ref(),
&principal,
Capability::AppLogRead(script.app_id),
)
.await?;
// Cap to keep the dashboard responsive; the data plane writes are // Cap to keep the dashboard responsive; the data plane writes are
// unbounded over time so a paged read is the only sane default. // unbounded over time so a paged read is the only sane default.
let limit = q.limit.clamp(1, 200); let limit = q.limit.clamp(1, 200);
@@ -223,6 +325,9 @@ pub enum ApiError {
#[error("script not found: {0}")] #[error("script not found: {0}")]
NotFound(ScriptId), NotFound(ScriptId),
#[error("app not found: {0}")]
AppNotFound(String),
#[error("conflict: {0}")] #[error("conflict: {0}")]
Conflict(String), Conflict(String),
@@ -232,18 +337,42 @@ pub enum ApiError {
#[error("{0}")] #[error("{0}")]
Ceiling(#[from] CeilingError), Ceiling(#[from] CeilingError),
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("repository error: {0}")] #[error("repository error: {0}")]
Repo(#[from] ScriptRepositoryError), Repo(#[from] ScriptRepositoryError),
} }
impl From<AuthzDenied> for ApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, message) = match &self { let (status, message) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
Self::AppNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()), Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
Self::Invalid(_) | Self::Ceiling(_) => { Self::Invalid(_) | Self::Ceiling(_) => {
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string()) (StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
} }
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
Self::Repo(ScriptRepositoryError::NotFound(_)) => { Self::Repo(ScriptRepositoryError::NotFound(_)) => {
(StatusCode::NOT_FOUND, self.to_string()) (StatusCode::NOT_FOUND, self.to_string())
} }

View File

@@ -0,0 +1,292 @@
//! CRUD over the `api_keys` table — backs the `Authorization: Bearer
//! pic_…` credential flow from blueprint §11.6.
//!
//! The repo never sees the raw token; only the 8-char `prefix` and the
//! Argon2id `hash`. Mint logic (random-bytes generation, prefix split,
//! hash compute) lives in `api_keys_api.rs`. Verification logic
//! (prefix lookup + Argon2 verify per candidate) lives in
//! `auth_middleware.rs`. Both call this repo for the storage layer.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, ApiKeyId, AppId, Scope};
use sqlx::PgPool;
#[derive(Debug, thiserror::Error)]
pub enum ApiKeyRepositoryError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("api key not found: {0}")]
NotFound(ApiKeyId),
#[error("invalid scope stored in DB: {0}")]
InvalidScope(String),
}
/// Insert payload — built by `api_keys_api` after generating the raw
/// token and hashing it. `hash` is an Argon2id PHC string covering the
/// body of the token (everything after `pic_`); `prefix` is the first
/// 8 chars of that body, indexed for fast candidate lookup.
#[derive(Debug, Clone)]
pub struct NewApiKey {
pub user_id: AdminUserId,
pub hash: String,
pub prefix: String,
pub name: String,
pub scopes: Vec<Scope>,
pub app_id: Option<AppId>,
pub expires_at: Option<DateTime<Utc>>,
}
/// Public-facing row — never exposes the hash. Used for `GET
/// /admin/api-keys` and the `POST` response (alongside the
/// one-shot raw token).
#[derive(Debug, Clone)]
pub struct ApiKeyRow {
pub id: ApiKeyId,
pub user_id: AdminUserId,
pub prefix: String,
pub name: String,
pub scopes: Vec<Scope>,
pub app_id: Option<AppId>,
pub expires_at: Option<DateTime<Utc>>,
pub last_used_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
/// Verification candidate — includes the Argon2id `hash` and `user_id`
/// so middleware can verify the supplied token and assemble the
/// `Principal`. Kept separate from `ApiKeyRow` so handlers can't leak
/// the hash through a careless `Json(row)`.
#[derive(Debug, Clone)]
pub struct ApiKeyVerification {
pub id: ApiKeyId,
pub user_id: AdminUserId,
pub hash: String,
pub scopes: Vec<Scope>,
pub app_id: Option<AppId>,
}
#[async_trait]
pub trait ApiKeyRepository: Send + Sync {
/// Mint. Caller has already hashed the raw token + computed prefix.
async fn create(&self, key: NewApiKey) -> Result<ApiKeyRow, ApiKeyRepositoryError>;
/// Return every non-expired key with the given 8-char prefix. The
/// caller (middleware) Argon2-verifies the supplied token against
/// each candidate's `hash`. Returning a Vec rather than one row
/// keeps the contract correct even if two keys happen to share a
/// prefix (statistically near-zero but possible).
async fn find_active_by_prefix(
&self,
prefix: &str,
) -> Result<Vec<ApiKeyVerification>, ApiKeyRepositoryError>;
/// Update `last_used_at` for an authenticated request. Inline (not
/// fire-and-forget) so a DB blip surfaces as a 500 rather than
/// silent stale timestamps.
async fn touch_last_used(&self, id: ApiKeyId) -> Result<(), ApiKeyRepositoryError>;
/// Caller's own keys, for `GET /admin/api-keys`.
async fn list_for_user(
&self,
user_id: AdminUserId,
) -> Result<Vec<ApiKeyRow>, ApiKeyRepositoryError>;
/// Look up a key by id — used by `DELETE` to verify ownership
/// before issuing the delete.
async fn get(&self, id: ApiKeyId) -> Result<Option<ApiKeyRow>, ApiKeyRepositoryError>;
/// Delete the row only if it belongs to `user_id`. Returns whether
/// a row was actually deleted (false = key didn't exist OR wasn't
/// theirs — handlers map both to 404 to avoid leaking the
/// distinction).
async fn delete_by_id_and_user(
&self,
id: ApiKeyId,
user_id: AdminUserId,
) -> Result<bool, ApiKeyRepositoryError>;
/// Set `expires_at = NOW()` on every active key for a user. Wired
/// into `set_active(false)` so deactivation invalidates both
/// sessions (already done by `AdminSessionRepository::delete_for_user`)
/// and bearer keys at the same moment.
async fn expire_all_for_user(&self, user_id: AdminUserId)
-> Result<u64, ApiKeyRepositoryError>;
}
pub struct PostgresApiKeyRepository {
pool: PgPool,
}
impl PostgresApiKeyRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl ApiKeyRepository for PostgresApiKeyRepository {
async fn create(&self, key: NewApiKey) -> Result<ApiKeyRow, ApiKeyRepositoryError> {
let scope_strings: Vec<String> =
key.scopes.iter().map(|s| s.as_str().to_string()).collect();
let row = sqlx::query_as::<_, ApiKeyRecord>(
"INSERT INTO api_keys \
(user_id, hash, prefix, name, scopes, app_id, expires_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7) \
RETURNING id, user_id, prefix, name, scopes, app_id, \
expires_at, last_used_at, created_at",
)
.bind(key.user_id.into_inner())
.bind(&key.hash)
.bind(&key.prefix)
.bind(&key.name)
.bind(&scope_strings)
.bind(key.app_id.map(picloud_shared::AppId::into_inner))
.bind(key.expires_at)
.fetch_one(&self.pool)
.await?;
row.try_into()
}
async fn find_active_by_prefix(
&self,
prefix: &str,
) -> Result<Vec<ApiKeyVerification>, ApiKeyRepositoryError> {
let rows = sqlx::query_as::<_, ApiKeyVerifyRecord>(
"SELECT id, user_id, hash, scopes, app_id \
FROM api_keys \
WHERE prefix = $1 \
AND (expires_at IS NULL OR expires_at > NOW())",
)
.bind(prefix)
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn touch_last_used(&self, id: ApiKeyId) -> Result<(), ApiKeyRepositoryError> {
sqlx::query("UPDATE api_keys SET last_used_at = NOW() WHERE id = $1")
.bind(id.into_inner())
.execute(&self.pool)
.await?;
Ok(())
}
async fn list_for_user(
&self,
user_id: AdminUserId,
) -> Result<Vec<ApiKeyRow>, ApiKeyRepositoryError> {
let rows = sqlx::query_as::<_, ApiKeyRecord>(
"SELECT id, user_id, prefix, name, scopes, app_id, \
expires_at, last_used_at, created_at \
FROM api_keys WHERE user_id = $1 \
ORDER BY created_at DESC",
)
.bind(user_id.into_inner())
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn get(&self, id: ApiKeyId) -> Result<Option<ApiKeyRow>, ApiKeyRepositoryError> {
let row = sqlx::query_as::<_, ApiKeyRecord>(
"SELECT id, user_id, prefix, name, scopes, app_id, \
expires_at, last_used_at, created_at \
FROM api_keys WHERE id = $1",
)
.bind(id.into_inner())
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn delete_by_id_and_user(
&self,
id: ApiKeyId,
user_id: AdminUserId,
) -> Result<bool, ApiKeyRepositoryError> {
let res = sqlx::query("DELETE FROM api_keys WHERE id = $1 AND user_id = $2")
.bind(id.into_inner())
.bind(user_id.into_inner())
.execute(&self.pool)
.await?;
Ok(res.rows_affected() > 0)
}
async fn expire_all_for_user(
&self,
user_id: AdminUserId,
) -> Result<u64, ApiKeyRepositoryError> {
let res = sqlx::query(
"UPDATE api_keys \
SET expires_at = NOW() \
WHERE user_id = $1 \
AND (expires_at IS NULL OR expires_at > NOW())",
)
.bind(user_id.into_inner())
.execute(&self.pool)
.await?;
Ok(res.rows_affected())
}
}
#[derive(sqlx::FromRow)]
struct ApiKeyRecord {
id: uuid::Uuid,
user_id: uuid::Uuid,
prefix: String,
name: String,
scopes: Vec<String>,
app_id: Option<uuid::Uuid>,
expires_at: Option<DateTime<Utc>>,
last_used_at: Option<DateTime<Utc>>,
created_at: DateTime<Utc>,
}
impl TryFrom<ApiKeyRecord> for ApiKeyRow {
type Error = ApiKeyRepositoryError;
fn try_from(r: ApiKeyRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: r.id.into(),
user_id: r.user_id.into(),
prefix: r.prefix,
name: r.name,
scopes: parse_scopes(r.scopes)?,
app_id: r.app_id.map(Into::into),
expires_at: r.expires_at,
last_used_at: r.last_used_at,
created_at: r.created_at,
})
}
}
#[derive(sqlx::FromRow)]
struct ApiKeyVerifyRecord {
id: uuid::Uuid,
user_id: uuid::Uuid,
hash: String,
scopes: Vec<String>,
app_id: Option<uuid::Uuid>,
}
impl TryFrom<ApiKeyVerifyRecord> for ApiKeyVerification {
type Error = ApiKeyRepositoryError;
fn try_from(r: ApiKeyVerifyRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: r.id.into(),
user_id: r.user_id.into(),
hash: r.hash,
scopes: parse_scopes(r.scopes)?,
app_id: r.app_id.map(Into::into),
})
}
}
fn parse_scopes(raw: Vec<String>) -> Result<Vec<Scope>, ApiKeyRepositoryError> {
raw.into_iter()
.map(|s| Scope::from_wire(&s).ok_or(ApiKeyRepositoryError::InvalidScope(s)))
.collect()
}

View File

@@ -0,0 +1,251 @@
//! `/api/v1/admin/api-keys/*` — bearer API key CRUD (blueprint §11.6).
//!
//! All endpoints are guarded by `require_authenticated`. Capability
//! checks: none — every authenticated user manages **their own** keys.
//! The repo enforces caller ownership on `delete`, and `list` is
//! scoped to the caller's user_id. No instance-level authority is
//! exposed (no listing other users' keys, no admin-issued keys for
//! another user — those flows belong with the invite system).
//!
//! Mint semantics:
//! * raw token is returned **exactly once** in the POST response and
//! never logged. Lose it = mint a new key.
//! * `app_id` (optional) binds the key to one app; capability checks
//! deny every `App*(other_app)`.
//! * scopes containing `instance:*` are rejected when `app_id` is
//! set — the combination is irreconcilable.
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get};
use axum::{Extension, Router};
use chrono::{DateTime, Utc};
use picloud_shared::{ApiKeyId, AppId, Principal, Scope};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::api_key_repo::{ApiKeyRepository, ApiKeyRepositoryError, ApiKeyRow, NewApiKey};
use crate::auth::generate_api_key;
/// Validation bounds for the user-supplied `name` field — keeps the
/// dashboard's list view tidy and rejects accidental whole-token
/// pastes.
const NAME_MIN: usize = 1;
const NAME_MAX: usize = 64;
#[derive(Clone)]
pub struct ApiKeysState {
pub keys: Arc<dyn ApiKeyRepository>,
}
pub fn api_keys_router(state: ApiKeysState) -> Router {
Router::new()
.route("/api-keys", get(list_keys).post(mint_key))
.route("/api-keys/{id}", delete(delete_key))
.with_state(state)
}
// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct MintApiKeyRequest {
pub name: String,
pub scopes: Vec<Scope>,
/// When set, the key is bound to this app — every `App*(other)`
/// capability is denied regardless of role.
#[serde(default)]
pub app_id: Option<AppId>,
/// When set, lookup rejects the key after this instant. Absent =
/// never expires (until explicit DELETE).
#[serde(default)]
pub expires_at: Option<DateTime<Utc>>,
}
/// Response body for a freshly-minted key. `raw_token` only appears
/// here — `GET /api-keys` returns `ApiKeyDto` without it.
#[derive(Debug, Serialize)]
pub struct MintApiKeyResponse {
#[serde(flatten)]
pub key: ApiKeyDto,
/// The full wire-format token (`pic_<base32>`). Shown exactly once;
/// store it client-side immediately.
pub raw_token: String,
}
#[derive(Debug, Serialize)]
pub struct ApiKeyDto {
pub id: ApiKeyId,
pub prefix: String,
pub name: String,
pub scopes: Vec<Scope>,
pub app_id: Option<AppId>,
pub expires_at: Option<DateTime<Utc>>,
pub last_used_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
impl From<ApiKeyRow> for ApiKeyDto {
fn from(r: ApiKeyRow) -> Self {
Self {
id: r.id,
prefix: r.prefix,
name: r.name,
scopes: r.scopes,
app_id: r.app_id,
expires_at: r.expires_at,
last_used_at: r.last_used_at,
created_at: r.created_at,
}
}
}
// ----------------------------------------------------------------------------
// Handlers
// ----------------------------------------------------------------------------
async fn mint_key(
State(state): State<ApiKeysState>,
Extension(principal): Extension<Principal>,
Json(input): Json<MintApiKeyRequest>,
) -> Result<(StatusCode, Json<MintApiKeyResponse>), ApiKeysError> {
validate_name(&input.name)?;
validate_scopes(&input.scopes, input.app_id)?;
let minted = generate_api_key().map_err(|e| ApiKeysError::Hash(e.to_string()))?;
let row = state
.keys
.create(NewApiKey {
user_id: principal.user_id,
hash: minted.hash,
prefix: minted.prefix,
name: input.name,
scopes: input.scopes,
app_id: input.app_id,
expires_at: input.expires_at,
})
.await?;
Ok((
StatusCode::CREATED,
Json(MintApiKeyResponse {
key: row.into(),
raw_token: minted.raw,
}),
))
}
async fn list_keys(
State(state): State<ApiKeysState>,
Extension(principal): Extension<Principal>,
) -> Result<Json<Vec<ApiKeyDto>>, ApiKeysError> {
let rows = state.keys.list_for_user(principal.user_id).await?;
Ok(Json(rows.into_iter().map(Into::into).collect()))
}
async fn delete_key(
State(state): State<ApiKeysState>,
Extension(principal): Extension<Principal>,
Path(id): Path<ApiKeyId>,
) -> Result<StatusCode, ApiKeysError> {
let deleted = state
.keys
.delete_by_id_and_user(id, principal.user_id)
.await?;
if !deleted {
// 404 covers both "doesn't exist" and "exists but not yours" —
// we deliberately don't leak the distinction.
return Err(ApiKeysError::NotFound(id));
}
Ok(StatusCode::NO_CONTENT)
}
// ----------------------------------------------------------------------------
// Validation
// ----------------------------------------------------------------------------
fn validate_name(s: &str) -> Result<(), ApiKeysError> {
let trimmed = s.trim();
if trimmed.len() < NAME_MIN || trimmed.len() > NAME_MAX {
return Err(ApiKeysError::InvalidName(format!(
"name must be {NAME_MIN}-{NAME_MAX} characters after trimming"
)));
}
Ok(())
}
fn validate_scopes(scopes: &[Scope], app_id: Option<AppId>) -> Result<(), ApiKeysError> {
if scopes.is_empty() {
return Err(ApiKeysError::InvalidScopes(
"scopes must be non-empty".into(),
));
}
// Bound key + any instance:* scope → irreconcilable.
if app_id.is_some() && scopes.iter().any(|s| s.is_instance()) {
return Err(ApiKeysError::InvalidScopes(
"bound keys (app_id set) cannot carry instance:* scopes".into(),
));
}
Ok(())
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum ApiKeysError {
#[error("api key not found: {0}")]
NotFound(ApiKeyId),
#[error("{0}")]
InvalidName(String),
#[error("{0}")]
InvalidScopes(String),
#[error("failed to hash key: {0}")]
Hash(String),
#[error("repository error: {0}")]
Repo(#[from] ApiKeyRepositoryError),
}
impl IntoResponse for ApiKeysError {
fn into_response(self) -> Response {
let (status, message) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
Self::InvalidName(_) | Self::InvalidScopes(_) => {
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
}
Self::Hash(_) => {
tracing::error!(error = %self, "api key hash failure");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
Self::Repo(ApiKeyRepositoryError::NotFound(_)) => {
(StatusCode::NOT_FOUND, self.to_string())
}
Self::Repo(ApiKeyRepositoryError::InvalidScope(_)) => {
tracing::error!(error = %self, "api key row carries an unknown scope");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
Self::Repo(ApiKeyRepositoryError::Db(e)) => {
tracing::error!(error = %e, "api_keys db error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}

View File

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

View File

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

View File

@@ -0,0 +1,331 @@
//! `/api/v1/admin/apps/{id_or_slug}/members/*` — CRUD over the
//! `app_members` table (blueprint §11.6).
//!
//! Every endpoint is gated on `Capability::AppAdmin(app_id)` after
//! resolving the app from `id_or_slug`. Editors and viewers receive
//! 403 from list and never see the dashboard's Members tab.
//!
//! POST is **non-idempotent on purpose**: a duplicate `(app_id,
//! user_id)` returns 409 rather than upsert-200, so the UI can show
//! "already a member — promote / demote them instead" cleanly. Role
//! changes go through PATCH.
//!
//! No last-app-admin guard: owners always implicitly satisfy
//! `Capability::AppAdmin(_)` (authz::role_grants), so removing the
//! final explicit `app_admin` membership cannot orphan an app.
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, patch};
use axum::{Extension, Router};
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, AppRole, InstanceRole, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
use crate::app_members_repo::{
AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow,
};
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::repo::ScriptRepositoryError;
#[derive(Clone)]
pub struct AppMembersState {
pub apps: Arc<dyn AppRepository>,
pub users: Arc<dyn AdminUserRepository>,
pub members: Arc<dyn AppMembersRepository>,
pub authz: Arc<dyn AuthzRepo>,
}
pub fn app_members_router(state: AppMembersState) -> Router {
Router::new()
.route(
"/apps/{id_or_slug}/members",
get(list_members).post(grant_member),
)
.route(
"/apps/{id_or_slug}/members/{user_id}",
patch(patch_member).delete(remove_member),
)
.with_state(state)
}
// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------
#[derive(Debug, Serialize)]
pub struct AppMemberDto {
pub user_id: AdminUserId,
pub username: String,
pub email: Option<String>,
pub instance_role: InstanceRole,
pub is_active: bool,
pub role: AppRole,
pub created_at: DateTime<Utc>,
}
impl From<AppMembershipDetail> for AppMemberDto {
fn from(d: AppMembershipDetail) -> Self {
Self {
user_id: d.user_id,
username: d.username,
email: d.email,
instance_role: d.instance_role,
is_active: d.is_active,
role: d.role,
created_at: d.created_at,
}
}
}
/// Compose a DTO from an `AdminUserRow` (fetched for validation) and
/// the `AppMembershipRow` returned by `upsert`. Saves a re-fetch on
/// POST/PATCH at the cost of trusting the two inputs reference the
/// same user_id — caller's responsibility.
fn compose_dto(user: AdminUserRow, membership: AppMembershipRow) -> AppMemberDto {
AppMemberDto {
user_id: user.id,
username: user.username,
email: user.email,
instance_role: user.instance_role,
is_active: user.is_active,
role: membership.role,
created_at: membership.created_at,
}
}
#[derive(Debug, Deserialize)]
pub struct GrantMemberRequest {
pub user_id: AdminUserId,
pub role: AppRole,
}
#[derive(Debug, Deserialize)]
pub struct PatchMemberRequest {
pub role: AppRole,
}
// ----------------------------------------------------------------------------
// Handlers
// ----------------------------------------------------------------------------
async fn list_members(
State(s): State<AppMembersState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<AppMemberDto>>, AppMembersApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?;
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
let rows = s.members.list_for_app_enriched(app.id).await?;
Ok(Json(rows.into_iter().map(Into::into).collect()))
}
async fn grant_member(
State(s): State<AppMembersState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<GrantMemberRequest>,
) -> Result<(StatusCode, Json<AppMemberDto>), AppMembersApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?;
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
let user = s
.users
.get(input.user_id)
.await?
.ok_or(AppMembersApiError::UserNotFound(input.user_id))?;
validate_grant_target(&user)?;
// Atomic insert — if a row already exists, returns None and we 409.
// Avoids the find-then-upsert race where two concurrent POSTs would
// both pass the existence check and the second `upsert` would
// silently rewrite the role.
let row = s
.members
.try_insert(app.id, user.id, input.role)
.await?
.ok_or_else(|| AppMembersApiError::AlreadyMember {
username: user.username.clone(),
})?;
Ok((StatusCode::CREATED, Json(compose_dto(user, row))))
}
async fn patch_member(
State(s): State<AppMembersState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
Json(input): Json<PatchMemberRequest>,
) -> Result<Json<AppMemberDto>, AppMembersApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?;
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
let user_id = AdminUserId::from(user_id);
let user = s
.users
.get(user_id)
.await?
.ok_or(AppMembersApiError::UserNotFound(user_id))?;
// Atomic update — returns None if no row exists, so 404 is decided
// by the same statement that does the write. Eliminates the
// find-then-upsert race where a concurrent DELETE between the two
// calls would let PATCH silently re-create the row.
let row = s
.members
.update_role(app.id, user_id, input.role)
.await?
.ok_or(AppMembersApiError::MembershipNotFound)?;
Ok(Json(compose_dto(user, row)))
}
async fn remove_member(
State(s): State<AppMembersState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
) -> Result<StatusCode, AppMembersApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?;
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
s.members.remove(app.id, AdminUserId::from(user_id)).await?;
Ok(StatusCode::NO_CONTENT)
}
// ----------------------------------------------------------------------------
// Validation + helpers
// ----------------------------------------------------------------------------
fn validate_grant_target(user: &AdminUserRow) -> Result<(), AppMembersApiError> {
if !user.is_active {
return Err(AppMembersApiError::TargetInactive {
username: user.username.clone(),
});
}
if user.instance_role != InstanceRole::Member {
return Err(AppMembersApiError::TargetNotMember {
username: user.username.clone(),
instance_role: user.instance_role,
});
}
Ok(())
}
async fn resolve_app(
apps: &dyn AppRepository,
ident: &str,
) -> Result<picloud_shared::App, AppMembersApiError> {
crate::app_repo::resolve_app(apps, ident)
.await?
.map(|l| l.app)
.ok_or_else(|| AppMembersApiError::AppNotFound(ident.to_string()))
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum AppMembersApiError {
#[error("app not found: {0}")]
AppNotFound(String),
#[error("user not found: {0}")]
UserNotFound(AdminUserId),
#[error("no membership exists for this user on this app")]
MembershipNotFound,
#[error("{username} is already a member of this app — use PATCH to change their role")]
AlreadyMember { username: String },
#[error("{username} is deactivated and cannot be added as a member")]
TargetInactive { username: String },
#[error(
"{username} has instance_role {instance_role:?} and already has implicit access \
on every app — no explicit membership needed"
)]
TargetNotMember {
username: String,
instance_role: InstanceRole,
},
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("repository error: {0}")]
Members(#[from] AppMembersRepositoryError),
#[error("user repository error: {0}")]
Users(#[from] AdminUserRepositoryError),
#[error("repository error: {0}")]
Apps(#[from] ScriptRepositoryError),
}
impl From<AuthzDenied> for AppMembersApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl IntoResponse for AppMembersApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound(_)
| Self::UserNotFound(_)
| Self::MembershipNotFound
| Self::Apps(ScriptRepositoryError::NotFound(_)) => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::AlreadyMember { .. } | Self::Apps(ScriptRepositoryError::Conflict(_)) => {
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
}
Self::TargetInactive { .. } | Self::TargetNotMember { .. } => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "app members authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Members(e) => {
tracing::error!(error = %e, "app members repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Users(e) => {
tracing::error!(error = %e, "admin users repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Apps(ScriptRepositoryError::Db(e)) => {
tracing::error!(error = %e, "apps repo error in app_members");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

@@ -0,0 +1,340 @@
//! CRUD over the `app_members` table — explicit per-(user, app) role
//! grants for `member` instance-role users. Owners and admins do NOT
//! appear here; their app authority is implicit (see authz.rs).
//!
//! Doubles as the production `AuthzRepo` implementation: the
//! membership lookup `can()` needs is the same single-row SELECT as
//! `find` here.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
use sqlx::PgPool;
use crate::authz::{AuthzError, AuthzRepo};
#[derive(Debug, thiserror::Error)]
pub enum AppMembersRepositoryError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("membership row not found: app={app_id}, user={user_id}")]
NotFound { app_id: AppId, user_id: AdminUserId },
#[error("invalid app_role stored in DB: {0}")]
InvalidRole(String),
}
/// One row of `app_members`. Returned by `list_for_user` / `list_for_app`
/// so handlers can render the cross-reference without joining to apps
/// or admin_users themselves.
#[derive(Debug, Clone)]
pub struct AppMembershipRow {
pub app_id: AppId,
pub user_id: AdminUserId,
pub role: AppRole,
pub created_at: DateTime<Utc>,
}
/// `app_members` row joined with `admin_users` so the dashboard's
/// Members tab can render usernames / emails / status without an N+1
/// fetch per row. Drives `GET /apps/{id}/members`.
#[derive(Debug, Clone)]
pub struct AppMembershipDetail {
pub user_id: AdminUserId,
pub username: String,
pub email: Option<String>,
pub instance_role: InstanceRole,
pub is_active: bool,
pub role: AppRole,
pub created_at: DateTime<Utc>,
}
#[async_trait]
pub trait AppMembersRepository: Send + Sync {
/// Single (user, app) lookup. Returns `None` for non-members and
/// for unrelated apps. This is the hot path for `authz::can`.
async fn find(
&self,
user_id: AdminUserId,
app_id: AppId,
) -> Result<Option<AppRole>, AppMembersRepositoryError>;
/// Upsert a membership. Used both for first-time grants and role
/// promotions/demotions on an existing row.
async fn upsert(
&self,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<AppMembershipRow, AppMembersRepositoryError>;
/// Atomic insert. Returns `Some(row)` on success, `None` if a
/// membership already exists. Lets the HTTP handler return 409
/// without a separate `find` round-trip (no TOCTOU between check
/// and insert).
async fn try_insert(
&self,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
/// Atomic role update. Returns `Some(row)` on success, `None` if no
/// membership row exists. Lets PATCH return 404 without a separate
/// `find` round-trip (no TOCTOU between check and update).
async fn update_role(
&self,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
/// Remove a membership. No-op (Ok) when the row doesn't exist —
/// the user wasn't a member, which is the desired post-condition.
async fn remove(
&self,
app_id: AppId,
user_id: AdminUserId,
) -> Result<(), AppMembersRepositoryError>;
/// Every membership the user holds. Drives the membership-filtered
/// list endpoints (`GET /admin/apps`, `GET /admin/scripts` for
/// `member` callers).
async fn list_for_user(
&self,
user_id: AdminUserId,
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>;
/// Every membership on a given app. Used by `GET
/// /admin/apps/{id}/members` once that surface lands; included now
/// so the trait is complete enough for tests.
async fn list_for_app(
&self,
app_id: AppId,
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>;
/// Like `list_for_app` but joined with `admin_users` so the
/// dashboard can render member rows in one round-trip. Ordered by
/// username for a stable list.
async fn list_for_app_enriched(
&self,
app_id: AppId,
) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError>;
}
pub struct PostgresAppMembersRepository {
pool: PgPool,
}
impl PostgresAppMembersRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl AppMembersRepository for PostgresAppMembersRepository {
async fn find(
&self,
user_id: AdminUserId,
app_id: AppId,
) -> Result<Option<AppRole>, AppMembersRepositoryError> {
let row: Option<(String,)> =
sqlx::query_as("SELECT role FROM app_members WHERE user_id = $1 AND app_id = $2")
.bind(user_id.into_inner())
.bind(app_id.into_inner())
.fetch_optional(&self.pool)
.await?;
row.map(|(role,)| {
AppRole::from_db_str(&role).ok_or(AppMembersRepositoryError::InvalidRole(role))
})
.transpose()
}
async fn upsert(
&self,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<AppMembershipRow, AppMembersRepositoryError> {
let row = sqlx::query_as::<_, AppMembershipRecord>(
"INSERT INTO app_members (app_id, user_id, role) \
VALUES ($1, $2, $3) \
ON CONFLICT (app_id, user_id) DO UPDATE SET role = EXCLUDED.role \
RETURNING app_id, user_id, role, created_at",
)
.bind(app_id.into_inner())
.bind(user_id.into_inner())
.bind(role.as_str())
.fetch_one(&self.pool)
.await?;
row.try_into()
}
async fn remove(
&self,
app_id: AppId,
user_id: AdminUserId,
) -> Result<(), AppMembersRepositoryError> {
sqlx::query("DELETE FROM app_members WHERE app_id = $1 AND user_id = $2")
.bind(app_id.into_inner())
.bind(user_id.into_inner())
.execute(&self.pool)
.await?;
Ok(())
}
async fn try_insert(
&self,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
let row = sqlx::query_as::<_, AppMembershipRecord>(
"INSERT INTO app_members (app_id, user_id, role) \
VALUES ($1, $2, $3) \
ON CONFLICT (app_id, user_id) DO NOTHING \
RETURNING app_id, user_id, role, created_at",
)
.bind(app_id.into_inner())
.bind(user_id.into_inner())
.bind(role.as_str())
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn update_role(
&self,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
let row = sqlx::query_as::<_, AppMembershipRecord>(
"UPDATE app_members SET role = $1 \
WHERE app_id = $2 AND user_id = $3 \
RETURNING app_id, user_id, role, created_at",
)
.bind(role.as_str())
.bind(app_id.into_inner())
.bind(user_id.into_inner())
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn list_for_user(
&self,
user_id: AdminUserId,
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError> {
let rows = sqlx::query_as::<_, AppMembershipRecord>(
"SELECT app_id, user_id, role, created_at \
FROM app_members WHERE user_id = $1 \
ORDER BY created_at",
)
.bind(user_id.into_inner())
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn list_for_app(
&self,
app_id: AppId,
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError> {
let rows = sqlx::query_as::<_, AppMembershipRecord>(
"SELECT app_id, user_id, role, created_at \
FROM app_members WHERE app_id = $1 \
ORDER BY created_at",
)
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn list_for_app_enriched(
&self,
app_id: AppId,
) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError> {
let rows = sqlx::query_as::<_, AppMembershipDetailRecord>(
"SELECT au.id, au.username, au.email, au.instance_role, au.is_active, \
am.role, am.created_at \
FROM app_members am \
JOIN admin_users au ON au.id = am.user_id \
WHERE am.app_id = $1 \
ORDER BY au.username",
)
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
}
/// Forwarding impl so the Postgres repo satisfies `AuthzRepo` directly
/// — handlers store a single `Arc<dyn AppMembersRepository>` and pass
/// it to `authz::can` without casting.
#[async_trait]
impl AuthzRepo for PostgresAppMembersRepository {
async fn membership(
&self,
user_id: AdminUserId,
app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
self.find(user_id, app_id)
.await
.map_err(|e| AuthzError::Repo(e.to_string()))
}
}
#[derive(sqlx::FromRow)]
struct AppMembershipRecord {
app_id: uuid::Uuid,
user_id: uuid::Uuid,
role: String,
created_at: DateTime<Utc>,
}
impl TryFrom<AppMembershipRecord> for AppMembershipRow {
type Error = AppMembersRepositoryError;
fn try_from(r: AppMembershipRecord) -> Result<Self, Self::Error> {
Ok(Self {
app_id: r.app_id.into(),
user_id: r.user_id.into(),
role: AppRole::from_db_str(&r.role)
.ok_or(AppMembersRepositoryError::InvalidRole(r.role))?,
created_at: r.created_at,
})
}
}
#[derive(sqlx::FromRow)]
struct AppMembershipDetailRecord {
id: uuid::Uuid,
username: String,
email: Option<String>,
instance_role: String,
is_active: bool,
role: String,
created_at: DateTime<Utc>,
}
impl TryFrom<AppMembershipDetailRecord> for AppMembershipDetail {
type Error = AppMembersRepositoryError;
fn try_from(r: AppMembershipDetailRecord) -> Result<Self, Self::Error> {
Ok(Self {
user_id: r.id.into(),
username: r.username,
email: r.email,
instance_role: InstanceRole::from_db_str(&r.instance_role)
.ok_or(AppMembersRepositoryError::InvalidRole(r.instance_role))?,
is_active: r.is_active,
role: AppRole::from_db_str(&r.role)
.ok_or(AppMembersRepositoryError::InvalidRole(r.role))?,
created_at: r.created_at,
})
}
}

View File

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

View File

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

View File

@@ -0,0 +1,231 @@
//! Pure auth helpers: password hashing, session-token generation, and
//! token-to-hash conversion. No DB, no HTTP — repos and middleware live
//! in their own modules. Keeping this surface pure also keeps the unit
//! tests fast (no Postgres needed).
//!
//! Hash algorithm is Argon2id with the OWASP default parameters
//! (`Argon2::default()`). Tokens are 32 cryptographically random bytes
//! base64-url-encoded for the wire; their SHA-256 (hex) is what hits the
//! sessions table.
use argon2::password_hash::rand_core::OsRng as ArgonRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use data_encoding::BASE32_NOPAD;
use rand::rngs::OsRng;
use rand::RngCore;
use sha2::{Digest, Sha256};
/// Returned when the supplied password hash string isn't a valid PHC
/// Argon2id encoding. Only surfaces at bootstrap time when the operator
/// passes `PICLOUD_ADMIN_PASSWORD_HASH`.
#[derive(Debug, thiserror::Error)]
#[error("invalid Argon2id PHC hash")]
pub struct InvalidPasswordHash;
/// Hash a raw password into an Argon2id PHC-formatted string suitable
/// for `admin_users.password_hash`. The output already encodes the salt
/// and parameters; nothing else needs to be persisted alongside it.
pub fn hash_password(raw: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut ArgonRng);
let hash = Argon2::default().hash_password(raw.as_bytes(), &salt)?;
Ok(hash.to_string())
}
/// Constant-ish-time verify of a raw password against a PHC hash.
/// Returns `false` for any error (including malformed stored hash) —
/// callers should treat that case identically to "wrong password" so
/// nothing leaks about why auth failed.
#[must_use]
pub fn verify_password(stored_hash: &str, raw: &str) -> bool {
let Ok(parsed) = PasswordHash::new(stored_hash) else {
return false;
};
Argon2::default()
.verify_password(raw.as_bytes(), &parsed)
.is_ok()
}
/// Validate that a string parses as a PHC Argon2id hash — used at
/// bootstrap to fail fast on malformed `PICLOUD_ADMIN_PASSWORD_HASH`
/// rather than write garbage into the DB and discover it at first login.
pub fn validate_password_hash(stored_hash: &str) -> Result<(), InvalidPasswordHash> {
PasswordHash::new(stored_hash).map_err(|_| InvalidPasswordHash)?;
Ok(())
}
/// Newly minted session token: `raw` goes to the client (cookie + JSON
/// response), `hash` is what gets stored. Raw is unrecoverable from hash
/// even if the DB leaks.
pub struct GeneratedToken {
pub raw: String,
pub hash: String,
}
/// Generate a fresh session token (32 random bytes base64-url-encoded).
/// Always succeeds — `OsRng::fill_bytes` panics on entropy failure
/// instead of returning, but that's a non-recoverable system condition.
#[must_use]
pub fn generate_session_token() -> GeneratedToken {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
let raw = URL_SAFE_NO_PAD.encode(bytes);
let hash = hash_token(&raw);
GeneratedToken { raw, hash }
}
/// SHA-256(raw) as lower-case hex. Stable lookup key for
/// `admin_sessions.token_hash`.
#[must_use]
pub fn hash_token(raw: &str) -> String {
let digest = Sha256::digest(raw.as_bytes());
hex(&digest)
}
fn hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
// ----------------------------------------------------------------------------
// API key generation (Phase 3.5)
// ----------------------------------------------------------------------------
/// Wire-format prefix that marks a Bearer value as an API key (vs. a
/// session token). Mirrors `auth_middleware::API_KEY_PREFIX` so the
/// generator and the verifier agree.
pub const API_KEY_WIRE_PREFIX: &str = "pic_";
/// Length of the indexed prefix portion (the first 8 chars of the
/// `pic_`-stripped body). Mirrors `auth_middleware::API_KEY_PREFIX_LEN`.
pub const API_KEY_INDEX_PREFIX_LEN: usize = 8;
/// Newly minted API key — returned exactly once by `POST /api/v1/admin/api-keys`.
///
/// * `raw` is the full wire-format token (`pic_<base32>`) shown to the
/// caller in the response body and never persisted.
/// * `prefix` is the indexed 8-char slice persisted to
/// `api_keys.prefix` for lookup.
/// * `hash` is the Argon2id PHC string persisted to `api_keys.hash`;
/// covers the body after `pic_` (i.e., `raw[4..]`).
pub struct GeneratedApiKey {
pub raw: String,
pub prefix: String,
pub hash: String,
}
/// Generate a fresh API key. 32 random bytes → unpadded base32, then
/// `pic_` prefix on the wire. The first 8 base32 chars are the index
/// key; everything after `pic_` is what the verifier hashes.
///
/// # Errors
///
/// Returns `argon2::password_hash::Error` if the Argon2 hash step
/// fails (which it shouldn't under normal conditions).
pub fn generate_api_key() -> Result<GeneratedApiKey, argon2::password_hash::Error> {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
let body = BASE32_NOPAD.encode(&bytes);
debug_assert!(
body.len() >= API_KEY_INDEX_PREFIX_LEN,
"32 bytes base32 must exceed the 8-char prefix length"
);
let prefix = body[..API_KEY_INDEX_PREFIX_LEN].to_string();
let salt = SaltString::generate(&mut ArgonRng);
let hash = Argon2::default()
.hash_password(body.as_bytes(), &salt)?
.to_string();
let raw = format!("{API_KEY_WIRE_PREFIX}{body}");
Ok(GeneratedApiKey { raw, prefix, hash })
}
/// Verify a wire-format token body (the portion *after* `pic_`)
/// against a stored Argon2id hash. Convenience wrapper around
/// `verify_password` named to reflect its caller.
#[must_use]
pub fn verify_api_key(stored_hash: &str, presented_body: &str) -> bool {
verify_password(stored_hash, presented_body)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_verify_roundtrip() {
let h = hash_password("correct horse battery staple").unwrap();
assert!(verify_password(&h, "correct horse battery staple"));
assert!(!verify_password(&h, "wrong"));
}
#[test]
fn verify_returns_false_on_malformed_hash() {
assert!(!verify_password("not-a-phc-string", "anything"));
}
#[test]
fn validate_password_hash_accepts_phc() {
let h = hash_password("pw").unwrap();
assert!(validate_password_hash(&h).is_ok());
}
#[test]
fn validate_password_hash_rejects_garbage() {
assert!(validate_password_hash("not a hash").is_err());
}
#[test]
fn generate_token_unique_and_hash_stable() {
let a = generate_session_token();
let b = generate_session_token();
assert_ne!(a.raw, b.raw, "tokens must be unique");
assert_ne!(a.hash, b.hash, "hashes must differ");
assert_eq!(a.hash, hash_token(&a.raw), "hash must be reproducible");
assert_eq!(a.hash.len(), 64, "sha256-hex is 64 chars");
}
#[test]
fn generate_api_key_round_trip() {
let key = generate_api_key().expect("mint");
assert!(
key.raw.starts_with(API_KEY_WIRE_PREFIX),
"raw must carry the pic_ prefix"
);
let body = key
.raw
.strip_prefix(API_KEY_WIRE_PREFIX)
.expect("starts with prefix");
assert_eq!(
&body[..API_KEY_INDEX_PREFIX_LEN],
key.prefix,
"stored prefix matches the first 8 chars of the body"
);
assert!(
verify_api_key(&key.hash, body),
"Argon2 verify must accept the original body"
);
assert!(
!verify_api_key(&key.hash, "wrong-body-entirely"),
"Argon2 verify must reject anything else"
);
}
#[test]
fn generate_api_key_unique() {
let a = generate_api_key().expect("mint a");
let b = generate_api_key().expect("mint b");
assert_ne!(a.raw, b.raw);
assert_ne!(a.hash, b.hash);
assert_ne!(
a.prefix, b.prefix,
"32 random bytes → prefix collision is negligible"
);
}
}

View File

@@ -0,0 +1,269 @@
//! `/api/v1/admin/auth/*` — login, logout, who-am-I.
//!
//! Login mints an opaque session token, stores its SHA-256, sets the
//! `picloud_session` HttpOnly cookie, and also returns the raw token in
//! the JSON body for non-browser clients. The same token works as
//! `Authorization: Bearer …` afterward; there is no separate "API
//! token" concept yet.
//!
//! Logout deletes the session row regardless of whether the supplied
//! token matched anything (idempotent). `me` returns the row that the
//! middleware already attached to the request extensions.
use axum::body::Body;
use axum::extract::{Extension, Request, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::middleware::from_fn_with_state;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, post};
use axum::Router;
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use picloud_shared::{AdminUserId, InstanceRole};
use serde::{Deserialize, Serialize};
use serde_json::json;
use picloud_shared::Principal;
use crate::auth::{generate_session_token, hash_token, verify_password};
use crate::auth_middleware::{require_authenticated, AuthState, SESSION_COOKIE};
pub fn auth_router(state: AuthState) -> Router {
// /login + /logout are unguarded (login is how you get in; logout
// is idempotent). /me is guarded — by definition it needs to know
// who you are, so the middleware must run first.
let guarded = Router::new()
.route("/auth/me", get(me))
.route_layer(from_fn_with_state(state.clone(), require_authenticated));
Router::new()
.route("/auth/login", post(login))
.route("/auth/logout", post(logout))
.merge(guarded)
.with_state(state)
}
// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct LoginResponse {
pub user: AdminUserDto,
pub token: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Serialize)]
pub struct AdminUserDto {
pub id: AdminUserId,
pub username: String,
pub instance_role: InstanceRole,
pub email: Option<String>,
}
// ----------------------------------------------------------------------------
// Handlers
// ----------------------------------------------------------------------------
async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>) -> Response {
// Always perform a verify, even on missing/inactive users, to flatten
// timing and prevent username enumeration. The dummy hash is a real
// Argon2id PHC string for "x" — the verify will simply fail.
const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$dGltaW5nLWZsYXR0ZW4$Ux6dgPqgX1Mhg5fRgIeKZF3MWdYqJplKEz/cKLcSdks";
let creds = match state
.users
.get_credentials_by_username(&input.username)
.await
{
Ok(c) => c,
Err(err) => {
tracing::error!(?err, "admin_users credentials lookup failed");
return internal_error();
}
};
// username from creds is discarded — the re-fetch below carries the
// canonical row used in the response DTO.
let (stored_hash, user_id, is_active) = match creds {
Some(c) => (c.password_hash, Some(c.id), c.is_active),
None => (DUMMY_HASH.to_string(), None, false),
};
let password_ok = verify_password(&stored_hash, &input.password);
if !password_ok || user_id.is_none() || !is_active {
return invalid_credentials();
}
let user_id = user_id.unwrap();
// Re-fetch the full row so the login response carries the same
// shape /me does (instance_role, email). The credentials struct
// intentionally omits email; one extra query per login is fine.
let user_row = match state.users.get(user_id).await {
Ok(Some(row)) => row,
Ok(None) => return invalid_credentials(),
Err(err) => {
tracing::error!(?err, "admin_users lookup after login failed");
return internal_error();
}
};
let token = generate_session_token();
let expires_at = Utc::now()
+ ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
if let Err(err) = state
.sessions
.create(user_id, &token.hash, expires_at)
.await
{
tracing::error!(?err, "admin_sessions insert failed");
return internal_error();
}
if let Err(err) = state.users.touch_last_login(user_id).await {
// Non-fatal — log and continue. Login itself succeeded.
tracing::warn!(?err, "failed to touch admin last_login_at");
}
let mut headers = HeaderMap::new();
headers.insert(
header::SET_COOKIE,
HeaderValue::from_str(&build_cookie(&token.raw, state.ttl)).unwrap_or_else(|_| {
// Cookie text is ASCII-clean by construction; this branch is
// unreachable in practice but the type signature requires it.
HeaderValue::from_static("")
}),
);
(
StatusCode::OK,
headers,
Json(LoginResponse {
user: AdminUserDto {
id: user_row.id,
username: user_row.username,
instance_role: user_row.instance_role,
email: user_row.email,
},
token: token.raw,
expires_at,
}),
)
.into_response()
}
async fn logout(State(state): State<AuthState>, req: Request<Body>) -> Response {
// Pull token without requiring a valid session (logout is idempotent
// and we still want to clear the cookie on the client side).
let token = extract_token_for_logout(&req);
if let Some(raw) = token {
let hash = hash_token(&raw);
if let Err(err) = state.sessions.delete(&hash).await {
tracing::error!(?err, "admin_sessions delete failed");
// Still clear the cookie below.
}
}
let mut headers = HeaderMap::new();
headers.insert(
header::SET_COOKIE,
HeaderValue::from_static("picloud_session=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0"),
);
(StatusCode::NO_CONTENT, headers).into_response()
}
async fn me(
State(state): State<AuthState>,
Extension(principal): Extension<Principal>,
) -> Response {
// /me consumes the resolved Principal directly; we re-fetch the
// user row only to surface a fresh username (it can change via
// PATCH while a session/key is still valid).
match state.users.get(principal.user_id).await {
Ok(Some(row)) => Json(AdminUserDto {
id: row.id,
username: row.username,
instance_role: row.instance_role,
email: row.email,
})
.into_response(),
Ok(None) => invalid_credentials(),
Err(err) => {
tracing::error!(?err, "admin_users lookup for /me failed");
internal_error()
}
}
}
// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------
fn build_cookie(raw_token: &str, ttl: std::time::Duration) -> String {
// Secure is on by default; flip to off for HTTP-only dev with
// PICLOUD_COOKIE_SECURE=0. The header-injected bearer token works
// either way, so this is purely for browsers that prefer the cookie
// path (e.g., direct API hits without the dashboard's auth.ts).
let secure = std::env::var("PICLOUD_COOKIE_SECURE").ok().is_none_or(|v| {
!matches!(
v.to_ascii_lowercase().as_str(),
"0" | "false" | "no" | "off"
)
});
let secure_attr = if secure { "; Secure" } else { "" };
format!(
"{SESSION_COOKIE}={raw_token}; HttpOnly{secure_attr}; SameSite=Lax; Path=/; Max-Age={}",
ttl.as_secs()
)
}
fn extract_token_for_logout(req: &Request<Body>) -> Option<String> {
// Same precedence as the middleware — Authorization first, cookie
// fallback. Duplicated here because logout has to read the request
// before any middleware would run.
if let Some(value) = req.headers().get(header::AUTHORIZATION) {
if let Ok(s) = value.to_str() {
if let Some(token) = s.strip_prefix("Bearer ") {
let trimmed = token.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
}
if let Some(value) = req.headers().get(header::COOKIE) {
if let Ok(s) = value.to_str() {
for chunk in s.split(';') {
let chunk = chunk.trim();
if let Some(rest) = chunk.strip_prefix(&format!("{SESSION_COOKIE}=")) {
if !rest.is_empty() {
return Some(rest.to_string());
}
}
}
}
}
None
}
fn invalid_credentials() -> Response {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid credentials" })),
)
.into_response()
}
fn internal_error() -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "internal error" })),
)
.into_response()
}

View File

@@ -0,0 +1,331 @@
//! First-run admin seeding from env vars. Idempotent: if any admin
//! already exists, this is a no-op (and a warning is logged when the
//! env vars are also set, so the operator notices the inert state).
//!
//! On a fresh install, exactly one row is inserted from:
//! - `PICLOUD_ADMIN_USERNAME` (required)
//! - `PICLOUD_ADMIN_PASSWORD_HASH` (preferred — pre-computed PHC) OR
//! - `PICLOUD_ADMIN_PASSWORD` (fallback — raw, hashed on the way in)
//!
//! After that initial seed, the env vars become inert. This is
//! deliberate: the env var is a one-time setup hatch, not a permanent
//! override (which would let anyone with systemd/compose access change
//! any admin's password without authentication). Recovery is the CLI
//! subcommand `picloud admin reset-password <username>`.
//!
//! The env-var reading is factored into `BootstrapEnv::from_process`
//! so the core logic stays pure (and testable) — the only side effect
//! in `bootstrap_first_admin` is the DB write and a tracing log.
use tracing::{info, warn};
use crate::admin_user_repo::AdminUserRepository;
use crate::auth::{hash_password, validate_password_hash};
pub const ENV_USERNAME: &str = "PICLOUD_ADMIN_USERNAME";
pub const ENV_PASSWORD: &str = "PICLOUD_ADMIN_PASSWORD";
pub const ENV_PASSWORD_HASH: &str = "PICLOUD_ADMIN_PASSWORD_HASH";
#[derive(Debug, thiserror::Error)]
pub enum BootstrapError {
#[error("repository error: {0}")]
Repo(#[from] crate::admin_user_repo::AdminUserRepositoryError),
#[error("{ENV_USERNAME} not set (required to bootstrap the first admin)")]
MissingUsername,
#[error(
"no admin password env var set; provide {ENV_PASSWORD_HASH} (preferred) or {ENV_PASSWORD}"
)]
MissingPassword,
#[error("{ENV_PASSWORD_HASH} is not a valid Argon2id PHC string")]
InvalidHash,
#[error("failed to hash password: {0}")]
HashFailure(String),
}
/// Captured-at-call-site env values. The fields map 1:1 to the bootstrap
/// env vars. Read from the live process with `from_process`, or build
/// directly in tests to keep them free of process-env races.
#[derive(Debug, Default, Clone)]
pub struct BootstrapEnv {
pub username: Option<String>,
pub password: Option<String>,
pub password_hash: Option<String>,
}
impl BootstrapEnv {
/// Snapshot the bootstrap env vars from the current process.
#[must_use]
pub fn from_process() -> Self {
Self {
username: std::env::var(ENV_USERNAME).ok(),
password: std::env::var(ENV_PASSWORD).ok(),
password_hash: std::env::var(ENV_PASSWORD_HASH).ok(),
}
}
fn any_set(&self) -> bool {
self.username.is_some() || self.password.is_some() || self.password_hash.is_some()
}
}
/// Run the bootstrap. Reads env vars from the live process — the
/// canonical wiring for the binary.
pub async fn bootstrap_first_admin<R: AdminUserRepository + ?Sized>(
repo: &R,
) -> Result<(), BootstrapError> {
bootstrap_first_admin_with(repo, BootstrapEnv::from_process()).await
}
/// Run the bootstrap against an explicit env. Used by tests to keep
/// the bootstrap logic independent of process state.
pub async fn bootstrap_first_admin_with<R: AdminUserRepository + ?Sized>(
repo: &R,
env: BootstrapEnv,
) -> Result<(), BootstrapError> {
if repo.count_active().await? > 0 {
if env.any_set() {
warn!(
"{ENV_USERNAME}/{ENV_PASSWORD}/{ENV_PASSWORD_HASH} set but admin_users \
already populated — env values ignored. Use \
`picloud admin reset-password <user>` to change a password."
);
}
return Ok(());
}
let username = env.username.ok_or(BootstrapError::MissingUsername)?;
let password_hash = match (env.password_hash, env.password) {
(Some(hash), maybe_raw) => {
if maybe_raw.is_some() {
warn!(
"both {ENV_PASSWORD_HASH} and {ENV_PASSWORD} set — \
using the pre-computed hash; raw password ignored."
);
}
validate_password_hash(&hash).map_err(|_| BootstrapError::InvalidHash)?;
hash
}
(None, Some(raw)) => {
hash_password(&raw).map_err(|e| BootstrapError::HashFailure(e.to_string()))?
}
(None, None) => return Err(BootstrapError::MissingPassword),
};
// Bootstrap admin is always seeded as Owner — Phase 3.5 keys the
// first row to full instance control. Subsequent admins minted via
// the API default to Admin and can be promoted explicitly.
repo.create(
&username,
&password_hash,
picloud_shared::InstanceRole::Owner,
None,
)
.await?;
info!(username = %username, "bootstrapped initial admin user");
Ok(())
}
#[cfg(test)]
mod tests {
//! These tests use an in-memory `AdminUserRepository` and the
//! `bootstrap_first_admin_with` overload so they never touch
//! process-global env vars. They can run in parallel safely.
use super::*;
use async_trait::async_trait;
use chrono::Utc;
use picloud_shared::{AdminUserId, InstanceRole};
use std::sync::Mutex;
use crate::admin_user_repo::{AdminUserCredentials, AdminUserRepositoryError, AdminUserRow};
#[derive(Default)]
struct InMemoryRepo {
rows: Mutex<Vec<AdminUserRow>>,
}
#[async_trait]
impl AdminUserRepository for InMemoryRepo {
async fn get(
&self,
_id: AdminUserId,
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
unimplemented!()
}
async fn get_by_username(
&self,
_u: &str,
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
unimplemented!()
}
async fn get_credentials_by_username(
&self,
_u: &str,
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError> {
unimplemented!()
}
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
unimplemented!()
}
async fn create(
&self,
username: &str,
_password_hash: &str,
instance_role: InstanceRole,
email: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let row = AdminUserRow {
id: AdminUserId::new(),
username: username.to_string(),
is_active: true,
instance_role,
email: email.map(str::to_string),
created_at: Utc::now(),
updated_at: Utc::now(),
last_login_at: None,
};
self.rows.lock().unwrap().push(row.clone());
Ok(row)
}
async fn update_username(
&self,
_i: AdminUserId,
_u: &str,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
unimplemented!()
}
async fn update_password_hash(
&self,
_i: AdminUserId,
_h: &str,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
unimplemented!()
}
async fn update_email(
&self,
_i: AdminUserId,
_e: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
unimplemented!()
}
async fn update_instance_role(
&self,
_i: AdminUserId,
_r: InstanceRole,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
unimplemented!()
}
async fn set_active(
&self,
_i: AdminUserId,
_a: bool,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
unimplemented!()
}
async fn delete(&self, _i: AdminUserId) -> Result<(), AdminUserRepositoryError> {
unimplemented!()
}
async fn touch_last_login(&self, _i: AdminUserId) -> Result<(), AdminUserRepositoryError> {
unimplemented!()
}
async fn count_active(&self) -> Result<i64, AdminUserRepositoryError> {
Ok(i64::try_from(self.rows.lock().unwrap().len()).unwrap_or(i64::MAX))
}
async fn count_active_excluding(
&self,
_i: AdminUserId,
) -> Result<i64, AdminUserRepositoryError> {
unimplemented!()
}
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
unimplemented!()
}
async fn count_other_active_owners(
&self,
_i: AdminUserId,
) -> Result<i64, AdminUserRepositoryError> {
unimplemented!()
}
}
#[tokio::test]
async fn empty_db_creates_admin_from_raw_password() {
let repo = InMemoryRepo::default();
let env = BootstrapEnv {
username: Some("alice".into()),
password: Some("supersecret".into()),
password_hash: None,
};
bootstrap_first_admin_with(&repo, env).await.unwrap();
assert_eq!(repo.rows.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn empty_db_with_pre_hashed_password_succeeds() {
let repo = InMemoryRepo::default();
let prehashed = hash_password("pw").unwrap();
let env = BootstrapEnv {
username: Some("alice".into()),
password: None,
password_hash: Some(prehashed),
};
bootstrap_first_admin_with(&repo, env).await.unwrap();
assert_eq!(repo.rows.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn populated_db_is_noop() {
let repo = InMemoryRepo::default();
repo.create("seeded", "x", InstanceRole::Owner, None)
.await
.unwrap();
let env = BootstrapEnv {
username: Some("alice".into()),
password: Some("supersecret".into()),
password_hash: None,
};
bootstrap_first_admin_with(&repo, env).await.unwrap();
assert_eq!(repo.rows.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn missing_username_fails() {
let repo = InMemoryRepo::default();
let env = BootstrapEnv {
username: None,
password: Some("supersecret".into()),
password_hash: None,
};
let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err();
assert!(matches!(err, BootstrapError::MissingUsername));
}
#[tokio::test]
async fn missing_password_fails() {
let repo = InMemoryRepo::default();
let env = BootstrapEnv {
username: Some("alice".into()),
password: None,
password_hash: None,
};
let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err();
assert!(matches!(err, BootstrapError::MissingPassword));
}
#[tokio::test]
async fn invalid_hash_fails() {
let repo = InMemoryRepo::default();
let env = BootstrapEnv {
username: Some("alice".into()),
password: None,
password_hash: Some("not a phc hash".into()),
};
let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidHash));
}
}

View File

@@ -0,0 +1,377 @@
//! Authentication middleware — resolves the caller's `Principal` from
//! either a session cookie / Bearer session-token OR an API key
//! (`Authorization: Bearer pic_…`). Both paths converge on the same
//! request extension so downstream handlers see one shape.
//!
//! Capability checks live in `crate::authz` and are called per-handler
//! (after the relevant resource is loaded, so the capability binds to
//! the actual resource's `app_id`). This middleware is gate-only: it
//! ensures *some* `Principal` is attached, or returns 401.
//!
//! Token discriminator: the `pic_` prefix on a Bearer value selects
//! the API-key path; anything else (raw 32-byte base64-url-encoded
//! string) takes the session path. The session cookie can only ever
//! carry a session token (cookies are never API keys).
use std::sync::Arc;
use std::time::Duration;
use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::{header, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Json, Response};
use chrono::Utc;
use picloud_shared::{AdminUserId, Principal};
use serde_json::json;
use crate::admin_session_repo::AdminSessionRepository;
use crate::admin_user_repo::AdminUserRepository;
use crate::api_key_repo::{ApiKeyRepository, ApiKeyVerification};
use crate::auth::{hash_token, verify_password};
pub const SESSION_COOKIE: &str = "picloud_session";
/// Prefix on the wire that selects the API-key path. The body that
/// follows is `base32(32 random bytes)`; the first 8 chars of the body
/// index into `api_keys.prefix` for verification.
pub const API_KEY_PREFIX: &str = "pic_";
/// Length of the indexed prefix portion of an API key (the 8 chars
/// immediately after `pic_`). Schema-side index is on this slice.
pub const API_KEY_PREFIX_LEN: usize = 8;
/// Shared state for auth: the user / session / API-key repos plus the
/// configured sliding session TTL. Cheap to clone (`Arc` everywhere).
#[derive(Clone)]
pub struct AuthState {
pub users: Arc<dyn AdminUserRepository>,
pub sessions: Arc<dyn AdminSessionRepository>,
pub keys: Arc<dyn ApiKeyRepository>,
pub ttl: Duration,
}
/// Legacy request-extension alias retained so the (only remaining)
/// handler that pulled `AuthedAdmin` out — `GET /admin/auth/me` —
/// keeps compiling during the migration. New handlers should pull
/// `Extension<Principal>` directly.
#[deprecated(note = "use Extension<Principal> directly")]
#[derive(Debug, Clone)]
pub struct AuthedAdmin {
pub id: AdminUserId,
pub username: String,
}
/// Middleware entry point. Wire with
/// `axum::middleware::from_fn_with_state(auth_state, require_authenticated)`.
/// Inserts `Principal` (and the legacy `AuthedAdmin`) as request
/// extensions on success; returns 401 on any failure mode.
pub async fn require_authenticated(
State(state): State<AuthState>,
mut req: Request<Body>,
next: Next,
) -> Response {
let Some(token) = extract_token(&req) else {
return unauthorized();
};
let principal = match resolve_principal(&state, &token).await {
Ok(Some(p)) => p,
Ok(None) => return unauthorized(),
Err(InternalError) => return internal_error(),
};
let username_for_legacy = username_for(&state, principal.user_id).await;
req.extensions_mut().insert(principal.clone());
#[allow(deprecated)]
if let Some(username) = username_for_legacy {
req.extensions_mut().insert(AuthedAdmin {
id: principal.user_id,
username,
});
}
next.run(req).await
}
/// Backwards-compatible alias — the single callsite that still names
/// `require_admin` keeps working without an immediate rename. New
/// wiring should call `require_authenticated`.
#[deprecated(note = "renamed to require_authenticated")]
pub async fn require_admin(state: State<AuthState>, req: Request<Body>, next: Next) -> Response {
require_authenticated(state, req, next).await
}
/// Opportunistic data-plane variant: always inserts an
/// `Extension<Option<Principal>>` and forwards the request. Used on
/// `/execute/{id}` and the user-route fallback, where most invocations
/// are anonymous public HTTP and the few authed ones (dashboard
/// test-runs, API keys) should still let scripts see the caller via
/// `cx.principal` once services consume it.
///
/// Failure modes — all degrade to `None` rather than rejecting:
/// * No bearer / cookie → `None`.
/// * Malformed or unknown token → `None`.
/// * DB blip while resolving → `None` (fail-open; the data plane
/// should not 500 on transient infra failures for an *optional*
/// identity check).
///
/// Admin-side routes that REQUIRE an identity keep using
/// `require_authenticated`.
pub async fn attach_principal_if_present(
State(state): State<AuthState>,
mut req: Request<Body>,
next: Next,
) -> Response {
let principal: Option<Principal> = match extract_token(&req) {
Some(token) => resolve_principal(&state, &token).await.unwrap_or(None),
None => None,
};
req.extensions_mut().insert(principal);
next.run(req).await
}
/// Decide whether the token is an API key (pic_ prefix) or a session
/// token, then resolve the corresponding `Principal`. `Ok(None)`
/// means the token was structurally valid but didn't match any active
/// credential; `Err(InternalError)` means a DB blip.
async fn resolve_principal(
state: &AuthState,
token: &str,
) -> Result<Option<Principal>, InternalError> {
if let Some(rest) = token.strip_prefix(API_KEY_PREFIX) {
return verify_api_key(state, rest).await;
}
verify_session(state, token).await
}
async fn verify_session(
state: &AuthState,
token: &str,
) -> Result<Option<Principal>, InternalError> {
let token_hash = hash_token(token);
let lookup = match state.sessions.lookup(&token_hash).await {
Ok(Some(l)) => l,
Ok(None) => return Ok(None),
Err(err) => {
tracing::error!(?err, "admin_sessions lookup failed");
return Err(InternalError);
}
};
let user = match state.users.get(lookup.user_id).await {
Ok(Some(u)) if u.is_active => u,
Ok(_) => return Ok(None),
Err(err) => {
tracing::error!(?err, "admin_users lookup failed");
return Err(InternalError);
}
};
// Sliding-window bump — inline so a DB blip surfaces as 500 rather
// than silent stale sessions. Same shape as Phase 3a.
let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default();
if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await {
tracing::error!(?err, "admin_sessions touch failed");
return Err(InternalError);
}
Ok(Some(Principal {
user_id: user.id,
instance_role: user.instance_role,
scopes: None,
app_binding: None,
}))
}
/// API-key verification path. `rest` is the portion of the bearer
/// value *after* `pic_`. We slice off the first 8 chars as the
/// indexed lookup key, then Argon2id-verify each candidate's hash
/// against the full `rest`. At most one match is expected; multiple
/// candidates with the same prefix is statistically negligible but
/// handled correctly (verify each, take the first match).
async fn verify_api_key(state: &AuthState, rest: &str) -> Result<Option<Principal>, InternalError> {
if rest.len() <= API_KEY_PREFIX_LEN {
return Ok(None);
}
let prefix = &rest[..API_KEY_PREFIX_LEN];
let candidates = match state.keys.find_active_by_prefix(prefix).await {
Ok(v) => v,
Err(err) => {
tracing::error!(?err, "api_keys lookup failed");
return Err(InternalError);
}
};
let matched: Option<ApiKeyVerification> = candidates
.into_iter()
.find(|c| verify_password(&c.hash, rest));
let Some(matched) = matched else {
return Ok(None);
};
// Resolve the owning user. is_active = false → reject even if the
// key itself hasn't been expired yet (the expire_all_for_user
// cascade on deactivation is the primary defense; this is the
// belt-and-suspenders check at request time).
let user = match state.users.get(matched.user_id).await {
Ok(Some(u)) if u.is_active => u,
Ok(_) => return Ok(None),
Err(err) => {
tracing::error!(?err, "admin_users lookup for api key failed");
return Err(InternalError);
}
};
if let Err(err) = state.keys.touch_last_used(matched.id).await {
tracing::error!(?err, "api_keys touch_last_used failed");
// Soft-fail: a timestamp blip should not invalidate the
// request. Continue with the resolved Principal.
}
Ok(Some(Principal {
user_id: user.id,
instance_role: user.instance_role,
scopes: Some(matched.scopes),
app_binding: matched.app_id,
}))
}
/// Best-effort username lookup for the legacy `AuthedAdmin` extension.
/// Returns `None` on DB error (the caller treats `None` as "skip the
/// legacy extension"). New handlers use `Principal` and don't depend
/// on this.
async fn username_for(state: &AuthState, id: AdminUserId) -> Option<String> {
match state.users.get(id).await {
Ok(Some(u)) => Some(u.username),
Ok(None) => None,
Err(err) => {
tracing::warn!(
?err,
"username lookup for AuthedAdmin failed; skipping legacy ext"
);
None
}
}
}
/// Pull the bearer token out of an `Authorization` header (preferred)
/// or the `picloud_session` cookie (fallback for browser clients).
/// Same shape as Phase 3a; the cookie only ever carries session
/// tokens — no `pic_` prefix expected there.
fn extract_token(req: &Request<Body>) -> Option<String> {
if let Some(value) = req.headers().get(header::AUTHORIZATION) {
if let Ok(s) = value.to_str() {
if let Some(token) = s.strip_prefix("Bearer ") {
let trimmed = token.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
}
if let Some(value) = req.headers().get(header::COOKIE) {
if let Ok(s) = value.to_str() {
for chunk in s.split(';') {
let chunk = chunk.trim();
if let Some(rest) = chunk.strip_prefix(&format!("{SESSION_COOKIE}=")) {
if !rest.is_empty() {
return Some(rest.to_string());
}
}
}
}
}
None
}
/// Sentinel returned from the resolve functions when a DB error should
/// produce a 500 rather than a 401. Empty struct because the actual
/// error is already logged at the failure site.
struct InternalError;
fn unauthorized() -> Response {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "authentication required" })),
)
.into_response()
}
fn internal_error() -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "internal error" })),
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::Request;
use picloud_shared::InstanceRole;
fn req_with_header(name: &str, value: &str) -> Request<Body> {
Request::builder()
.header(name, value)
.body(Body::empty())
.unwrap()
}
#[test]
fn extracts_bearer_token() {
let r = req_with_header("authorization", "Bearer abc123");
assert_eq!(extract_token(&r).as_deref(), Some("abc123"));
}
#[test]
fn extracts_bearer_pic_prefixed_token() {
let r = req_with_header("authorization", "Bearer pic_abcdefghIJKL");
assert_eq!(extract_token(&r).as_deref(), Some("pic_abcdefghIJKL"));
}
#[test]
fn ignores_bearer_with_no_token() {
let r = req_with_header("authorization", "Bearer ");
assert_eq!(extract_token(&r), None);
}
#[test]
fn extracts_cookie_token() {
let r = req_with_header("cookie", "foo=bar; picloud_session=xyz; baz=qux");
assert_eq!(extract_token(&r).as_deref(), Some("xyz"));
}
#[test]
fn bearer_wins_over_cookie() {
let r = Request::builder()
.header("authorization", "Bearer header-token")
.header("cookie", "picloud_session=cookie-token")
.body(Body::empty())
.unwrap();
assert_eq!(extract_token(&r).as_deref(), Some("header-token"));
}
#[test]
fn returns_none_when_neither_present() {
let r = Request::builder().body(Body::empty()).unwrap();
assert_eq!(extract_token(&r), None);
}
// Round-trip test for the unused-variable to keep `Principal`
// visibly tied to InstanceRole — caught a real bug during dev when
// the field order in the struct literal had drifted.
#[test]
fn principal_construction_is_explicit() {
let p = Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: None,
app_binding: None,
};
assert_eq!(p.instance_role, InstanceRole::Owner);
assert!(p.scopes.is_none());
assert!(p.app_binding.is_none());
}
}

View File

@@ -0,0 +1,614 @@
//! Capability-based authorization — see blueprint §11.6.
//!
//! Single entry point for every admin endpoint: `can(repo, principal,
//! capability)` returns whether the caller can perform the action.
//! Handlers call `require` (which wraps `can` + a `Forbidden` error)
//! after loading the resource so the capability binds to the resource's
//! actual `app_id`, not a path param the caller controls.
//!
//! Three layers of intersection, evaluated in order:
//!
//! 1. **Role grant** — does the caller's `InstanceRole` plus any
//! `app_members` row authorize this capability?
//! 2. **Scope intersection** — if the principal came from an API key
//! (`principal.scopes.is_some()`), does the key's scope set cover
//! the capability's required scope?
//! 3. **App binding** — if the key was minted bound to a specific
//! app (`principal.app_binding`), does the capability target the
//! same app? (Instance-level capabilities are denied for bound
//! keys; the mint handler also rejects the combination upfront.)
//!
//! The capability set is intentionally finer-grained than the seven
//! scopes (e.g., `AppWriteScript` vs `AppWriteRoute` both fall under
//! the `script:write` / `route:write` scopes respectively). Keeping
//! capabilities precise lets a `script:write`-only key write scripts
//! without also being able to mutate routes. The scope set stays at
//! seven values — capabilities are the internal check, scopes are the
//! external user-facing label.
use async_trait::async_trait;
use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
/// Things a caller can attempt to do. Each app-scoped variant carries
/// the `AppId` of the resource the action targets — handlers compute
/// it from the loaded resource (e.g., `script.app_id`), not from a
/// path param.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Capability {
/// Create a new app. Owner / admin only.
InstanceCreateApp,
/// Create / update / delete admin_users rows (other than self
/// password change, which is a separate flow). Owner / admin.
InstanceManageUsers,
/// Mutate instance-wide configuration (sandbox ceiling, etc.).
/// Owner only.
InstanceManageSettings,
/// Read app metadata, scripts, routes. Viewer / editor / app_admin
/// (member); implicit for admin / owner.
AppRead(AppId),
/// Create / update / delete a script in this app.
AppWriteScript(AppId),
/// Create / update / delete a route in this app.
AppWriteRoute(AppId),
/// Manage domain claims on this app (add / remove).
AppManageDomains(AppId),
/// App settings + delete app. app_admin only (or owner via
/// implicit grant).
AppAdmin(AppId),
/// Read execution logs for scripts in this app.
AppLogRead(AppId),
}
impl Capability {
/// Extract the `AppId` for app-scoped capabilities; `None` for
/// instance-scoped ones. Used by the app-binding check on API keys.
#[must_use]
pub const fn app_id(self) -> Option<AppId> {
match self {
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
None
}
Self::AppRead(id)
| Self::AppWriteScript(id)
| Self::AppWriteRoute(id)
| Self::AppManageDomains(id)
| Self::AppAdmin(id)
| Self::AppLogRead(id) => Some(id),
}
}
/// The single scope that authorizes this capability on an API key.
/// Strict mapping — a `script:write` key cannot read scripts unless
/// it also carries `script:read`. The intent is predictability: a
/// key has exactly the scopes it was minted with, no implicit
/// upgrades.
#[must_use]
pub const fn required_scope(self) -> Scope {
match self {
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
Scope::InstanceAdmin
}
Self::AppRead(_) => Scope::ScriptRead,
Self::AppWriteScript(_) => Scope::ScriptWrite,
Self::AppWriteRoute(_) => Scope::RouteWrite,
Self::AppManageDomains(_) => Scope::DomainManage,
Self::AppAdmin(_) => Scope::AppAdmin,
Self::AppLogRead(_) => Scope::LogRead,
}
}
}
/// Repo seam for membership lookups. Implemented in the DB-backed
/// repos crate (`app_members_repo.rs`); keeping it as a trait here
/// means unit tests can stub it.
#[async_trait]
pub trait AuthzRepo: Send + Sync {
async fn membership(
&self,
user_id: UserId,
app_id: AppId,
) -> Result<Option<AppRole>, AuthzError>;
}
/// Repo errors surface here so handlers can map them to 500 without
/// dragging sqlx types across the boundary.
#[derive(Debug, thiserror::Error)]
pub enum AuthzError {
#[error("authorization repo error: {0}")]
Repo(String),
}
/// Decision flavor returned by `can` — distinguishes outright denial
/// from a partial answer that requires further checks (none today,
/// but the shape lets us add audit/explain mode later without rewriting
/// every caller).
#[must_use = "an authorization decision must be acted on"]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Allow,
Deny,
}
impl Decision {
#[must_use]
pub const fn is_allow(self) -> bool {
matches!(self, Self::Allow)
}
}
/// Core authorization check. Walks the three intersection layers in
/// order and returns the resulting `Decision`.
pub async fn can(
repo: &dyn AuthzRepo,
principal: &Principal,
cap: Capability,
) -> Result<Decision, AuthzError> {
if !role_grants(repo, principal, cap).await? {
return Ok(Decision::Deny);
}
if !scope_allows(principal, cap) {
return Ok(Decision::Deny);
}
if !binding_allows(principal, cap) {
return Ok(Decision::Deny);
}
Ok(Decision::Allow)
}
/// Helper: returns `Ok(())` on Allow, `Err(AuthzDenied)` on Deny.
/// Handlers call this so the `?` operator threads the 403 through
/// naturally.
///
/// # Errors
///
/// Returns `AuthzDenied::Denied` when the capability is not granted,
/// or `AuthzDenied::Repo` if the underlying membership lookup fails.
pub async fn require(
repo: &dyn AuthzRepo,
principal: &Principal,
cap: Capability,
) -> Result<(), AuthzDenied> {
match can(repo, principal, cap).await {
Ok(Decision::Allow) => Ok(()),
Ok(Decision::Deny) => Err(AuthzDenied::Denied),
Err(e) => Err(AuthzDenied::Repo(e)),
}
}
#[derive(Debug, thiserror::Error)]
pub enum AuthzDenied {
#[error("forbidden")]
Denied,
#[error(transparent)]
Repo(#[from] AuthzError),
}
// ----------------------------------------------------------------------------
// Layer 1: role-derived grant
// ----------------------------------------------------------------------------
async fn role_grants(
repo: &dyn AuthzRepo,
principal: &Principal,
cap: Capability,
) -> Result<bool, AuthzError> {
match principal.instance_role {
InstanceRole::Owner => Ok(true),
InstanceRole::Admin => Ok(admin_grants(cap)),
InstanceRole::Member => member_grants(repo, principal.user_id, cap).await,
}
}
/// Admin is implicit `app_admin` on every app (per blueprint §11.6).
/// They can create apps, manage users, and take any app-scoped action
/// on any app without an explicit `app_members` row — single-human
/// installs would otherwise need to add themselves to every new app.
/// Only `InstanceManageSettings` (sandbox ceiling, etc.) stays
/// owner-only.
const fn admin_grants(cap: Capability) -> bool {
!matches!(cap, Capability::InstanceManageSettings)
}
/// Member has zero instance authority. App authority requires an
/// explicit `app_members` row with sufficient `AppRole`.
async fn member_grants(
repo: &dyn AuthzRepo,
user_id: UserId,
cap: Capability,
) -> Result<bool, AuthzError> {
let Some(app_id) = cap.app_id() else {
return Ok(false);
};
let Some(role) = repo.membership(user_id, app_id).await? else {
return Ok(false);
};
Ok(role_satisfies(role, cap))
}
/// Does the per-app `AppRole` cover the capability? Viewer can read;
/// Editor adds script/route/log mutations; AppAdmin adds settings,
/// domain claims, and delete. Roles form a strict subset chain, so
/// the check is "is this capability in the role's set?".
const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
let in_viewer = matches!(cap, Capability::AppRead(_) | Capability::AppLogRead(_));
let in_editor = in_viewer
|| matches!(
cap,
Capability::AppWriteScript(_) | Capability::AppWriteRoute(_)
);
let in_app_admin = in_editor
|| matches!(
cap,
Capability::AppManageDomains(_) | Capability::AppAdmin(_)
);
match role {
AppRole::Viewer => in_viewer,
AppRole::Editor => in_editor,
AppRole::AppAdmin => in_app_admin,
}
}
// ----------------------------------------------------------------------------
// Layer 2: API-key scope intersection
// ----------------------------------------------------------------------------
fn scope_allows(principal: &Principal, cap: Capability) -> bool {
match &principal.scopes {
None => true, // cookie session — full role authority
Some(scopes) => scopes.contains(&cap.required_scope()),
}
}
// ----------------------------------------------------------------------------
// Layer 3: API-key app binding
// ----------------------------------------------------------------------------
fn binding_allows(principal: &Principal, cap: Capability) -> bool {
let Some(bound_app) = principal.app_binding else {
return true;
};
match cap.app_id() {
// Instance-scoped capability + bound key → always denied. The
// mint handler also rejects this combination upfront, but
// defending in depth here means a stale/malformed row can't
// escalate.
None => false,
Some(target_app) => target_app == bound_app,
}
}
// ----------------------------------------------------------------------------
// Tests
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use picloud_shared::{AdminUserId, AppId};
use std::collections::HashMap;
use tokio::sync::Mutex;
/// In-memory `AuthzRepo` so the unit tests don't need a database.
#[derive(Default)]
struct InMemoryAuthzRepo {
memberships: Mutex<HashMap<(UserId, AppId), AppRole>>,
}
impl InMemoryAuthzRepo {
async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
self.memberships.lock().await.insert((user, app), role);
}
}
#[async_trait]
impl AuthzRepo for InMemoryAuthzRepo {
async fn membership(
&self,
user_id: UserId,
app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(self
.memberships
.lock()
.await
.get(&(user_id, app_id))
.copied())
}
}
fn principal(role: InstanceRole) -> Principal {
Principal {
user_id: AdminUserId::new(),
instance_role: role,
scopes: None,
app_binding: None,
}
}
#[tokio::test]
async fn owner_can_do_everything() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Owner);
let app = AppId::new();
for cap in [
Capability::InstanceCreateApp,
Capability::InstanceManageUsers,
Capability::InstanceManageSettings,
Capability::AppRead(app),
Capability::AppWriteScript(app),
Capability::AppWriteRoute(app),
Capability::AppManageDomains(app),
Capability::AppAdmin(app),
Capability::AppLogRead(app),
] {
assert_eq!(
can(&repo, &p, cap).await.unwrap(),
Decision::Allow,
"owner denied {cap:?}"
);
}
}
#[tokio::test]
async fn admin_cannot_manage_instance_settings() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Admin);
assert_eq!(
can(&repo, &p, Capability::InstanceManageSettings)
.await
.unwrap(),
Decision::Deny,
);
}
#[tokio::test]
async fn admin_is_implicit_app_admin_on_every_app() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Admin);
let app = AppId::new();
// Instance-scoped allowances.
assert_eq!(
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
Decision::Allow,
);
assert_eq!(
can(&repo, &p, Capability::InstanceManageUsers)
.await
.unwrap(),
Decision::Allow,
);
// Editor-like + app-admin grants both succeed without any
// app_members row.
for cap in [
Capability::AppRead(app),
Capability::AppWriteScript(app),
Capability::AppWriteRoute(app),
Capability::AppLogRead(app),
Capability::AppManageDomains(app),
Capability::AppAdmin(app),
] {
assert_eq!(
can(&repo, &p, cap).await.unwrap(),
Decision::Allow,
"admin denied app-scoped capability {cap:?}"
);
}
}
#[tokio::test]
async fn member_without_row_is_denied_everywhere() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Member);
let app = AppId::new();
for cap in [
Capability::InstanceCreateApp,
Capability::InstanceManageUsers,
Capability::InstanceManageSettings,
Capability::AppRead(app),
Capability::AppWriteScript(app),
Capability::AppWriteRoute(app),
Capability::AppAdmin(app),
Capability::AppLogRead(app),
] {
assert_eq!(
can(&repo, &p, cap).await.unwrap(),
Decision::Deny,
"member granted {cap:?} without a membership row"
);
}
}
#[tokio::test]
async fn member_with_viewer_role_can_read_but_not_write() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Member);
let app = AppId::new();
repo.grant(p.user_id, app, AppRole::Viewer).await;
assert!(can(&repo, &p, Capability::AppRead(app))
.await
.unwrap()
.is_allow());
assert!(can(&repo, &p, Capability::AppLogRead(app))
.await
.unwrap()
.is_allow());
assert_eq!(
can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap(),
Decision::Deny
);
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny
);
}
#[tokio::test]
async fn member_with_editor_role_can_write_scripts_and_routes() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Member);
let app = AppId::new();
repo.grant(p.user_id, app, AppRole::Editor).await;
assert!(can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap()
.is_allow());
assert!(can(&repo, &p, Capability::AppWriteRoute(app))
.await
.unwrap()
.is_allow());
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny
);
}
/// Editors hold `AppWriteScript` (Save) but **not** `AppAdmin`
/// (Delete). The script-delete handler gates on the latter so the
/// API can't be tricked into letting an editor remove the script
/// they were only allowed to edit.
#[tokio::test]
async fn editor_can_write_scripts_but_not_delete_them() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Member);
let app = AppId::new();
repo.grant(p.user_id, app, AppRole::Editor).await;
assert!(can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap()
.is_allow());
// Delete is gated on AppAdmin in the handler — editors must be
// denied here for that gate to bite.
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny,
);
}
#[tokio::test]
async fn member_with_app_admin_role_can_do_app_admin_actions() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Member);
let app = AppId::new();
repo.grant(p.user_id, app, AppRole::AppAdmin).await;
assert!(can(&repo, &p, Capability::AppAdmin(app))
.await
.unwrap()
.is_allow());
assert!(can(&repo, &p, Capability::AppManageDomains(app))
.await
.unwrap()
.is_allow());
// Membership in App A does NOT grant access to App B
let other_app = AppId::new();
assert_eq!(
can(&repo, &p, Capability::AppAdmin(other_app))
.await
.unwrap(),
Decision::Deny
);
}
#[tokio::test]
async fn scoped_key_intersects_with_role() {
let repo = InMemoryAuthzRepo::default();
let app = AppId::new();
// Owner key with only script:read — cannot write
let p = Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: Some(vec![Scope::ScriptRead]),
app_binding: None,
};
assert!(can(&repo, &p, Capability::AppRead(app))
.await
.unwrap()
.is_allow());
assert_eq!(
can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap(),
Decision::Deny
);
// Even though the user is owner — the key's scope set is the
// hard ceiling.
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny
);
}
#[tokio::test]
async fn bound_key_cannot_escape_its_app() {
let repo = InMemoryAuthzRepo::default();
let bound_app = AppId::new();
let other_app = AppId::new();
let p = Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: Some(vec![Scope::ScriptWrite]),
app_binding: Some(bound_app),
};
assert!(can(&repo, &p, Capability::AppWriteScript(bound_app))
.await
.unwrap()
.is_allow());
assert_eq!(
can(&repo, &p, Capability::AppWriteScript(other_app))
.await
.unwrap(),
Decision::Deny
);
}
#[tokio::test]
async fn bound_key_cannot_do_instance_actions() {
let repo = InMemoryAuthzRepo::default();
let bound_app = AppId::new();
let p = Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: Some(vec![Scope::InstanceAdmin]), // mint handler also rejects this combo
app_binding: Some(bound_app),
};
assert_eq!(
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
Decision::Deny,
"bound key with instance scope must still be denied at the binding layer"
);
}
#[test]
fn capability_app_id_extraction() {
let app = AppId::new();
assert_eq!(Capability::InstanceCreateApp.app_id(), None);
assert_eq!(Capability::AppRead(app).app_id(), Some(app));
assert_eq!(Capability::AppAdmin(app).app_id(), Some(app));
}
#[test]
fn capability_required_scope_mapping_is_complete() {
// Sanity: every variant returns a scope. Compiler-enforced
// exhaustiveness lives in the match itself; this test guards
// against accidental drift to a default branch.
let app = AppId::new();
for cap in [
Capability::InstanceCreateApp,
Capability::InstanceManageUsers,
Capability::InstanceManageSettings,
Capability::AppRead(app),
Capability::AppWriteScript(app),
Capability::AppWriteRoute(app),
Capability::AppManageDomains(app),
Capability::AppAdmin(app),
Capability::AppLogRead(app),
] {
let _ = cap.required_scope(); // does not panic
}
}
}

View File

@@ -4,7 +4,23 @@
//! the same DB for now; once we add caching and per-node ingress, the //! the same DB for now; once we add caching and per-node ingress, the
//! manager will publish change events. //! manager will publish change events.
pub mod admin_session_repo;
pub mod admin_user_repo;
pub mod admin_users_api;
pub mod api; pub mod api;
pub mod api_key_repo;
pub mod api_keys_api;
pub mod app_bootstrap;
pub mod app_domain_repo;
pub mod app_members_api;
pub mod app_members_repo;
pub mod app_repo;
pub mod apps_api;
pub mod auth;
pub mod auth_api;
pub mod auth_bootstrap;
pub mod auth_middleware;
pub mod authz;
pub mod log_sink; pub mod log_sink;
pub mod migrations; pub mod migrations;
pub mod repo; pub mod repo;
@@ -13,7 +29,40 @@ pub mod route_repo;
pub mod sandbox; pub mod sandbox;
pub mod scheduler; pub mod scheduler;
pub use admin_session_repo::{
AdminSessionLookup, AdminSessionRepository, AdminSessionRepositoryError,
PostgresAdminSessionRepository,
};
pub use admin_user_repo::{
AdminUserCredentials, AdminUserRepository, AdminUserRepositoryError, AdminUserRow,
PostgresAdminUserRepository,
};
pub use admin_users_api::{admins_router, AdminsState};
pub use api::{admin_router, AdminState}; pub use api::{admin_router, AdminState};
pub use api_key_repo::{
ApiKeyRepository, ApiKeyRepositoryError, ApiKeyRow, ApiKeyVerification, NewApiKey,
PostgresApiKeyRepository,
};
pub use api_keys_api::{api_keys_router, ApiKeysState};
pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
pub use app_members_api::{app_members_router, AppMembersApiError, AppMembersState};
pub use app_members_repo::{
AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow,
PostgresAppMembersRepository,
};
pub use app_repo::{resolve_app, AppLookup, AppRepository, PostgresAppRepository};
pub use apps_api::{apps_router, AppsState};
pub use auth_api::auth_router;
pub use auth_bootstrap::{
bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError,
};
#[allow(deprecated)]
pub use auth_middleware::{
attach_principal_if_present, require_admin, require_authenticated, AuthState, AuthedAdmin,
API_KEY_PREFIX, API_KEY_PREFIX_LEN, SESSION_COOKIE,
};
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
pub use log_sink::PostgresExecutionLogSink; pub use log_sink::PostgresExecutionLogSink;
pub use repo::{ pub use repo::{
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository, ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,

View File

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

View File

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

View File

@@ -10,42 +10,56 @@ use axum::{
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::{delete, get, post}, routing::{delete, get, post},
Json, Router, Extension, Json, Router,
}; };
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable}; use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
use picloud_shared::{HostKind, PathKind, Route, ScriptId}; use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::repo::ScriptRepositoryError; use crate::app_domain_repo::AppDomainRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::repo::{ScriptRepository, ScriptRepositoryError};
use crate::route_repo::{NewRoute, RouteRepository}; use crate::route_repo::{NewRoute, RouteRepository};
pub struct RouteAdminState<RR> { pub struct RouteAdminState<RR, SR> {
pub routes: Arc<RR>, pub routes: Arc<RR>,
/// Used to resolve `script_id → app_id` when creating routes (the
/// route inherits the script's app) and to scope conflict checks.
pub scripts: Arc<SR>,
/// Used to validate the route's host against the parent app's
/// declared domain claims.
pub domains: Arc<dyn AppDomainRepository>,
pub table: Arc<RouteTable>, pub table: Arc<RouteTable>,
/// Capability gate — Phase 3.5.
pub authz: Arc<dyn AuthzRepo>,
} }
impl<RR> Clone for RouteAdminState<RR> { impl<RR, SR> Clone for RouteAdminState<RR, SR> {
fn clone(&self) -> Self { fn clone(&self) -> Self {
Self { Self {
routes: self.routes.clone(), routes: self.routes.clone(),
scripts: self.scripts.clone(),
domains: self.domains.clone(),
table: self.table.clone(), table: self.table.clone(),
authz: self.authz.clone(),
} }
} }
} }
pub fn route_admin_router<RR>(state: RouteAdminState<RR>) -> Router pub fn route_admin_router<RR, SR>(state: RouteAdminState<RR, SR>) -> Router
where where
RR: RouteRepository + 'static, RR: RouteRepository + 'static,
SR: ScriptRepository + 'static,
{ {
Router::new() Router::new()
.route( .route(
"/scripts/{id}/routes", "/scripts/{id}/routes",
get(list_routes::<RR>).post(create_route::<RR>), get(list_routes::<RR, SR>).post(create_route::<RR, SR>),
) )
.route("/routes/{route_id}", delete(delete_route::<RR>)) .route("/routes/{route_id}", delete(delete_route::<RR, SR>))
.route("/routes:check", post(check_route::<RR>)) .route("/routes:check", post(check_route::<RR, SR>))
.route("/routes:match", post(match_route::<RR>)) .route("/routes:match", post(match_route::<RR, SR>))
.with_state(state) .with_state(state)
} }
@@ -67,6 +81,10 @@ pub struct CreateRouteRequest {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CheckRouteRequest { pub struct CheckRouteRequest {
/// Required: which app's route table this hypothetical route would
/// join. Conflict checks are strictly intra-app (cross-app route
/// errors would leak tenant info — see blueprint §11.5).
pub app_id: AppId,
pub host_kind: HostKind, pub host_kind: HostKind,
#[serde(default)] #[serde(default)]
pub host: String, pub host: String,
@@ -84,6 +102,9 @@ pub struct CheckRouteResponse {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct MatchRouteRequest { pub struct MatchRouteRequest {
/// Which app's route table to dispatch against. The dashboard's
/// route-preview tester always knows the current app context.
pub app_id: AppId,
pub url: String, pub url: String,
#[serde(default = "default_method")] #[serde(default = "default_method")]
pub method: String, pub method: String,
@@ -111,15 +132,28 @@ pub struct MatchedRoute {
// Handlers // Handlers
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
async fn list_routes<RR: RouteRepository>( async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR>>, State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Path(script_id): Path<ScriptId>, Path(script_id): Path<ScriptId>,
) -> Result<Json<Vec<Route>>, RouteApiError> { ) -> Result<Json<Vec<Route>>, RouteApiError> {
let script = state
.scripts
.get(script_id)
.await?
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
require(
state.authz.as_ref(),
&principal,
Capability::AppRead(script.app_id),
)
.await?;
Ok(Json(state.routes.list_for_script(script_id).await?)) Ok(Json(state.routes.list_for_script(script_id).await?))
} }
async fn create_route<RR: RouteRepository>( async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR>>, State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Path(script_id): Path<ScriptId>, Path(script_id): Path<ScriptId>,
Json(input): Json<CreateRouteRequest>, Json(input): Json<CreateRouteRequest>,
) -> Result<(StatusCode, Json<Route>), RouteApiError> { ) -> Result<(StatusCode, Json<Route>), RouteApiError> {
@@ -130,8 +164,28 @@ async fn create_route<RR: RouteRepository>(
input.host_param_name.as_deref(), input.host_param_name.as_deref(),
)?; )?;
// Within-kind conflict check against existing routes. // Look up the script's owning app — every route inherits it.
let existing = state.routes.list_all().await?; let script = state
.scripts
.get(script_id)
.await?
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
let app_id = script.app_id;
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteRoute(app_id),
)
.await?;
// Validate the route's host is consistent with one of the app's
// domain claims. `HostKind::Any` is always permitted (catches every
// host the app already owns). Specific hosts must match a claim.
validate_route_host_against_app(state.domains.as_ref(), app_id, input.host_kind, &input.host)
.await?;
// Within-app conflict check (cross-app is impossible by construction).
let existing = state.routes.list_for_app(app_id).await?;
if let Some((conflicting, reason)) = first_conflict( if let Some((conflicting, reason)) = first_conflict(
&existing, &existing,
input.host_kind, input.host_kind,
@@ -149,6 +203,7 @@ async fn create_route<RR: RouteRepository>(
let created = state let created = state
.routes .routes
.create(NewRoute { .create(NewRoute {
app_id,
script_id, script_id,
host_kind: input.host_kind, host_kind: input.host_kind,
host: input.host, host: input.host,
@@ -162,23 +217,47 @@ async fn create_route<RR: RouteRepository>(
Ok((StatusCode::CREATED, Json(created))) Ok((StatusCode::CREATED, Json(created)))
} }
async fn delete_route<RR: RouteRepository>( async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR>>, State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Path(route_id): Path<Uuid>, Path(route_id): Path<Uuid>,
) -> Result<StatusCode, RouteApiError> { ) -> Result<StatusCode, RouteApiError> {
// Resolve the route's app before we delete, so the capability
// binds to the actual route's app_id (not a path param).
let route = state
.routes
.get(route_id)
.await?
.ok_or(RouteApiError::RouteNotFound(route_id))?;
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteRoute(route.app_id),
)
.await?;
state.routes.delete(route_id).await?; state.routes.delete(route_id).await?;
refresh_table(&state).await?; refresh_table(&state).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
async fn check_route<RR: RouteRepository>( async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR>>, State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Json(input): Json<CheckRouteRequest>, Json(input): Json<CheckRouteRequest>,
) -> Result<Json<CheckRouteResponse>, RouteApiError> { ) -> Result<Json<CheckRouteResponse>, RouteApiError> {
// routes:check is read-only — peeking at a hypothetical conflict
// is bounded by AppRead on the target app (otherwise members
// could probe other apps).
require(
state.authz.as_ref(),
&principal,
Capability::AppRead(input.app_id),
)
.await?;
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?; let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
pattern::parse_host(input.host_kind, &input.host, None)?; pattern::parse_host(input.host_kind, &input.host, None)?;
let existing = state.routes.list_all().await?; let existing = state.routes.list_for_app(input.app_id).await?;
let conflict = first_conflict( let conflict = first_conflict(
&existing, &existing,
input.host_kind, input.host_kind,
@@ -201,16 +280,25 @@ async fn check_route<RR: RouteRepository>(
})) }))
} }
async fn match_route<RR: RouteRepository>( async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR>>, State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Json(input): Json<MatchRouteRequest>, Json(input): Json<MatchRouteRequest>,
) -> Result<Json<MatchRouteResponse>, RouteApiError> { ) -> Result<Json<MatchRouteResponse>, RouteApiError> {
require(
state.authz.as_ref(),
&principal,
Capability::AppRead(input.app_id),
)
.await?;
let parsed = url::Url::parse(&input.url) let parsed = url::Url::parse(&input.url)
.map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?; .map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?;
let host = parsed.host_str().unwrap_or("").to_string(); let host = parsed.host_str().unwrap_or("").to_string();
let path = parsed.path().to_string(); let path = parsed.path().to_string();
let result = state.table.match_request(&host, &input.method, &path); let result = state
.table
.match_request_for_app(input.app_id, &host, &input.method, &path);
Ok(Json(MatchRouteResponse { Ok(Json(MatchRouteResponse {
matched: result.map(|r| MatchedRoute { matched: result.map(|r| MatchedRoute {
route_id: r.matched.route_id, route_id: r.matched.route_id,
@@ -263,12 +351,12 @@ fn first_conflict(
Ok(None) Ok(None)
} }
async fn refresh_table<RR: RouteRepository>( async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
state: &RouteAdminState<RR>, state: &RouteAdminState<RR, SR>,
) -> Result<(), RouteApiError> { ) -> Result<(), RouteApiError> {
let rows = state.routes.list_all().await?; let rows = state.routes.list_all().await?;
let compiled = compile_routes(&rows)?; let compiled = compile_routes(&rows)?;
state.table.replace(compiled); state.table.replace_all(compiled);
Ok(()) Ok(())
} }
@@ -277,6 +365,7 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
.map(|r| { .map(|r| {
Ok(CompiledRoute { Ok(CompiledRoute {
route_id: r.id, route_id: r.id,
app_id: r.app_id,
script_id: r.script_id, script_id: r.script_id,
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?, host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
path: pattern::parse_path(r.path_kind, &r.path)?, path: pattern::parse_path(r.path_kind, &r.path)?,
@@ -286,6 +375,79 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
.collect() .collect()
} }
/// Validate that a new route's (host_kind, host) is consistent with at
/// least one of the parent app's domain claims. `HostKind::Any` is
/// always permitted — it catches every host the app already owns.
async fn validate_route_host_against_app(
domains: &dyn AppDomainRepository,
app_id: AppId,
host_kind: HostKind,
host: &str,
) -> Result<(), RouteApiError> {
if matches!(host_kind, HostKind::Any) {
return Ok(());
}
let claims = domains.list_for_app(app_id).await?;
if claims.is_empty() {
return Err(RouteApiError::HostNotClaimed {
host: host.to_string(),
available_claims: vec![],
});
}
let host_lower = host.to_ascii_lowercase();
for claim in &claims {
let claim_lower = claim.pattern.to_ascii_lowercase();
match (host_kind, claim.shape) {
// Strict route under exact claim: must match exactly.
(HostKind::Strict, picloud_shared::DomainShape::Exact) => {
if host_lower == claim_lower {
return Ok(());
}
}
// Strict route under wildcard/parameterized: must end with
// ".<suffix>" where the claim's suffix is the part after
// `*.` or `{...}.`.
(
HostKind::Strict,
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
) => {
let suffix = claim_lower
.split_once('.')
.map(|(_, s)| s.to_string())
.unwrap_or_default();
let needle = format!(".{suffix}");
if !suffix.is_empty() && host_lower.ends_with(&needle) {
return Ok(());
}
}
// Wildcard route: must match a wildcard or parameterized
// claim with identical suffix.
(
HostKind::Wildcard,
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
) => {
let claim_suffix = claim_lower
.split_once('.')
.map(|(_, s)| s.to_string())
.unwrap_or_default();
if claim_suffix == host_lower {
return Ok(());
}
}
// Wildcard route under exact claim: not allowed (would
// shadow other apps' subdomains the operator didn't claim).
(HostKind::Wildcard, picloud_shared::DomainShape::Exact) => {}
(HostKind::Any, _) => unreachable!("handled above"),
}
}
Err(RouteApiError::HostNotClaimed {
host: host.to_string(),
available_claims: claims.into_iter().map(|c| c.pattern).collect(),
})
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Errors // Errors
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -304,10 +466,37 @@ pub enum RouteApiError {
#[error("bad request: {0}")] #[error("bad request: {0}")]
BadRequest(String), BadRequest(String),
#[error("script not found: {0}")]
ScriptNotFound(ScriptId),
#[error("route not found: {0}")]
RouteNotFound(Uuid),
#[error("host {host:?} is not claimed by this app")]
HostNotClaimed {
host: String,
available_claims: Vec<String>,
},
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("repository error: {0}")] #[error("repository error: {0}")]
Repo(#[from] ScriptRepositoryError), Repo(#[from] ScriptRepositoryError),
} }
impl From<AuthzDenied> for RouteApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl IntoResponse for RouteApiError { impl IntoResponse for RouteApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, body) = match &self { let (status, body) = match &self {
@@ -326,10 +515,34 @@ impl IntoResponse for RouteApiError {
StatusCode::UNPROCESSABLE_ENTITY, StatusCode::UNPROCESSABLE_ENTITY,
serde_json::json!({ "error": self.to_string() }), serde_json::json!({ "error": self.to_string() }),
), ),
Self::Repo(ScriptRepositoryError::NotFound(_)) => ( Self::ScriptNotFound(_)
| Self::RouteNotFound(_)
| Self::Repo(ScriptRepositoryError::NotFound(_)) => (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
serde_json::json!({ "error": self.to_string() }), serde_json::json!({ "error": self.to_string() }),
), ),
Self::Forbidden => (
StatusCode::FORBIDDEN,
serde_json::json!({ "error": self.to_string() }),
),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "route authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
serde_json::json!({ "error": "internal error" }),
)
}
Self::HostNotClaimed {
host,
available_claims,
} => (
StatusCode::UNPROCESSABLE_ENTITY,
serde_json::json!({
"error": self.to_string(),
"host": host,
"available_claims": available_claims,
}),
),
Self::Repo(ScriptRepositoryError::Conflict(_)) => ( Self::Repo(ScriptRepositoryError::Conflict(_)) => (
StatusCode::CONFLICT, StatusCode::CONFLICT,
serde_json::json!({ "error": self.to_string() }), serde_json::json!({ "error": self.to_string() }),

View File

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

View File

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

View File

@@ -12,27 +12,32 @@ use axum::{
http::{HeaderMap, HeaderName, HeaderValue, StatusCode}, http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::post, routing::post,
Json, Router, Extension, Json, Router,
}; };
use chrono::Utc; use chrono::Utc;
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType}; use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
use picloud_shared::{ use picloud_shared::{
ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId, AppId, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, Principal, RequestId,
ScriptId,
}; };
use serde_json::Value as Json_; use serde_json::Value as Json_;
use uuid::Uuid; use uuid::Uuid;
use crate::client::ExecutorClient; use crate::client::ExecutorClient;
use crate::resolver::{ResolverError, ScriptResolver}; use crate::resolver::{ResolverError, ScriptResolver};
use crate::routing::RouteTable; use crate::routing::{AppDomainTable, RouteTable};
/// State shared by data-plane handlers. /// State shared by data-plane handlers.
pub struct DataPlaneState<E, R> { pub struct DataPlaneState<E, R> {
pub executor: Arc<E>, pub executor: Arc<E>,
pub resolver: Arc<R>, pub resolver: Arc<R>,
pub log_sink: Arc<dyn ExecutionLogSink>, pub log_sink: Arc<dyn ExecutionLogSink>,
/// Routing table for user-defined paths. Shared with the manager /// Host → app_id resolver. Run before `routes` to filter to the
/// (admin router writes; this side reads). /// owning app's slice. Shared with the manager (writes invalidate
/// the cache by replacing the table).
pub app_domains: Arc<AppDomainTable>,
/// Routing table for user-defined paths, partitioned per app.
/// Shared with the manager (admin router writes; this side reads).
pub routes: Arc<RouteTable>, pub routes: Arc<RouteTable>,
} }
@@ -42,6 +47,7 @@ impl<E, R> Clone for DataPlaneState<E, R> {
executor: self.executor.clone(), executor: self.executor.clone(),
resolver: self.resolver.clone(), resolver: self.resolver.clone(),
log_sink: self.log_sink.clone(), log_sink: self.log_sink.clone(),
app_domains: self.app_domains.clone(),
routes: self.routes.clone(), routes: self.routes.clone(),
} }
} }
@@ -49,6 +55,11 @@ impl<E, R> Clone for DataPlaneState<E, R> {
/// Build the data-plane router. Handles `POST /execute/:id` — the /// Build the data-plane router. Handles `POST /execute/:id` — the
/// always-available ID-based bypass. /// always-available ID-based bypass.
///
/// Handlers expect an `Extension<Option<Principal>>` to be attached by
/// upstream middleware (`manager-core::attach_principal_if_present`);
/// requests without that extension panic at extraction time. The
/// picloud binary wires this in `build_app`.
pub fn data_plane_router<E, R>(state: DataPlaneState<E, R>) -> Router pub fn data_plane_router<E, R>(state: DataPlaneState<E, R>) -> Router
where where
E: ExecutorClient + 'static, E: ExecutorClient + 'static,
@@ -62,6 +73,10 @@ where
/// Build a router that handles ALL paths via the user-defined routing /// Build a router that handles ALL paths via the user-defined routing
/// table. Intended to be merged into the picloud app router as a /// table. Intended to be merged into the picloud app router as a
/// fallback (after the system routes are mounted). /// fallback (after the system routes are mounted).
///
/// Same middleware expectation as `data_plane_router` — wrap with
/// `attach_principal_if_present` so handlers can extract
/// `Extension<Option<Principal>>`.
pub fn user_routes_router<E, R>(state: DataPlaneState<E, R>) -> Router pub fn user_routes_router<E, R>(state: DataPlaneState<E, R>) -> Router
where where
E: ExecutorClient + 'static, E: ExecutorClient + 'static,
@@ -79,6 +94,7 @@ where
async fn execute_by_id<E, R>( async fn execute_by_id<E, R>(
State(state): State<DataPlaneState<E, R>>, State(state): State<DataPlaneState<E, R>>,
Path(id): Path<ScriptId>, Path(id): Path<ScriptId>,
Extension(principal): Extension<Option<Principal>>,
headers: HeaderMap, headers: HeaderMap,
body: Bytes, body: Bytes,
) -> Result<Response, ApiError> ) -> Result<Response, ApiError>
@@ -92,7 +108,7 @@ where
.await? .await?
.ok_or(ApiError::NotFound(id))?; .ok_or(ApiError::NotFound(id))?;
let mut req = build_exec_request(id, &script.name, &headers, &body)?; let mut req = build_exec_request(id, &script.name, &headers, &body, script.app_id, principal)?;
req.sandbox_overrides = script.sandbox; req.sandbox_overrides = script.sandbox;
let request_id = req.request_id; let request_id = req.request_id;
let request_path = req.path.clone(); let request_path = req.path.clone();
@@ -109,6 +125,7 @@ where
// audit-visible platform — but a sink failure must not mask the // audit-visible platform — but a sink failure must not mask the
// user-facing result, so we only log a warning if it fails. // user-facing result, so we only log a warning if it fails.
let log = build_execution_log( let log = build_execution_log(
script.app_id,
id, id,
request_id, request_id,
request_path, request_path,
@@ -127,6 +144,7 @@ where
async fn user_route_handler<E, R>( async fn user_route_handler<E, R>(
State(state): State<DataPlaneState<E, R>>, State(state): State<DataPlaneState<E, R>>,
Extension(principal): Extension<Option<Principal>>,
request: Request, request: Request,
) -> Result<Response, ApiError> ) -> Result<Response, ApiError>
where where
@@ -145,7 +163,23 @@ where
.to_string(); .to_string();
let headers = request.headers().clone(); let headers = request.headers().clone();
let Some(matched) = state.routes.match_request(&host, &method, &path) else { // Two-phase dispatch (blueprint §11.5): first resolve Host → app_id,
// then run the existing matcher on that app's slice. No app claims
// this host → flat 404; the path doesn't get the chance to fire.
let Some(app_id) = state.app_domains.resolve_app(&host) else {
return Ok((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": format!("no app claims host {host:?}")
})),
)
.into_response());
};
let Some(matched) = state
.routes
.match_request_for_app(app_id, &host, &method, &path)
else {
return Ok(( return Ok((
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
Json(serde_json::json!({ Json(serde_json::json!({
@@ -173,6 +207,8 @@ where
&script.name, &script.name,
&headers, &headers,
&body_bytes, &body_bytes,
app_id,
principal,
)?; )?;
req.path = path; req.path = path;
req.params = matched.params; req.params = matched.params;
@@ -191,6 +227,7 @@ where
let finished = Utc::now(); let finished = Utc::now();
let log = build_execution_log( let log = build_execution_log(
script.app_id,
matched.matched.script_id, matched.matched.script_id,
request_id, request_id,
request_path, request_path,
@@ -241,6 +278,8 @@ fn build_exec_request(
name: &str, name: &str,
headers: &HeaderMap, headers: &HeaderMap,
body: &Bytes, body: &Bytes,
app_id: AppId,
principal: Option<Principal>,
) -> Result<ExecRequest, ApiError> { ) -> Result<ExecRequest, ApiError> {
let mut hmap = BTreeMap::new(); let mut hmap = BTreeMap::new();
for (k, v) in headers { for (k, v) in headers {
@@ -256,8 +295,9 @@ fn build_exec_request(
.map_err(|e| ApiError::BadRequest(format!("invalid JSON body: {e}")))? .map_err(|e| ApiError::BadRequest(format!("invalid JSON body: {e}")))?
}; };
let execution_id = ExecutionId::new();
Ok(ExecRequest { Ok(ExecRequest {
execution_id: ExecutionId::new(), execution_id,
request_id: RequestId::new(), request_id: RequestId::new(),
script_id: id, script_id: id,
script_name: name.to_string(), script_name: name.to_string(),
@@ -270,6 +310,13 @@ fn build_exec_request(
rest: String::new(), rest: String::new(),
// Overwritten by the handler after the script is resolved. // Overwritten by the handler after the script is resolved.
sandbox_overrides: picloud_shared::ScriptSandbox::default(), sandbox_overrides: picloud_shared::ScriptSandbox::default(),
app_id,
principal,
// Direct invocations are at depth 0 with a self-referential
// root. The triggers framework (v1.1.1) increments depth and
// preserves the original root for chained executions.
trigger_depth: 0,
root_execution_id: execution_id,
}) })
} }
@@ -292,6 +339,7 @@ fn exec_response_to_http(resp: ExecResponse) -> Response {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn build_execution_log( fn build_execution_log(
app_id: AppId,
script_id: ScriptId, script_id: ScriptId,
request_id: RequestId, request_id: RequestId,
request_path: String, request_path: String,
@@ -336,6 +384,7 @@ fn build_execution_log(
ExecutionLog { ExecutionLog {
id: Uuid::new_v4(), id: Uuid::new_v4(),
app_id,
script_id, script_id,
request_id, request_id,
request_path, request_path,
@@ -371,7 +420,22 @@ pub enum ApiError {
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
// Overloaded is the only variant that needs to attach an HTTP
// header (Retry-After), so it short-circuits the (status, body)
// reduction below. Axum's tuple builder makes per-arm header
// injection awkward otherwise.
use ApiError as E; use ApiError as E;
if let E::Exec(ExecError::Overloaded { retry_after_secs }) = &self {
let retry = retry_after_secs.to_string();
let body = Json(serde_json::json!({ "error": self.to_string() }));
return (
StatusCode::SERVICE_UNAVAILABLE,
[(axum::http::header::RETRY_AFTER, retry)],
body,
)
.into_response();
}
let (status, message) = match &self { let (status, message) = match &self {
E::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), E::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
E::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()), E::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
@@ -391,6 +455,7 @@ impl IntoResponse for ApiError {
(StatusCode::INSUFFICIENT_STORAGE, e.to_string()) (StatusCode::INSUFFICIENT_STORAGE, e.to_string())
} }
ExecError::Runtime(_) => (StatusCode::BAD_GATEWAY, e.to_string()), ExecError::Runtime(_) => (StatusCode::BAD_GATEWAY, e.to_string()),
ExecError::Overloaded { .. } => unreachable!("handled above"),
}, },
}; };
(status, Json(serde_json::json!({ "error": message }))).into_response() (status, Json(serde_json::json!({ "error": message }))).into_response()

View File

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

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 api;
pub mod client; pub mod client;
pub mod gate;
pub mod resolver; pub mod resolver;
pub mod routing; pub mod routing;
pub use api::{data_plane_router, user_routes_router, DataPlaneState}; pub use api::{data_plane_router, user_routes_router, DataPlaneState};
pub use client::{ExecutorClient, LocalExecutorClient, RemoteExecutorClient}; pub use client::{ExecutorClient, LocalExecutorClient, RemoteExecutorClient};
pub use gate::{AcquireError, ExecutionGate};
pub use resolver::{ResolverError, ScriptResolver}; pub use resolver::{ResolverError, ScriptResolver};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

@@ -39,3 +39,5 @@ figment.workspace = true
axum-test = "17" axum-test = "17"
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
uuid.workspace = true
chrono.workspace = true

View File

@@ -6,24 +6,66 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use axum::middleware::from_fn_with_state;
use axum::{routing::get, Json, Router}; use axum::{routing::get, Json, Router};
use picloud_executor_core::{Engine, Limits}; use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{ use picloud_manager_core::{
admin_router, compile_routes, migrations, route_admin_router, AdminState, admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository, attach_principal_if_present, auth_router, compile_routes, migrations, require_authenticated,
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling, route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState,
AppRepository, AppsState, AuthState, AuthzRepo, PostgresAdminSessionRepository,
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
PostgresAppMembersRepository, PostgresAppRepository, PostgresExecutionLogRepository,
PostgresExecutionLogSink, PostgresRouteRepository, PostgresScriptRepository, RepoResolver,
RouteAdminState, RouteRepository, SandboxCeiling,
}; };
use picloud_orchestrator_core::routing::RouteTable; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
use picloud_orchestrator_core::{ use picloud_orchestrator_core::{
data_plane_router, user_routes_router, DataPlaneState, LocalExecutorClient, data_plane_router, user_routes_router, DataPlaneState, ExecutionGate, LocalExecutorClient,
}; };
use picloud_shared::{ use picloud_shared::{
ExecutionLogSink, ScriptValidator, API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION, ExecutionLogSink, ScriptValidator, Services, API_VERSION, PRODUCT_VERSION, SDK_VERSION,
WIRE_VERSION,
}; };
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool; use sqlx::PgPool;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
/// Default session TTL when `PICLOUD_SESSION_TTL_HOURS` isn't set.
const DEFAULT_SESSION_TTL_HOURS: u64 = 24;
/// Bundles the auth-related dependencies that both `build_app` and the
/// startup bootstrap need. Built once in `main.rs` from the shared pool.
pub struct AuthDeps {
pub users: Arc<dyn AdminUserRepository>,
pub sessions: Arc<dyn AdminSessionRepository>,
pub keys: Arc<dyn ApiKeyRepository>,
pub ttl: Duration,
}
impl AuthDeps {
/// Construct from a pool with the binary's standard defaults.
#[must_use]
pub fn from_pool(pool: PgPool) -> Self {
Self {
users: Arc::new(PostgresAdminUserRepository::new(pool.clone())),
sessions: Arc::new(PostgresAdminSessionRepository::new(pool.clone())),
keys: Arc::new(PostgresApiKeyRepository::new(pool)),
ttl: read_session_ttl(),
}
}
}
fn read_session_ttl() -> Duration {
let hours = std::env::var("PICLOUD_SESSION_TTL_HOURS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.filter(|h| *h > 0)
.unwrap_or(DEFAULT_SESSION_TTL_HOURS);
Duration::from_secs(hours * 3600)
}
/// Compose the manager + orchestrator routes on top of a shared /// Compose the manager + orchestrator routes on top of a shared
/// Postgres pool, returning an Axum router ready to be served. /// Postgres pool, returning an Axum router ready to be served.
/// ///
@@ -31,53 +73,164 @@ use tower_http::trace::TraceLayer;
/// is mounted by Caddy at `/admin/*` (its base path). Anything else /// is mounted by Caddy at `/admin/*` (its base path). Anything else
/// falls through to the user-route table — user scripts can bind to /// falls through to the user-route table — user scripts can bind to
/// arbitrary paths (subject to the reserved-prefix list). /// arbitrary paths (subject to the reserved-prefix list).
pub async fn build_app(pool: PgPool) -> anyhow::Result<Router> { ///
let engine = Arc::new(Engine::new(Limits::default())); /// `auth` carries the admin user/session repositories and the
/// configured session TTL. The manager-side admin endpoints
/// (`/api/v1/admin/scripts/*`, `/api/v1/admin/routes/*`,
/// `/api/v1/admin/admins/*`, `/api/v1/admin/auth/me`) are guarded by
/// the `require_admin` middleware. The data plane
/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`,
/// `/version`) stays open — it's the public ingress for user scripts.
#[allow(clippy::too_many_lines)]
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
// `Services` is the SDK service bundle. Empty in v1.1.0; the
// v1.1.1 KV PR will populate it with `kv: Arc::new(...)` here.
let engine = Arc::new(Engine::new(Limits::default(), Services::new()));
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone())); let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone())); let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone())); let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
let route_repo = Arc::new(PostgresRouteRepository::new(pool)); let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
let domains_repo: Arc<dyn AppDomainRepository> =
Arc::new(PostgresAppDomainRepository::new(pool.clone()));
// The Postgres app_members repo implements both `AppMembersRepository`
// (CRUD over the table) and `AuthzRepo` (single-row membership lookup
// for capability checks). Construct it once and clone the Arc into
// both trait views — same allocation, two vtables.
let members_concrete = Arc::new(PostgresAppMembersRepository::new(pool));
let members: Arc<dyn AppMembersRepository> = members_concrete.clone();
let authz: Arc<dyn AuthzRepo> = members_concrete;
// Compile the routes table once at startup; admin writes refresh it. // Compile the routes table once at startup; admin writes refresh it.
let route_table = Arc::new(RouteTable::new()); let route_table = Arc::new(RouteTable::new());
let initial = route_repo.list_all().await?; let initial = route_repo.list_all().await?;
let compiled = compile_routes(&initial) let compiled = compile_routes(&initial)
.map_err(|e| anyhow::anyhow!("failed to compile stored routes: {e}"))?; .map_err(|e| anyhow::anyhow!("failed to compile stored routes: {e}"))?;
route_table.replace(compiled); route_table.replace_all(compiled);
// Same shape for app domains (Host → app_id cache).
let app_domain_table = Arc::new(AppDomainTable::new());
let initial_domains = domains_repo.list_all().await?;
let compiled_domains: Vec<_> = initial_domains
.iter()
.filter_map(|d| {
picloud_orchestrator_core::routing::parse_app_domain(&d.pattern)
.ok()
.map(|p| picloud_orchestrator_core::routing::CompiledAppDomain {
app_id: d.app_id,
pattern: p.pattern,
shape_key: p.shape_key,
})
})
.collect();
app_domain_table.replace(compiled_domains);
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle( let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
script_repo.clone(), script_repo.clone(),
))); )));
let executor = Arc::new(LocalExecutorClient::new(engine.clone())); // Single global gate — overflow is rejected with 503 + Retry-After.
// See `ExecutionGate` docs and `PICLOUD_MAX_CONCURRENT_EXECUTIONS`.
let gate = Arc::new(ExecutionGate::from_env());
let executor = Arc::new(LocalExecutorClient::new(engine.clone(), gate));
let admin = AdminState { let admin = AdminState {
repo: Arc::new(PostgresScriptRepoHandle(script_repo)), repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
logs: log_repo, logs: log_repo,
apps: apps_repo.clone(),
authz: authz.clone(),
validator: engine as Arc<dyn ScriptValidator>, validator: engine as Arc<dyn ScriptValidator>,
sandbox_ceiling: SandboxCeiling::from_env(), sandbox_ceiling: SandboxCeiling::from_env(),
}; };
let route_admin = RouteAdminState { let route_admin = RouteAdminState {
routes: route_repo, routes: route_repo.clone(),
scripts: Arc::new(PostgresScriptRepoHandle(script_repo)),
domains: domains_repo.clone(),
table: route_table.clone(), table: route_table.clone(),
authz: authz.clone(),
}; };
let data_plane = DataPlaneState { let data_plane = DataPlaneState {
executor, executor,
resolver, resolver,
log_sink, log_sink,
app_domains: app_domain_table.clone(),
routes: route_table, routes: route_table,
}; };
let apps_state = AppsState {
apps: apps_repo,
domains: domains_repo,
routes: route_repo,
domain_table: app_domain_table,
authz: authz.clone(),
};
let auth_state = AuthState {
users: auth.users.clone(),
sessions: auth.sessions.clone(),
keys: auth.keys.clone(),
ttl: auth.ttl,
};
let admins_state = AdminsState {
users: auth.users.clone(),
sessions: auth.sessions,
keys: auth.keys.clone(),
authz: authz.clone(),
};
let app_members_state = AppMembersState {
apps: apps_state.apps.clone(),
users: auth.users,
members,
authz,
};
let api_keys_state = ApiKeysState { keys: auth.keys };
// /admin/auth/login + /logout are unguarded by design (login is how
// you get in). /admin/auth/me applies the middleware internally so
// the same Router::with_state machinery composes cleanly. Everything
// else under /admin gets the require_authenticated layer; capability
// checks live in each handler (after the resource is loaded so the
// capability binds to the resource's actual app_id).
let guarded_admin = Router::new()
.merge(admin_router(admin))
.merge(route_admin_router(route_admin))
.merge(admins_router(admins_state))
.merge(apps_router(apps_state))
.merge(app_members_router(app_members_state))
.merge(api_keys_router(api_keys_state))
.layer(from_fn_with_state(
auth_state.clone(),
require_authenticated,
));
// Silence "unused import" lint on `apps_api` — we re-export via the
// facade above; the bare module path is retained so it's discoverable.
let _ = apps_api::AppsState::clone;
// Opportunistic principal extraction on every data-plane request.
// Always inserts `Extension<Option<Principal>>`: Some for authed
// ingress (bearer / cookie), None otherwise. Handlers depend on
// this layer being applied — scoped to the data-plane routers so
// the admin path (which uses `require_authenticated`) doesn't
// double-resolve the same token.
let data_plane_routed = data_plane_router(data_plane.clone()).layer(from_fn_with_state(
auth_state.clone(),
attach_principal_if_present,
));
let user_routes = user_routes_router(data_plane).layer(from_fn_with_state(
auth_state.clone(),
attach_principal_if_present,
));
let api_v1 = Router::new() let api_v1 = Router::new()
.nest("/admin", admin_router(admin)) .nest("/admin", auth_router(auth_state))
.nest("/admin", route_admin_router(route_admin)) .nest("/admin", guarded_admin)
.merge(data_plane_router(data_plane.clone())); .merge(data_plane_routed);
Ok(Router::new() Ok(Router::new()
.route("/healthz", get(healthz)) .route("/healthz", get(healthz))
.route("/version", get(version)) .route("/version", get(version))
.nest(&format!("/api/v{API_VERSION}"), api_v1) .nest(&format!("/api/v{API_VERSION}"), api_v1)
.merge(user_routes_router(data_plane)) .merge(user_routes)
.layer(TraceLayer::new_for_http())) .layer(TraceLayer::new_for_http()))
} }
@@ -138,6 +291,18 @@ impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> { ) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
self.0.list().await self.0.list().await
} }
async fn list_for_app(
&self,
app_id: picloud_shared::AppId,
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
self.0.list_for_app(app_id).await
}
async fn list_for_user(
&self,
user_id: picloud_shared::AdminUserId,
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
self.0.list_for_user(user_id).await
}
async fn create( async fn create(
&self, &self,
input: picloud_manager_core::NewScript, input: picloud_manager_core::NewScript,

View File

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

View File

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

File diff suppressed because it is too large Load Diff

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

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

242
crates/shared/src/auth.rs Normal file
View File

@@ -0,0 +1,242 @@
//! Cross-crate authn/authz types — Phase 3.5, see blueprint §11.6.
//!
//! The `Principal` extracted by `manager-core::auth_middleware` lives
//! here so handlers in every crate (and, later, the v1.1 SDKs in
//! `executor-core`) can refer to the same shape without pulling in the
//! manager crate. The authorization rules themselves live in
//! `manager-core::authz` — this module is data only.
//!
//! `UserId` is a transitional alias for `AdminUserId`. Phase 3a named
//! the table `admin_users` to leave room for the v1.1 script-level
//! `users` SDK feature (see blueprint §11.4 "Naming"); from the
//! authorization layer's perspective an admin row is the principal
//! identity, so we expose the alias rather than renaming the existing
//! id type.
use serde::{Deserialize, Serialize};
use crate::{AdminUserId, AppId};
/// Transitional alias — see module docs.
pub type UserId = AdminUserId;
/// Instance-wide role carried by every `admin_users` row. The DB
/// representation is `text` (`'owner'|'admin'|'member'`), checked via
/// a CHECK constraint in migration `0006_users_authz.sql`; this enum
/// is the Rust mirror.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstanceRole {
/// Full instance control, manage other owners, implicit `app_admin`
/// on every app. Multiple allowed.
Owner,
/// Create apps, invite users, implicit `editor` on every app. No
/// instance-settings authority and no owner-management.
Admin,
/// Invited into specific apps via `app_members` only. No app
/// creation, no invite authority. List endpoints filter strictly
/// by membership at SQL.
Member,
}
impl InstanceRole {
/// Stable string form — matches the DB CHECK constraint values
/// exactly. Used by repos and the seed/audit paths.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Owner => "owner",
Self::Admin => "admin",
Self::Member => "member",
}
}
/// Inverse of `as_str` — used when reading a row out of Postgres.
/// Returns `None` for unknown values so the caller can decide
/// between failing loudly or skipping a bad row.
#[must_use]
pub fn from_db_str(s: &str) -> Option<Self> {
match s {
"owner" => Some(Self::Owner),
"admin" => Some(Self::Admin),
"member" => Some(Self::Member),
_ => None,
}
}
}
/// Per-app role recorded in `app_members`. Members hold zero-or-one row
/// per (user, app); owners and admins are not represented in the table
/// (their app authority is implicit via `InstanceRole`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AppRole {
/// App settings, domain claims, delete.
AppAdmin,
/// CRUD on scripts, routes, sandbox config.
Editor,
/// Read scripts + execution logs.
Viewer,
}
impl AppRole {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::AppAdmin => "app_admin",
Self::Editor => "editor",
Self::Viewer => "viewer",
}
}
#[must_use]
pub fn from_db_str(s: &str) -> Option<Self> {
match s {
"app_admin" => Some(Self::AppAdmin),
"editor" => Some(Self::Editor),
"viewer" => Some(Self::Viewer),
_ => None,
}
}
}
/// API-key scope. Exactly seven values; new scopes need a blueprint
/// edit before they're added here. Wire form is the colon-separated
/// string (`"script:read"`, etc.) — matches the `text[]` stored in
/// `api_keys.scopes` and the strings shown to operators.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Scope {
ScriptRead,
ScriptWrite,
RouteWrite,
DomainManage,
LogRead,
AppAdmin,
InstanceAdmin,
}
impl Scope {
pub const ALL: &'static [Scope] = &[
Scope::ScriptRead,
Scope::ScriptWrite,
Scope::RouteWrite,
Scope::DomainManage,
Scope::LogRead,
Scope::AppAdmin,
Scope::InstanceAdmin,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ScriptRead => "script:read",
Self::ScriptWrite => "script:write",
Self::RouteWrite => "route:write",
Self::DomainManage => "domain:manage",
Self::LogRead => "log:read",
Self::AppAdmin => "app:admin",
Self::InstanceAdmin => "instance:admin",
}
}
#[must_use]
pub fn from_wire(s: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|sc| sc.as_str() == s)
}
/// True for scopes that only make sense on an unbound key — bound
/// keys (api_keys.app_id IS NOT NULL) cannot claim instance-wide
/// authority and the mint handler rejects the combination at 422.
#[must_use]
pub const fn is_instance(self) -> bool {
matches!(self, Self::InstanceAdmin)
}
}
// Custom serde so the wire form is the colon-separated string. The
// stored DB value lives in a `text[]`, so the repo converts between
// `Vec<String>` and `Vec<Scope>` using `as_str`/`from_wire`.
impl Serialize for Scope {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for Scope {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::from_wire(&s).ok_or_else(|| serde::de::Error::custom(format!("unknown scope: {s}")))
}
}
/// Resolved caller identity. Produced by `manager-core::auth_middleware`
/// for both the cookie-session path (then `scopes`/`app_binding` are
/// `None`) and the bearer-API-key path (then both fields carry the
/// key's constraints).
///
/// The capability check in `manager-core::authz::can` intersects
/// `instance_role` with `scopes` and `app_binding` to decide whether
/// a given `Capability` is granted.
#[derive(Debug, Clone)]
pub struct Principal {
pub user_id: UserId,
pub instance_role: InstanceRole,
/// `None` for cookie sessions (no scope restriction beyond the
/// role itself); `Some` for API keys, in which case the effective
/// authority is `role ∩ scopes`.
pub scopes: Option<Vec<Scope>>,
/// `Some(app)` for keys bound to a single app at mint time. Every
/// `App*(other)` capability is denied regardless of role.
pub app_binding: Option<AppId>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn instance_role_round_trip() {
for role in [
InstanceRole::Owner,
InstanceRole::Admin,
InstanceRole::Member,
] {
assert_eq!(InstanceRole::from_db_str(role.as_str()), Some(role));
}
assert_eq!(InstanceRole::from_db_str("bogus"), None);
}
#[test]
fn app_role_round_trip() {
for role in [AppRole::AppAdmin, AppRole::Editor, AppRole::Viewer] {
assert_eq!(AppRole::from_db_str(role.as_str()), Some(role));
}
assert_eq!(AppRole::from_db_str("bogus"), None);
}
#[test]
fn scope_round_trip_covers_all() {
for &scope in Scope::ALL {
assert_eq!(Scope::from_wire(scope.as_str()), Some(scope));
}
assert_eq!(Scope::from_wire("script:nope"), None);
}
#[test]
fn scope_is_instance_flags_only_instance_admin() {
for &scope in Scope::ALL {
let expected = scope == Scope::InstanceAdmin;
assert_eq!(scope.is_instance(), expected, "scope {}", scope.as_str());
}
}
#[test]
fn scope_serde_uses_wire_form() {
let s = serde_json::to_string(&Scope::ScriptWrite).unwrap();
assert_eq!(s, "\"script:write\"");
let back: Scope = serde_json::from_str(&s).unwrap();
assert_eq!(back, Scope::ScriptWrite);
let err = serde_json::from_str::<Scope>("\"unknown\"").unwrap_err();
assert!(err.to_string().contains("unknown scope"));
}
}

View File

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

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

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

View File

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

View File

@@ -4,22 +4,32 @@
//! that core's crate. Things here must be genuinely shared (IDs, the Script //! that core's crate. Things here must be genuinely shared (IDs, the Script
//! entity, error roots, transport DTOs). //! entity, error roots, transport DTOs).
pub mod app;
pub mod auth;
pub mod error; pub mod error;
pub mod events;
pub mod execution_log; pub mod execution_log;
pub mod ids; pub mod ids;
pub mod log_sink; pub mod log_sink;
pub mod route; pub mod route;
pub mod sandbox; pub mod sandbox;
pub mod script; pub mod script;
pub mod sdk_cx;
pub mod services;
pub mod validator; pub mod validator;
pub mod version; pub mod version;
pub use app::{App, AppDomain, DomainShape};
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
pub use error::Error; pub use error::Error;
pub use events::{EmitError, NoopEventEmitter, ServiceEvent, ServiceEventEmitter};
pub use execution_log::{ExecutionLog, ExecutionStatus}; pub use execution_log::{ExecutionLog, ExecutionStatus};
pub use ids::{ExecutionId, RequestId, ScriptId}; pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId};
pub use log_sink::{ExecutionLogSink, LogSinkError}; pub use log_sink::{ExecutionLogSink, LogSinkError};
pub use route::{HostKind, PathKind, Route}; pub use route::{HostKind, PathKind, Route};
pub use sandbox::ScriptSandbox; pub use sandbox::ScriptSandbox;
pub use script::Script; pub use script::Script;
pub use sdk_cx::SdkCallCx;
pub use services::Services;
pub use validator::{ScriptValidator, ValidationError}; pub use validator::{ScriptValidator, ValidationError};
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION}; pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};

View File

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

View File

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

View File

@@ -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 build
node_modules node_modules
package-lock.json package-lock.json
# Playwright generated artifacts
playwright-report
test-results
tests/e2e/.auth
tests/e2e/.results

View File

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

View File

@@ -1,36 +1,53 @@
{ {
"name": "picloud-dashboard", "name": "picloud-dashboard",
"version": "0.5.0", "version": "0.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "prettier --check . && eslint ." "lint": "prettier --check . && eslint .",
}, "test": "vitest run",
"devDependencies": { "test:e2e": "playwright test",
"@eslint/js": "^9.18.0", "test:e2e:ui": "playwright test --ui",
"@sveltejs/adapter-static": "^3.0.8", "test:e2e:install": "playwright install --with-deps chromium"
"@types/node": "^22.10.5", },
"@sveltejs/kit": "^2.17.0", "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@eslint/js": "^9.18.0",
"eslint": "^9.18.0", "@playwright/test": "^1.60.0",
"eslint-config-prettier": "^10.0.1", "@sveltejs/adapter-static": "^3.0.8",
"eslint-plugin-svelte": "^3.0.0", "@sveltejs/kit": "^2.17.0",
"globals": "^15.14.0", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"prettier": "^3.4.2", "@types/node": "^22.10.5",
"prettier-plugin-svelte": "^3.3.3", "eslint": "^9.18.0",
"svelte": "^5.19.0", "eslint-config-prettier": "^10.0.1",
"svelte-check": "^4.1.4", "eslint-plugin-svelte": "^3.0.0",
"typescript": "^5.7.3", "globals": "^15.14.0",
"typescript-eslint": "^8.20.0", "prettier": "^3.4.2",
"vite": "^6.0.7" "prettier-plugin-svelte": "^3.3.3",
}, "svelte": "^5.19.0",
"overrides": { "svelte-check": "^4.1.4",
"cookie": "^0.7.2" "typescript": "^5.7.3",
} "typescript-eslint": "^8.20.0",
"vite": "^6.0.7",
"vitest": "^3.0.5"
},
"overrides": {
"cookie": "^0.7.2"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.2",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.7.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.0",
"@lezer/highlight": "^1.2.3",
"codemirror": "^6.0.2"
}
} }

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