From 2d11090d1a99383f17637f8ab9911a7c5f36f4fc Mon Sep 17 00:00:00 2001
From: MechaCat02
Date: Thu, 4 Jun 2026 21:37:17 +0200
Subject: [PATCH] feat(v1.1.7-secrets): secrets SDK + table + admin API +
dashboard
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Encrypted per-app secrets, reachable from scripts as
secrets::{get,set,delete,list}(name) and managed from the dashboard
Secrets tab. Values are AES-256-GCM-sealed with the process master key
(picloud_shared::crypto) before they touch Postgres; the repo only ever
sees ciphertext + nonce. JSON round-trip preserves Rhai types.
- migration 0023_secrets.sql (PRIMARY KEY (app_id, name)).
- SecretsService trait (picloud-shared) + SecretsServiceImpl + repo
(manager-core), wired into the Services bundle and Rhai engine.
- Capability::AppSecretsRead/Write (→ script:read / script:write); no
new Scope variants (seven-scope commitment).
- Admin API GET/POST/DELETE /apps/{id}/secrets (list returns names +
updated_at, never values).
- build_app now takes a MasterKey, sourced from PICLOUD_SECRET_KEY in
main.rs; test callers pass a fixed test key.
- 64 KB value cap (PICLOUD_SECRET_MAX_VALUE_BYTES); no ServiceEvent
emission (secret writes don't fire triggers, by design).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
crates/executor-core/src/sdk/mod.rs | 4 +-
crates/executor-core/src/sdk/secrets.rs | 153 +++++
.../tests/module_redaction_logging.rs | 1 +
crates/executor-core/tests/modules.rs | 1 +
crates/executor-core/tests/sdk_docs.rs | 1 +
crates/executor-core/tests/sdk_files.rs | 1 +
crates/executor-core/tests/sdk_http.rs | 1 +
crates/executor-core/tests/sdk_kv.rs | 1 +
crates/executor-core/tests/sdk_pubsub.rs | 1 +
crates/executor-core/tests/sdk_secrets.rs | 212 +++++++
.../tests/sdk_subscriber_token.rs | 1 +
.../manager-core/migrations/0023_secrets.sql | 24 +
crates/manager-core/src/authz.rs | 18 +-
crates/manager-core/src/lib.rs | 12 +
crates/manager-core/src/secrets_api.rs | 232 +++++++
crates/manager-core/src/secrets_repo.rs | 246 ++++++++
crates/manager-core/src/secrets_service.rs | 574 ++++++++++++++++++
crates/picloud/src/lib.rs | 66 +-
crates/picloud/src/main.rs | 7 +-
crates/picloud/tests/api.rs | 8 +-
crates/picloud/tests/authz.rs | 10 +-
crates/picloud/tests/dispatcher_e2e.rs | 8 +-
crates/shared/src/crypto.rs | 8 +-
crates/shared/src/lib.rs | 5 +
crates/shared/src/secrets.rs | 166 +++++
crates/shared/src/services.rs | 12 +-
dashboard/src/lib/api.ts | 26 +
dashboard/src/routes/apps/[slug]/+page.svelte | 195 +++++-
28 files changed, 1959 insertions(+), 35 deletions(-)
create mode 100644 crates/executor-core/src/sdk/secrets.rs
create mode 100644 crates/executor-core/tests/sdk_secrets.rs
create mode 100644 crates/manager-core/migrations/0023_secrets.sql
create mode 100644 crates/manager-core/src/secrets_api.rs
create mode 100644 crates/manager-core/src/secrets_repo.rs
create mode 100644 crates/manager-core/src/secrets_service.rs
create mode 100644 crates/shared/src/secrets.rs
diff --git a/crates/executor-core/src/sdk/mod.rs b/crates/executor-core/src/sdk/mod.rs
index 74a319c..5a17fb2 100644
--- a/crates/executor-core/src/sdk/mod.rs
+++ b/crates/executor-core/src/sdk/mod.rs
@@ -19,6 +19,7 @@ pub mod files;
pub mod http;
pub mod kv;
pub mod pubsub;
+pub mod secrets;
pub mod stdlib;
pub use bridge::{dynamic_to_json, json_to_dynamic};
@@ -41,5 +42,6 @@ pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc) {
+ let svc = services.secrets.clone();
+ let mut module = Module::new();
+
+ // secrets::set(name, value) — overwrites if present.
+ {
+ let svc = svc.clone();
+ let cx = cx.clone();
+ module.set_native_fn(
+ "set",
+ move |name: &str, value: Dynamic| -> Result<(), Box> {
+ let json = dynamic_to_json(&value);
+ let svc = svc.clone();
+ let cx = cx.clone();
+ block_on(async move { svc.set(&cx, name, json).await })
+ },
+ );
+ }
+
+ // secrets::get(name) — decoded value, or () if missing.
+ {
+ let svc = svc.clone();
+ let cx = cx.clone();
+ module.set_native_fn(
+ "get",
+ move |name: &str| -> Result> {
+ let svc = svc.clone();
+ let cx = cx.clone();
+ let opt = block_on(async move { svc.get(&cx, name).await })?;
+ Ok(opt.map_or(Dynamic::UNIT, json_to_dynamic))
+ },
+ );
+ }
+
+ // secrets::delete(name) — bool was-present.
+ {
+ let svc = svc.clone();
+ let cx = cx.clone();
+ module.set_native_fn(
+ "delete",
+ move |name: &str| -> Result> {
+ let svc = svc.clone();
+ let cx = cx.clone();
+ block_on(async move { svc.delete(&cx, name).await })
+ },
+ );
+ }
+
+ // secrets::list(#{ cursor, limit }) — names only, cursor-paginated.
+ {
+ let svc = svc.clone();
+ let cx = cx.clone();
+ module.set_native_fn(
+ "list",
+ move |opts: Map| -> Result
{/if}
+
+ {#if revealConfirm}
+ (revealConfirm = false)}
+ >
+
+ The value you type will be shown in plain text on screen. Make sure no one
+ is looking over your shoulder and that screen-sharing is off.
+
+
+ {/if}
+
+ {#if secretToRemove}
+ (secretToRemove = null)}
+ >
+
+ Deleting {secretToRemove.name} is permanent. Any script calling
+ secrets::get("{secretToRemove.name}") will get ()
+ until you set it again.
+
+
+ {/if}
{/if}