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

@@ -14,19 +14,19 @@ use picloud_manager_core::{
attach_principal_if_present, auth_router, compile_routes, migrations, require_authenticated,
route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState,
AppRepository, AppsState, AuthState, AuthzRepo, PostgresAdminSessionRepository,
AppRepository, AppsState, AuthState, AuthzRepo, KvServiceImpl, PostgresAdminSessionRepository,
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
PostgresAppMembersRepository, PostgresAppRepository, PostgresExecutionLogRepository,
PostgresExecutionLogSink, PostgresRouteRepository, PostgresScriptRepository, RepoResolver,
RouteAdminState, RouteRepository, SandboxCeiling,
PostgresExecutionLogSink, PostgresKvRepo, PostgresRouteRepository, PostgresScriptRepository,
RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
};
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
use picloud_orchestrator_core::{
data_plane_router, user_routes_router, DataPlaneState, ExecutionGate, LocalExecutorClient,
};
use picloud_shared::{
ExecutionLogSink, ScriptValidator, Services, API_VERSION, PRODUCT_VERSION, SDK_VERSION,
WIRE_VERSION,
ExecutionLogSink, KvService, NoopDeadLetterService, NoopEventEmitter, ScriptValidator,
ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION,
};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
@@ -83,10 +83,6 @@ fn read_session_ttl() -> Duration {
/// `/version`) stays open — it's the public ingress for user scripts.
#[allow(clippy::too_many_lines)]
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
// `Services` is the SDK service bundle. Empty in v1.1.0; the
// v1.1.1 KV PR will populate it with `kv: Arc::new(...)` here.
let engine = Arc::new(Engine::new(Limits::default(), Services::new()));
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
@@ -98,10 +94,21 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
// (CRUD over the table) and `AuthzRepo` (single-row membership lookup
// for capability checks). Construct it once and clone the Arc into
// both trait views — same allocation, two vtables.
let members_concrete = Arc::new(PostgresAppMembersRepository::new(pool));
let members_concrete = Arc::new(PostgresAppMembersRepository::new(pool.clone()));
let members: Arc<dyn AppMembersRepository> = members_concrete.clone();
let authz: Arc<dyn AuthzRepo> = members_concrete;
// SDK services bundle. v1.1.1 ships the KV store; the outbox-backed
// event emitter replaces `NoopEventEmitter` once the triggers
// dispatcher lands. `NoopDeadLetterService` is a v1.1.1 stub that
// errors loudly until the real `PostgresDeadLetterService` ships.
let kv_repo = Arc::new(PostgresKvRepo::new(pool.clone()));
let events: Arc<dyn ServiceEventEmitter> = Arc::new(NoopEventEmitter);
let kv: Arc<dyn KvService> =
Arc::new(KvServiceImpl::new(kv_repo, authz.clone(), events.clone()));
let services = Services::new(kv, Arc::new(NoopDeadLetterService), events);
let engine = Arc::new(Engine::new(Limits::default(), services));
// Compile the routes table once at startup; admin writes refresh it.
let route_table = Arc::new(RouteTable::new());
let initial = route_repo.list_all().await?;