Commit Graph

60 Commits

Author SHA1 Message Date
MechaCat02
3af8cc38c9 feat(v1.1.2-docs): migrations + shared DocsService trait + TriggerEvent::Docs
Migrations 0013_docs.sql + 0014_docs_triggers.sql land the docs table
(JSONB body + GIN-on-jsonb_path_ops index, PK keyed on (app_id,
collection, id) for cross-app isolation) and widen the triggers.kind
and outbox.source_kind CHECK constraints to include 'docs', plus the
docs_trigger_details detail table mirroring kv_trigger_details.

picloud-shared grows the DocsService trait + DocRow/DocsListPage/
DocsError + NoopDocsService, the TriggerEvent::Docs variant with the
prev_data change-data-capture surface, the DocsEventOp enum, the docs
field on the Services bundle, and the SDK_VERSION bump 1.2 -> 1.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 19:54:56 +02:00
MechaCat02
66b661f64c chore(release): bump workspace to v1.1.1 + CHANGELOG
- Workspace package version: 1.1.0 → 1.1.1 (patch under the
  post-1.0 expansion-phase carve-out in docs/versioning.md)
- Rhai SDK version: 1.1 → 1.2 — minor bump, additive only.
  New surfaces: kv::*, dead_letters::*, ctx.event.
- Dashboard package version: 0.6.0 → 0.7.0 for the dead-letters UI.
- HTTP API version stays at 1 (additive: trigger CRUD, dead-letter
  admin endpoints, dispatch_mode field on routes).
- Schema version: 6 → 12 (migrations 0007–0012).

