diff --git a/crates/executor-core/tests/engine.rs b/crates/executor-core/tests/engine.rs
index 39935f4..7bb1336 100644
--- a/crates/executor-core/tests/engine.rs
+++ b/crates/executor-core/tests/engine.rs
@@ -27,7 +27,7 @@ fn req(body: serde_json::Value) -> ExecRequest {
}
fn engine() -> Engine {
- Engine::new(Limits::default(), Services::new())
+ Engine::new(Limits::default(), Services::default())
}
#[test]
@@ -126,7 +126,7 @@ fn enforces_operation_budget() {
max_operations: 1_000,
..Limits::default()
};
- let engine = Engine::new(limits, Services::new());
+ let engine = Engine::new(limits, Services::default());
// 10_000 iterations vastly exceeds 1_000 ops.
let src = r"let n = 0; for i in 0..10000 { n += 1; } n";
let err = engine
diff --git a/crates/executor-core/tests/sdk_contract.rs b/crates/executor-core/tests/sdk_contract.rs
index 26788c5..9ae6014 100644
--- a/crates/executor-core/tests/sdk_contract.rs
+++ b/crates/executor-core/tests/sdk_contract.rs
@@ -31,7 +31,7 @@ use serde_json::{json, Value};
// ----------------------------------------------------------------------------
fn engine() -> Engine {
- Engine::new(Limits::default(), Services::new())
+ Engine::new(Limits::default(), Services::default())
}
fn baseline_request() -> ExecRequest {
diff --git a/crates/executor-core/tests/stdlib.rs b/crates/executor-core/tests/stdlib.rs
index 1f119c7..c3649df 100644
--- a/crates/executor-core/tests/stdlib.rs
+++ b/crates/executor-core/tests/stdlib.rs
@@ -17,7 +17,7 @@ use serde_json::{json, Value};
// ----------------------------------------------------------------------------
fn engine() -> Engine {
- Engine::new(Limits::default(), Services::new())
+ Engine::new(Limits::default(), Services::default())
}
fn baseline_request() -> ExecRequest {
diff --git a/crates/manager-core/migrations/0007_kv.sql b/crates/manager-core/migrations/0007_kv.sql
new file mode 100644
index 0000000..c4ecd67
--- /dev/null
+++ b/crates/manager-core/migrations/0007_kv.sql
@@ -0,0 +1,28 @@
+-- v1.1.1: Key-value store — see blueprint §8.1 + docs/sdk-shape.md.
+--
+-- Identity tuple `(app_id, collection, key)`. `app_id` is first in the
+-- primary key so the implicit index is always per-app; cross-app reads
+-- cannot happen even with a buggy query. Collections are a required
+-- namespace inside an app — the same key can live in different
+-- collections without collision.
+--
+-- `value` is JSONB so scripts can store nested structures without
+-- a separate serialization step. No TTL column in v1.1.1; deferred
+-- until a concrete need surfaces (the blueprint reserved one but the
+-- v1.1.1 SDK surface — get/set/has/delete/list — doesn't expose TTL).
+
+CREATE TABLE kv_entries (
+ app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
+ collection TEXT NOT NULL,
+ key TEXT NOT NULL,
+ value JSONB NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (app_id, collection, key)
+);
+
+-- Supports list-by-collection (keyset pagination) and per-collection
+-- triggers' fan-out scans. The PK already covers (app_id, collection)
+-- as a prefix but spelling out the explicit index makes intent clear
+-- for the planner.
+CREATE INDEX idx_kv_entries_app_collection ON kv_entries (app_id, collection);
diff --git a/crates/manager-core/src/authz.rs b/crates/manager-core/src/authz.rs
index 1da5709..a19a055 100644
--- a/crates/manager-core/src/authz.rs
+++ b/crates/manager-core/src/authz.rs
@@ -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!(
diff --git a/crates/manager-core/src/kv_repo.rs b/crates/manager-core/src/kv_repo.rs
new file mode 100644
index 0000000..750d1bb
--- /dev/null
+++ b/crates/manager-core/src/kv_repo.rs
@@ -0,0 +1,223 @@
+//! Low-level Postgres CRUD over `kv_entries`. Stays storage-only;
+//! authorization, event emission, and empty-collection validation live
+//! one layer up in `KvServiceImpl`.
+
+use async_trait::async_trait;
+use base64::engine::general_purpose::URL_SAFE_NO_PAD;
+use base64::Engine as _;
+use picloud_shared::{AppId, KvListPage};
+use sqlx::PgPool;
+
+#[derive(Debug, thiserror::Error)]
+pub enum KvRepoError {
+ #[error("database error: {0}")]
+ Db(#[from] sqlx::Error),
+
+ #[error("invalid pagination cursor")]
+ InvalidCursor,
+}
+
+/// Repo surface. The trait is exposed so tests can substitute an
+/// in-memory backing without spinning up Postgres.
+#[async_trait]
+pub trait KvRepo: Send + Sync {
+ async fn get(
+ &self,
+ app_id: AppId,
+ collection: &str,
+ key: &str,
+ ) -> Result