Migrations 0008-0011 lay down the triggers framework's storage:
- `triggers` + `kv_trigger_details` + `dead_letter_trigger_details`
(Layout E, design notes §2). Parent table carries common columns
including `registered_by_principal` — the dispatcher uses this to
run the trigger as the user that registered it (design notes §4).
- `outbox`: universal async dispatch substrate. KV/cron/pubsub/queue/
email/dead-letter all write rows in the same shape; the dispatcher
claims due rows via FOR UPDATE SKIP LOCKED. `reply_to` is the
NATS-style inbox id for sync HTTP (commit 6) — its presence flags
"don't retry" per the design.
- `dead_letters`: exact schema from design notes §4 with the four-
value `resolution` CHECK constraint (`replayed | ignored |
handled_by_script | handler_failed`) and partial index on
unresolved rows for the dashboard badge.
- `abandoned_executions`: forensic table for the dispatcher's
"tried to resolve a dropped inbox" edge case (design notes §3 #9).
Repo surfaces with Postgres impls behind traits so unit tests can
swap in-memory backings:
- `TriggerRepo` — CRUD + the `list_matching_kv` /
`list_matching_dead_letter` hot paths the dispatcher uses.
Includes a `collection_matches` helper that handles `*`, `prefix:*`,
and exact-name globs.
- `OutboxRepo` — insert + claim-due + delete + reschedule.
- `DeadLetterRepo` — insert + get + list + unresolved-count +
resolve + GC.
- `AbandonedRepo` — insert + GC.
`TriggerConfig::from_env` (new module) follows the existing
`SandboxCeiling` env-loading pattern for `PICLOUD_MAX_TRIGGER_DEPTH`,
`PICLOUD_TRIGGER_RETRY_*`, `PICLOUD_DEAD_LETTER_RETENTION_DAYS`, and
`PICLOUD_ABANDONED_EXECUTIONS_RETENTION_DAYS`.
`Capability::AppManageTriggers(AppId)` and `AppDeadLetterManage(AppId)`
join the enum. Both map onto the existing `Scope::AppAdmin` per the
seven-scope commitment; `role_satisfies` grants them at the
`AppAdmin` per-app role.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the KV store into Rhai scripts via the handle pattern:
let widgets = kv::collection("widgets");
widgets.set("k", #{ n: 1 });
let v = widgets.get("k"); // value or () if absent
widgets.has("k") / widgets.delete("k")
let page = widgets.list(); // cursor-style pagination
`KvHandle` is a custom Rhai type holding `Arc<dyn KvService>` + the
per-call `Arc<SdkCallCx>`. Methods route async service calls through
`tokio::Handle::current().block_on(...)` — works because
`LocalExecutorClient` runs the script under `spawn_blocking` so a
runtime is reachable. The bridge surfaces `app_id` exclusively
through `cx.app_id`; no public-facing argument can spoof an app.
`TriggerEvent` lands in `picloud-shared` as the wire shape the
dispatcher will emit (KV + DeadLetter variants — KV exercised now,
DL hooks up with the dispatcher in commit 5/8). `SdkCallCx` and
`ExecRequest` grow `is_dead_letter_handler: bool` and
`event: Option<TriggerEvent>`. `engine.rs::build_ctx_map` flattens
the event into `ctx.event` for triggered handlers; direct ingress
leaves the key absent so scripts can `if "event" in ctx`.
Tests:
- 7 `sdk_kv.rs` integration tests covering the full Rhai surface
(round-trip, missing-key unit, has bool, delete was-present,
empty-collection rejection, cursor pagination, cross-app
isolation through the bridge).
- 3 new `engine.rs` tests pinning `ctx.event` shape per
design notes §4 (KV insert with value, delete with unit value,
direct invocations have no `event` key).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First v1.1.1 commit. Adds the KV store the design notes commit to:
`(app_id, collection, key)` identity with JSONB value and a per-app
index. Trait lives in `picloud-shared` so the executor-core Rhai
bridge (next commit), the Postgres impl, and tests all depend on the
same surface without coupling crates.
The `Services` bundle grows from empty to three fields: `kv`,
`dead_letters` (NoopDeadLetterService stub — replaced by the
Postgres impl in commit 8), and `events` (NoopEventEmitter until the
outbox emitter lands with the dispatcher). Tests use
`Services::default()` for an all-noop bundle.
New capabilities `AppKvRead` / `AppKvWrite` join the Capability
enum. They map onto the existing seven-value `Scope` (script:read /
script:write) — the scope vocabulary stays locked per the
`docs/versioning.md` commitment.
Script-as-gate semantics in `KvServiceImpl`: capability check runs
when `cx.principal.is_some()`, skipped when None (public HTTP).
Cross-app isolation is enforced independently by deriving every
row's `app_id` from `cx.app_id` rather than a script-passed argument.
In-memory `KvRepo` impl + unit tests cover the round-trips, the
cross-app isolation property, empty-collection rejection,
script-as-gate behaviour for both anonymous and authed contexts,
and cursor-style pagination. Postgres impl exists; integration
testing waits for a real DB harness (see HANDBACK).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
* 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>
* 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>
* 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>
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>
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>
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>
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>
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>
Boots a fresh Postgres via sqlx::test, applies every migration in
order, dumps the resulting public schema (tables, columns with type
+ nullability + default, indexes, constraints, applied migration
manifest), and compares against a checked-in golden text file.
What this catches:
* Someone edits a committed migration — schema diverges from the
snapshot, test fails with a precise diff.
* Someone adds a migration but forgets to update the snapshot —
same divergence; test reminds them.
* Two migrations drift apart in any other way — snapshot is the
source of truth about the post-replay schema.
Update workflow when adding a migration intentionally:
BLESS=1 DATABASE_URL=postgres://... \
cargo test -p picloud-manager-core --test schema_snapshot \
-- --include-ignored
Review the snapshot diff in the same PR. The header comment makes
it clear the file is not for hand-editing.
* Snapshot dump uses information_schema.columns + pg_indexes +
pg_constraint with pg_get_constraintdef. Output is sorted on
every dimension so cosmetic differences (insertion order,
etc.) never cause spurious diffs.
* #[ignore]'d by default for the same reason as the integration
tests — needs DATABASE_URL pointing at a writable Postgres.
* Initial expected_schema.txt blessed from the current
migrations/ contents (3 tables, 9 indexes, 12 constraints).
Wires up enforcement item (4) from docs/versioning.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Scripts can now answer at user-chosen paths (e.g. /greet, /greet/:name,
/webhooks/*), on user-chosen hosts (strict or *.example.com wildcards),
on user-chosen methods. The internal /api/v1/execute/{id} endpoint
stays as the always-available ID-based bypass.
Routing rules (decided in design with the user; see chat history):
Path kinds:
exact /greet literal
prefix /greet/* strict-subtree; stored as "/greet/";
does NOT match bare /greet (add an
exact route for that case)
param /users/:id :name captures one whole segment;
mid-segment colons are rejected;
{name} is reserved for a future SDK
Host kinds:
any no Host header constraint
strict sub.example.com literal match (case-insensitive)
wildcard *.example.com suffix match; multi-level subdomains OK
Within-kind uniqueness:
two routes of the same kind that could match the same request
conflict at config time. Algorithm (orchestrator_core::routing::
conflict):
exact: literal equality
prefix: literal equality (longer-prefix coexists; longer wins
at request time)
param: same segment count + same literals at every
literal-vs-literal position (the user's example:
:id vs :userId at same shape is a conflict)
Request-time precedence:
exact > param > prefix
among non-exact: more leading-literal segments wins
tie: param > prefix (more constrained)
within prefix: longest matching prefix wins
host bucket: strict > wildcard (longer suffix) > any; fall through
to less specific buckets when path doesn't match
Reserved path prefixes: /api/, /admin/, /healthz, /version
Routes that look invalid at config time return 422 with the precise
parse error; conflicting routes return 409 with the conflicting route
in the body (so the dashboard can render the conflict inline).
What landed:
* 0003_routes.sql — routes table (host_kind, host, host_param_name,
path_kind, path, method, script_id) with UNIQUE index on the
literal binding tuple. Schema 2 → 3.
* shared::Route / HostKind / PathKind — flat storage shape that
crosses wire boundaries cleanly.
* orchestrator_core::routing — four sub-modules, all unit-tested:
pattern.rs (16 tests) parse + validate + display
conflict.rs (12 tests) within-kind overlap predicate
matcher.rs (12 tests) runtime dispatch (specificity-aware)
table.rs Arc<RwLock<Vec<CompiledRoute>>>
shared by manager (writes) and
orchestrator (reads); atomic replace
after each admin write
* manager-core::route_admin — five new admin endpoints under
/api/v1/admin:
POST /scripts/{id}/routes create
GET /scripts/{id}/routes list per script
DELETE /routes/{route_id} delete (refreshes table)
POST /routes:check pre-flight conflict check
(powers the dashboard's
live conflict warning)
POST /routes:match synthetic URL → matched
route + extracted params
(powers the dashboard's
match-preview tool)
Stored path strings stay raw (user-typed); normalization
happens only in the in-memory CompiledRoute so re-parses are
idempotent.
* orchestrator_core::api::user_routes_router — fallback handler
mounted in picloud after the system routes. Reads Host /
method / path / query from the request, dispatches via the
table, builds an ExecRequest with params/query/rest filled,
calls the executor, writes to the log sink. 10 MiB body cap.
* executor-core::ctx (SDK 1.0 → 1.1) — adds
ctx.request.params (map of named-param captures)
ctx.request.query (parsed query string)
ctx.request.rest (suffix for prefix routes; "" otherwise)
All three are always present (empty when not applicable) so
scripts can read them unconditionally.
* picloud::build_app — now async; loads routes at startup,
populates the shared table, mounts route_admin_router under
/api/v1/admin alongside the script CRUD, and the user-routes
fallback at the app root.
* caddy/Caddyfile + Caddyfile.prod widened: anything not
/healthz, /version, /api/v1/admin/*, /api/v1/execute/*,
/api/* (404 sunset), or /admin/* (dashboard) → picloud.
* Dashboard moves to /admin/* via SvelteKit paths.base. Its
internal Caddy strips the prefix and serves with SPA fallback.
All in-app links use $app/paths. The dashboard URL is now
http://localhost:8000/admin/ — one-time break for the new
URL freedom users gained.
* PICLOUD_PUBLIC_BASE_URL env var, exposed via /version so the
dashboard renders full URLs for routes regardless of the
operator's external port / TLS setup.
* memory_limit_mb stays in the schema, still v1.3+ advisory.
Verified live through Caddy:
/version → schema 3, sdk 1.1, public_base_url
GET /admin/ → 200, dashboard HTML containing "PiCloud"
POST /api/v1/admin/scripts → 201
POST .../scripts/{id}/routes (path=/greet/:name) → 201
GET /greet/alice?lang=en → 200 {"name":"alice","q":"en"}
POST conflicting route → 409 with conflicting_route body
POST /admin/foo route → 422 "reserved"
POST /api/v1/admin/routes:match → matched + params extracted
GET /unbound-path → 404 JSON
Tests:
* 40 routing unit tests (pattern + conflict + matcher tables)
* 14 executor-core unit tests (one new for ctx.request.params/
query/rest exposure)
* 32 integration tests (10 new for routing CRUD + dispatch +
conflict + reserved + specificity tie-break + match preview +
delete invalidation + /version returns public_base_url)
* default cargo test --workspace stays green; opt-in via
DATABASE_URL + --include-ignored for the integration suite
Bumps: schema 2 → 3; SDK 1.0 → 1.1; product 0.3.0 → 0.4.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds optional per-script overrides for the six Rhai sandbox knobs
(max_operations, max_string_size, max_array_size, max_map_size,
max_call_levels, max_expr_depth). The executor merges its defaults
with each script's overrides on every call; the manager validates
overrides against an admin-set ceiling at write time, so the
executor trusts whatever is stored.
Storage chose JSONB on the existing scripts table over six new
columns: lets future knobs land as code-only changes, keeps the
sparse common case (most scripts override nothing) cheap to store
and serialize, and matches how the manager + executor pass the
config across the wire.
* 0002_sandbox.sql — ALTER TABLE scripts ADD COLUMN sandbox
JSONB NOT NULL DEFAULT '{}'
* shared::ScriptSandbox — six Option<u64> fields with
deny_unknown_fields so typos surface as 422
* Script.sandbox + ExecRequest.sandbox_overrides — typed end
to end; cluster mode just serializes the same struct
* executor-core::Limits::with_overrides — field-by-field
replacement; tests cover the override actually tightening
the live engine
* manager-core::SandboxCeiling — built-in conservative
defaults (10M ops, 1 MiB strings, 100k array/map, 128
call/expr depth); env vars override per knob, invalid
values warn-and-skip rather than blocking boot
* manager-core admin API — POST/PUT accept `sandbox`; values
above the ceiling return 422 with the specific field +
requested + ceiling; absent or `{}` keeps platform defaults
* picloud all-in-one — wires SandboxCeiling::from_env() into
AdminState
* memory_limit_mb stays in the schema, marked v1.3+ advisory
(no enforcement until OS-level isolation lands with
cluster-mode executors)
Verified live through Caddy:
* /version reports schema 2, product 0.3.0
* Script with max_operations: 500 → 507 on a 10k-iteration loop
* Same script after PUT raising to 1M → succeeds, returns 10000
* POST with max_operations: 1_000_000_000 → 422 (exceeds ceiling)
Tests:
* 13 executor-core unit tests (added 2 for override semantics)
* 20 integration tests (added 6 for sandbox CRUD + ceiling +
unknown-field rejection + executor honoring overrides)
* default cargo test --workspace stays green (integration tests
remain #[ignore]'d until DATABASE_URL is set)
Bumps:
* schema 1 → 2
* product 0.2.0 → 0.3.0
* SDK unchanged (scripts see nothing new)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Establish how versions are assigned, bumped, and checked across the
five things that actually change for users: the product itself, the
Rhai SDK, the HTTP API, the database schema, and the inter-service
wire (reserved for cluster mode). Crates ship in lockstep — drift
between picloud-shared and picloud-manager-core is fiction since
they always release together — but surfaces are versioned and
checked at their natural boundaries.
* docs/versioning.md is the authoritative reference: what gets a
version, the per-surface compatibility rules, how each surface
bump cascades to the product version (loose pre-1.0, strict
post-1.0), and the five enforcement mechanisms (lockstep at
compile time, /version at runtime, golden SDK contract tests,
migration replay, CI guardrail).
* shared::version exposes four constants — PRODUCT_VERSION (from
CARGO_PKG_VERSION), SDK_VERSION ("1.0"), API_VERSION (1),
WIRE_VERSION (1). Scripts read SDK_VERSION as ctx.sdk_version
and can feature-detect against it.
* Workspace inheritance: `[workspace.package] version = "0.2.0"`
is the single point of truth; every crate uses
`version.workspace = true`. dashboard/package.json mirrors.
* Routes move to /api/v1/* — both control plane
(/api/v1/admin/*) and data plane (/api/v1/execute/{id}).
Picloud composes them via a single `/api/v{API_VERSION}` nest,
so the next major is a copy-paste-and-bump. Caddyfile (dev and
prod) routes /api/v1/* to picloud and 404s any other /api/*
so old clients fail loudly instead of getting the SPA shell.
Dashboard client + integration tests updated.
* /healthz remains a plain "ok" string (k8s probes); /version is
the new JSON endpoint returning every surface version in one
place — product, sdk, api, schema (from
manager-core::migrations::latest_version), wire.
* Reasonable bump rationale: API path changes are breaking by
definition, so 0.1.0 → 0.2.0 (pre-1.0 license to bump minor on
any breaking change). SDK starts at 1.0 because scripts depend
on it more strictly than the product depends on its internals;
we'd rather promise SDK stability early than pull the rug.
Verified live:
* /healthz → "ok" (plain text)
* /version → {product:"0.2.0",sdk:"1.0",api:1,schema:1,wire:1}
* /api/v1/admin/scripts → 200
* /api/admin/scripts → 404 with error JSON (sunset major)
* Script can read ctx.sdk_version → "1.0"
* All 14 integration tests pass against new paths
* 11 executor-core unit tests pass (added one for sdk_version
exposure with the major.minor format invariant)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three threads landing together because they share a public surface
(the new execution_log shape) and verifying any one in isolation
would mean re-doing the work later.
== (A) execution log persistence ==
* shared::ExecutionLog + ExecutionStatus carry the audit-trail
shape that flows from the orchestrator through the sink and
back out via the manager's logs endpoint.
* shared::ExecutionLogSink trait — abstraction the orchestrator
writes through. In single-process MVP mode the manager's
Postgres-backed impl is plugged in directly; in cluster mode
(v1.3+) the orchestrator's impl will post over HTTP to the
manager. Trait lives in `shared` so neither *-core crate has
to know about the other.
* manager-core::PostgresExecutionLogSink writes to the
execution_logs table (already in the initial migration);
PostgresExecutionLogRepository reads them back, paginated.
AdminState now carries both a script repo and a log repo, so
`admin_router` exposes `GET /scripts/{id}/logs?limit=&offset=`
capped at 200 rows per page to keep the dashboard responsive.
* orchestrator-core::DataPlaneState gains `log_sink`. The
execute handler builds an ExecutionLog on every outcome —
success, error, timeout, budget-exceeded — and awaits the
sink. Sink failures are logged at warn and DO NOT mask the
user-facing result, since "we couldn't write the audit row"
is a separate concern from "the script ran".
* picloud binary refactored into a lib (`build_app(pool)` is
the seam) + thin bin shell. Same Postgres pool backs the
script repo, the log repo, and the sink — no double pool.
== (B) dashboard ==
* Typed API client extended with `scripts.logs(id, opts)`,
`scripts.update/remove`, and `execute(id, body, headers)`.
Plain `fetch` wrapper now surfaces server-side error
messages via a typed ApiError so the UI can render them.
* `/` — create-script form now actually creates; on success
the list reloads. List entries link to detail.
* `/scripts/[id]` — new detail route: source editor with save
(calls update, version bumps); Test invoke panel that sends
arbitrary JSON body + headers to /api/execute and shows the
response; Recent executions panel reading from /logs with
expandable per-row request/response/script-log views.
Delete button with confirm. SPA-routed; Caddy serves
`build/` with the same index.html fallback.
== (C) integration tests ==
* crates/picloud/tests/api.rs — 14 sqlx::test cases driving
`build_app` through an axum_test::TestServer against a fresh
Postgres DB per test. Covers: health, full script CRUD,
duplicate-name conflict, invalid-source rejection on both
create and update, execute echoing the body, status+header
passthrough, 404 on missing scripts, error-path executions
landing in the audit log with the right status.
* Tests are `#[ignore]` by default so plain `cargo test
--workspace` stays green without infrastructure. Opt-in via:
`docker compose up -d postgres && \
DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \
cargo test -p picloud --test api -- --include-ignored`
Verified live through Caddy on :8000: three logged invocations
land in the logs endpoint with the right structured `data` on
each `log::info`/`log::warn`, error-path executions are still
captured with status=error, dashboard list + SPA detail route
both reachable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings the MVP feature set online: upload a Rhai script, get an HTTP
endpoint that runs it sandboxed in-process, list/update/delete it, and
have invalid sources rejected at upload time. Verified live through
Caddy with a full lifecycle (`create → list → get → execute → update
→ delete`) plus error paths (syntax error, duplicate name, deleted).
Layout — every concern lands behind the trait seam its layer owns, so
cluster-mode in v1.3+ is a swap of two impls, not a rewrite:
* shared::ScriptValidator — manager calls into validation without
a hard dep on executor-core; executor-core impls the trait on
`Engine`. Pinned in shared so neither crate has to know about
the other.
* executor-core::Engine — real Rhai engine: sandbox limits (max
operations / string size / map size / call depth), disabled
`print`, blocked `import` (DummyModuleResolver), `log::trace
/info/warn/error` registered as a static module with shared
log-capture buffer (no `log::debug` because `debug` is a Rhai
reserved keyword — `log::trace` covers the same need).
- `ctx` is pushed as a Scope constant exposing
execution_id, script_id, script_name, request_id,
invocation_type, request.{path,headers,body}.
- Response convention: a Map with `statusCode` is the
structured shape (`{statusCode, headers?, body}`); any
other return value is a 200 with the value as the body.
- Engine::execute is now synchronous (pure compute); the
async wrapper + wall-clock timeout live in
LocalExecutorClient, which spawns_blocking and applies a
300s hard ceiling regardless of per-script config.
- 10 unit tests cover validate, exec, structured response,
ctx exposure, log capture, op-budget enforcement, runtime
errors, blocked imports, JSON round-tripping.
* manager-core::repo — full sqlx CRUD over the `scripts` table,
with proper unique-violation handling for duplicate names.
Embedded migrations via `sqlx::migrate!` (one initial
`0001_init.sql` for pgcrypto + scripts + execution_logs).
* manager-core::api — `admin_router` mounts `/scripts` and
`/scripts/{id}`. Create + Update validate source through the
injected `ScriptValidator` before persistence. Returns proper
422/409/404 status codes via `ApiError::IntoResponse`.
* orchestrator-core::api — `data_plane_router` mounts
`/execute/{id}`: resolves the script through `ScriptResolver`,
constructs the `ExecRequest` from headers+body, awaits
`ExecutorClient::execute(..., timeout)`, translates the
`ExecResponse` to an axum `Response` with header passthrough.
Maps `ExecError` variants to 422/504/502/507.
* picloud all-in-one — opens the pool, runs migrations, builds
one engine, nests both routers under `/api/admin` and `/api`,
enables structured JSON tracing and graceful shutdown on
SIGTERM. Single `PostgresScriptRepository` Arc is shared by
the admin router (writes) and the resolver (reads).
Other changes:
* Workspace axum bump 0.7 → 0.8 for the `{id}` path syntax
matching the route definitions.
* Workspace clippy: allow `needless_pass_by_value` and
`boxed_local` to keep API ergonomics over pedantic noise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sets up the PiCloud monorepo as a Cargo workspace organised around the
three-service architecture (manager / orchestrator / executor), each
backed by a *-core library crate so the same logic powers both the MVP
all-in-one `picloud` binary and the future split-process cluster mode.
* crates/shared, executor-core, orchestrator-core, manager-core
define the library surface and trait seams between the three
services (`ExecutorClient`, `ScriptResolver`, `ScriptRepository`).
* crates/picloud is the MVP entrypoint; serves /healthz on 8080
(override via PICLOUD_BIND).
* crates/picloud-{manager,orchestrator,executor} are skeleton
binaries that keep the crate boundaries honest until cluster
mode is built out in v1.3+.
* docs/git-workflow.md defines the trunk-based workflow:
short-lived branches, Conventional Commits, separate hotfix
flow with mandatory reproduction tests.
* CLAUDE.md captures the working rules for future Claude sessions.
Workspace passes `cargo fmt`, `cargo clippy -D warnings` (with
pedantic enabled), and `cargo test --workspace`. The all-in-one
binary responds on `/healthz` and `/`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>