CHANGELOG.md created at the repo root following the convention from
prior bumps (release commits + design-notes references).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:24:25 +02:00
MechaCat02
6b7ff78730 feat(v1.1.1-gc): dead-letter + abandoned-executions retention sweepers
Two tokio tasks spawned at startup that sweep their respective
tables on a weekly cadence (design notes §3 #9 + §4 retention).
Both use `FOR UPDATE SKIP LOCKED` on the claim query so concurrent
sweepers in cluster mode (v1.3+) don't fight each other.

Defaults: 30 days for dead_letters, 7 days for abandoned_executions.
Both env-overridable via `PICLOUD_DEAD_LETTER_RETENTION_DAYS` and
`PICLOUD_ABANDONED_EXECUTIONS_RETENTION_DAYS` (loaded into
`TriggerConfig::from_env` from commit 5).

Per-tick batch cap (5_000 rows) so a sweep can't lock up the table
in a single transaction; the inner loop continues until 0 rows
affected, after which the outer tick waits for the next week.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:22:42 +02:00
MechaCat02
20f1b5e64d feat(v1.1.1-dead-letters): service + Rhai SDK + admin endpoints
`PostgresDeadLetterService` lands as the real `DeadLetterService`
impl, replacing `NoopDeadLetterService` in the picloud binary's
`Services` bundle. Both methods are gated by
`Capability::AppDeadLetterManage(AppId)` — public-HTTP scripts with
`principal: None` fail the check, per design notes §4.

- `dead_letters::replay(id)` (Rhai SDK + admin endpoint): re-inserts
  the original event payload into the outbox with attempt_count=0,
  reply_to=None. The DL row is marked `resolution='replayed'`.
- `dead_letters::resolve(id, reason)` (Rhai SDK + admin endpoint):
  closes the row with `resolved_at = NOW()` and the given reason.
  CHECK constraint on the column enforces the 4-value vocabulary.
- `dead_letters::list(filter)` is intentionally NOT shipped —
  design notes §4 defers it to v1.2 to align with the eventual
  `docs::find()` query DSL.

Admin endpoints under `/api/v1/admin/apps/{id}/dead_letters/*`:
- `GET    /` (with `?unresolved=true`) → list view
- `GET    /count`                       → unresolved-count badge
- `GET    /{dl_id}`                     → row detail (full payload + error)
- `POST   /{dl_id}/replay`              → re-enqueue
- `POST   /{dl_id}/resolve` body `{reason}` → close out
All cross-app-aware: the row's `app_id` is compared against the path
param so a caller with rights on app A cannot manipulate app B's
dead letters by id alone.

The Rhai bridge for `dead_letters::*` follows the same sync↔async
pattern as the `kv::` bridge (`Handle::current().block_on(...)`
inside the spawn_blocking-wrapped Rhai engine).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:17:25 +02:00
MechaCat02
77b2cb58bb feat(v1.1.1-routes): outbox-routed sync HTTP + dispatch_mode=async
Routes gain `dispatch_mode TEXT NOT NULL DEFAULT 'sync'` (migration
0012). Existing routes default to sync so the migration is
non-breaking. `DispatchMode` enum lands in `picloud-shared`.

The user-routes orchestrator handler now branches:
- `dispatch_mode = async` → write outbox row with `reply_to = None`,
  return `202 Accepted` + `{accepted_at, execution_id}`. Dispatcher
  fires the script in the background; retries / dead-letters via
  the framework from commit 5.
- `dispatch_mode = sync` → register an inbox channel
  (`tokio::sync::oneshot`), write outbox row with `reply_to =
  inbox_id`, `.await` on the receiver with a timeout =
  script.timeout_seconds + 2s buffer. Dispatcher hands the result
  back; orchestrator maps `InboxResult` into the HTTP response per
  the design-notes §3 status-code table (422/502/503/504/507/500).

`InboxRegistry` (orchestrator-core/src/inbox.rs) is the in-process
implementation of `InboxResolver`. Lock-free HashMap of pending
oneshot senders keyed by `inbox_id`. Tests cover register/deliver
round-trip, unknown-id is abandoned, dropped-receiver is abandoned,
explicit cancel. Cluster mode (v1.3+) swaps this for
LISTEN/NOTIFY-keyed lookup behind the same trait.

`OutboxWriter` trait lives in `picloud-shared` so orchestrator-core
can write to the outbox without depending on manager-core (which
would invert the dependency arrow). `PostgresOutboxRepo` implements
both `OutboxRepo` (dispatcher surface) and `OutboxWriter`
(orchestrator surface); the picloud binary clones the same concrete
Arc into both trait views.

The dispatcher's HTTP arm (commit 5 had a stub) now decodes the
`HttpDispatchPayload` off the outbox row, looks up the script,
synthesizes an `ExecRequest`, and runs it through the executor.
Outcome routing reuses the same path as KV triggers — sync HTTP
flows through the inbox, async dispatch gets dropped after
success (or DL'd on exhaustion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:12:55 +02:00
MechaCat02
6a2971ac70 feat(v1.1.1-dispatcher): dispatcher loop + retry + depth limit + outbox emitter
`OutboxEventEmitter` replaces `NoopEventEmitter` in the picloud
binary's `Services` bundle. KV mutations now fan out to the outbox
via `TriggerRepo::list_matching_kv` — one row per matching trigger,
carrying the serialized `TriggerEvent` payload + the matching
trigger's retry policy.

`Dispatcher` is the single tokio task that polls the outbox every
100ms, claims due rows via FOR UPDATE SKIP LOCKED (with a batch cap),
and routes each to the executor. Shares the `ExecutionGate` with
sync HTTP per design notes §2 — gate saturation reschedules the
row instead of dropping it.

Outcome handling matches design notes §3 and §4:
- reply_to.is_some() (sync HTTP): never retry. Deliver via
  `InboxResolver`; if the receiver was dropped, write an
  `abandoned_executions` row.
- is_dead_letter_handler == true: never retry, never DL. On
  failure, annotate the original DL row with
  `resolution = 'handler_failed'`. Stops the recursion that would
  otherwise re-fire a broken handler script.
- Otherwise async: bump attempt_count, reschedule with exponential
  backoff + ±jitter; once max_attempts is reached, write a
  `dead_letters` row and drop from outbox.
- Trigger-depth limit: `cx.trigger_depth > max_trigger_depth` skips
  execution entirely (log + future metric), NEVER dead-letters.
  Loops are not retried via the DL chain — they're terminated.

`InboxResolver` trait lands in `picloud-shared` with a
`NoopInboxResolver` bootstrap that flags every delivery as
`Abandoned`. Commit 6 replaces the noop with the real
in-process registry in `orchestrator-core`.

`AdminPrincipalResolver` builds a `Principal` from a trigger's
`registered_by_principal` user id so the dispatched script executes
as the trigger registrant (design notes §4).

Unit tests cover backoff math (exponential/linear/constant) +
jitter range + ExecError → InboxFailureKind classification + the
status-code table mapping. Integration tests for the full
dispatcher loop need a real Postgres + executor; reviewer runs them
via the manual smoke flow in the plan / HANDBACK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:01:42 +02:00
MechaCat02
2e92691ee1 feat(v1.1.1-triggers): trigger CRUD admin endpoints
`/api/v1/admin/apps/{id}/triggers/*` — separate POST endpoints per
kind (kv / dead_letter) so each request validates against the
correct shape. List and DELETE work across both kinds.

Gated on `Capability::AppManageTriggers(app_id)`, which maps onto
`Scope::AppAdmin` (no new scope variants — seven-scope commitment
held) and is granted at the per-app `AppAdmin` role.

Request payloads accept `dispatch_mode` (defaults to `async`) and
retry-override fields. Omitted retry fields fall back to
`TriggerConfig::from_env`, which the binary plumbs into
`TriggersState` so the row is auditable from itself (no lazy
resolution at dispatch time). `registered_by_principal` is taken
from the authenticated principal — design notes §4: "a trigger
execution runs as the principal that registered the trigger".

DELETE loads the trigger first and 404s if its `app_id` doesn't
match the path — prevents a caller with rights on app A from
deleting a trigger via app B's path (bound-key safety net).

In-memory tests cover: app-not-found, member-without-role 403,
default-fallback for retry settings when request omits them,
empty-glob rejection, cross-app delete is treated as not-found.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 21:52:51 +02:00
MechaCat02
545d863199 feat(v1.1.1-triggers): triggers + outbox schema + repos
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>
2026-06-01 21:46:45 +02:00
MechaCat02
6b99f74c48 feat(v1.1.1-kv): Rhai kv:: SDK module + ctx.event wiring
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>
2026-06-01 21:38:41 +02:00
MechaCat02
434fb63cd2 feat(v1.1.1-kv): migrations + KvService trait + Postgres impl
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>
2026-06-01 21:29:59 +02:00
MechaCat02
1d2e99e42c test(stdlib): integration tests for the seven utility modules
43 tests exercising one happy path and the major error paths per
module (invalid regex pattern, oversize random::bytes, malformed JSON,
bad base64, mixed-case hex round-trip, invalid UTF-8 in url::decode,
etc.). Harness duplicates the pattern from sdk_contract.rs — each
integration test file in this crate keeps its own; there is no
tests/common/.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:28:47 +02:00
MechaCat02
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
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
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
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
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
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
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
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
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
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