//! `SecretsService` — the v1.1.7 encrypted per-app secrets contract. //! //! Collection-less (per-app, like pubsub): the script API is the bare //! `secrets::{get,set,delete,list}(name)` — there is no //! `secrets::collection(...)`. Secrets are operational config (API keys, //! OAuth tokens, webhook signing keys), encrypted at rest with the //! process master key. //! //! Lives in `picloud-shared` (not `executor-core`) so the Rhai bridge, //! the manager-core Postgres impl, and test fakes can all depend on the //! same trait. Implementations MUST derive every storage `app_id` from //! `cx.app_id` — never from a script-passed argument. That is the //! cross-app isolation boundary; see `docs/sdk-shape.md`. //! //! Values are JSON internally: `set` accepts any `serde_json::Value` //! (the bridge maps a Rhai String/Map/Array to JSON), encrypts the //! encoded bytes, and `get` decrypts + decodes back to the same JSON //! shape — so a String round-trips to a String, not a JSON-quoted //! `"\"…\""`. There is deliberately **no `ServiceEvent` emission**: //! firing triggers on secret writes is a footgun (every rotation would //! fan out handler executions that might log the new value). use async_trait::async_trait; use thiserror::Error; use crate::SdkCallCx; /// Maximum secret name length in bytes (matches the brief: 255). pub const SECRET_NAME_MAX_BYTES: usize = 255; /// `SecretsService` is collection-less and per-app. Every method derives /// the owning `app_id` from `cx.app_id`. #[async_trait] pub trait SecretsService: Send + Sync { /// Decrypt and return the secret, or `None` if no secret with this /// name exists for the app. async fn get( &self, cx: &SdkCallCx, name: &str, ) -> Result, SecretsError>; /// Encrypt and store the secret, overwriting any existing value for /// this name. async fn set( &self, cx: &SdkCallCx, name: &str, value: serde_json::Value, ) -> Result<(), SecretsError>; /// Delete the secret. Returns whether a secret was present. async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result; /// List secret **names only** (never values), cursor-paginated like /// KV/files `list`. `cursor` is opaque; `None` starts from the /// beginning. async fn list( &self, cx: &SdkCallCx, cursor: Option<&str>, limit: u32, ) -> Result; } /// One page of secret names from `SecretsService::list`. `next_cursor` /// is `Some` when more pages exist. #[derive(Debug, Clone)] pub struct SecretsListPage { pub names: Vec, pub next_cursor: Option, } /// Failure modes surfaced to the Rhai bridge. The bridge converts each /// to a Rhai runtime error string. #[derive(Debug, Error)] pub enum SecretsError { /// Empty name, or a name longer than [`SECRET_NAME_MAX_BYTES`]. #[error("{0}")] InvalidName(String), /// The encoded plaintext exceeded the configured per-secret cap. #[error("secret value too large: {actual} bytes exceeds the {limit}-byte limit")] TooLarge { limit: usize, actual: usize }, /// Caller principal lacked the required capability. Only raised when /// `cx.principal.is_some()` — public-HTTP scripts (`principal: None`) /// operate under script-as-gate semantics and skip the check. #[error("forbidden")] Forbidden, /// The stored ciphertext could not be decrypted (corrupted row, /// wrong master key, or tampering). The impl logs the affected /// `(app_id, name)` at error level before returning this. #[error("secret is corrupted or was encrypted with a different master key")] Corrupted, /// The process master key was unavailable. Startup should already /// have failed; this is defense in depth. #[error("master key is not configured")] MasterKeyMissing, /// Anything else — Postgres unavailable, serialization failure, etc. #[error("secrets backend error: {0}")] Backend(String), } /// Stub used by the executor-core test harness (which doesn't touch /// secrets) so a `Services` bundle can be built without Postgres. Every /// call returns `SecretsError::Backend(...)` so accidental use surfaces. #[derive(Debug, Default, Clone, Copy)] pub struct NoopSecretsService; #[async_trait] impl SecretsService for NoopSecretsService { async fn get( &self, _cx: &SdkCallCx, _name: &str, ) -> Result, SecretsError> { Err(SecretsError::Backend("secrets is not wired in".into())) } async fn set( &self, _cx: &SdkCallCx, _name: &str, _value: serde_json::Value, ) -> Result<(), SecretsError> { Err(SecretsError::Backend("secrets is not wired in".into())) } async fn delete(&self, _cx: &SdkCallCx, _name: &str) -> Result { Err(SecretsError::Backend("secrets is not wired in".into())) } async fn list( &self, _cx: &SdkCallCx, _cursor: Option<&str>, _limit: u32, ) -> Result { Err(SecretsError::Backend("secrets is not wired in".into())) } } /// Validate a secret name at the SDK/admin boundary: non-empty and at /// most [`SECRET_NAME_MAX_BYTES`] bytes. /// /// # Errors /// /// Returns [`SecretsError::InvalidName`] when empty or too long. pub fn validate_secret_name(name: &str) -> Result<(), SecretsError> { if name.is_empty() { return Err(SecretsError::InvalidName( "secret name must not be empty".into(), )); } if name.len() > SECRET_NAME_MAX_BYTES { return Err(SecretsError::InvalidName(format!( "secret name must be at most {SECRET_NAME_MAX_BYTES} bytes, got {}", name.len() ))); } Ok(()) }