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>
This commit is contained in:
MechaCat02
2026-06-01 21:29:59 +02:00
parent 1efb350b54
commit 434fb63cd2
14 changed files with 1143 additions and 42 deletions

View File

@@ -57,6 +57,13 @@ pub enum Capability {
AppAdmin(AppId),
/// Read execution logs for scripts in this app.
AppLogRead(AppId),
/// Read entries from this app's KV store (v1.1.1). Granted to
/// `viewer`+ in the per-app role table. Maps to `script:read` on
/// API keys — the seven-scope vocabulary stays locked.
AppKvRead(AppId),
/// Write entries to this app's KV store (v1.1.1). Granted to
/// `editor`+. Maps to `script:write` on API keys.
AppKvWrite(AppId),
}
impl Capability {
@@ -73,7 +80,9 @@ impl Capability {
| Self::AppWriteRoute(id)
| Self::AppManageDomains(id)
| Self::AppAdmin(id)
| Self::AppLogRead(id) => Some(id),
| Self::AppLogRead(id)
| Self::AppKvRead(id)
| Self::AppKvWrite(id) => Some(id),
}
}
@@ -88,8 +97,8 @@ impl Capability {
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
Scope::InstanceAdmin
}
Self::AppRead(_) => Scope::ScriptRead,
Self::AppWriteScript(_) => Scope::ScriptWrite,
Self::AppRead(_) | Self::AppKvRead(_) => Scope::ScriptRead,
Self::AppWriteScript(_) | Self::AppKvWrite(_) => Scope::ScriptWrite,
Self::AppWriteRoute(_) => Scope::RouteWrite,
Self::AppManageDomains(_) => Scope::DomainManage,
Self::AppAdmin(_) => Scope::AppAdmin,
@@ -230,11 +239,16 @@ async fn member_grants(
/// 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_viewer = matches!(
cap,
Capability::AppRead(_) | Capability::AppLogRead(_) | Capability::AppKvRead(_)
);
let in_editor = in_viewer
|| matches!(
cap,
Capability::AppWriteScript(_) | Capability::AppWriteRoute(_)
Capability::AppWriteScript(_)
| Capability::AppWriteRoute(_)
| Capability::AppKvWrite(_)
);
let in_app_admin = in_editor
|| matches!(