Files (per-file cap), secrets (64 KB default), and email (25 MB default)
already enforce limits; kv::set, docs::create/update, pubsub::publish_durable
and queue::enqueue accepted any JSON value straight to a JSONB column with
no size validation. An anonymous public-HTTP script could fill disk via
queue::enqueue or amplify a single publish into N outbox rows × payload bytes.
Adds four new error variants:
- KvError::ValueTooLarge { limit, actual }
- DocsError::ValueTooLarge { limit, actual }
- PubsubError::MessageTooLarge { limit, actual }
- QueueError::PayloadTooLarge { limit, actual }
Each stateful service grows a `max_value_bytes` field with:
- Conservative 256 KB default (DEFAULT_KV_MAX_VALUE_BYTES etc.).
- New `with_max_*` constructor preserving the old `new()` signature.
- Env-knob reader (`*_max_*_from_env()`) — mirrors SecretsConfig::from_env.
Validation runs at the entry point BEFORE authz so an anonymous DoS doesn't
pay a membership lookup per attempt.
Wired via env knobs:
- PICLOUD_KV_MAX_VALUE_BYTES
- PICLOUD_DOCS_MAX_VALUE_BYTES
- PICLOUD_PUBSUB_MAX_MESSAGE_BYTES
- PICLOUD_QUEUE_MAX_PAYLOAD_BYTES
Documented in CLAUDE.md runtime config table.
New unit test in queue_service verifying the cap fires before authz.
AUDIT.md anchor: F-S-001. Depends on F-Q-004 (Backend variant).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
203 lines
7.1 KiB
Rust
203 lines
7.1 KiB
Rust
//! `PubsubService` — the v1.1.5 durable pub/sub contract.
|
||
//!
|
||
//! `pubsub::publish_durable(topic, message)` writes to the universal
|
||
//! outbox; the publish-time fan-out inserts one delivery row per
|
||
//! matching `pubsub` trigger, and each delivery retries / dead-letters
|
||
//! independently (the dispatcher already handles one-row-equals-one-
|
||
//! dispatch — no dispatcher changes for pub/sub).
|
||
//!
|
||
//! `publish_ephemeral` is committed as a v1.2 addition — the suffix
|
||
//! naming exists now so users learn "durable by default" from day one.
|
||
//!
|
||
//! Topic pattern matching runs in Rust (not SQL) so the trigger-select
|
||
//! query stays simple. The matcher + validator live here in
|
||
//! `picloud-shared` so the manager-core publish path, the admin trigger
|
||
//! endpoint, and tests all agree on the rules.
|
||
|
||
use async_trait::async_trait;
|
||
use thiserror::Error;
|
||
|
||
use crate::SdkCallCx;
|
||
|
||
#[async_trait]
|
||
pub trait PubsubService: Send + Sync {
|
||
/// Durable publish: writes the message to the outbox, fanned out to
|
||
/// every matching enabled `pubsub` trigger in `cx.app_id`. Succeeds
|
||
/// silently (zero rows written) when no trigger matches the topic.
|
||
async fn publish_durable(
|
||
&self,
|
||
cx: &SdkCallCx,
|
||
topic: &str,
|
||
message: serde_json::Value,
|
||
) -> Result<(), PubsubError>;
|
||
|
||
/// Mint an HMAC-signed realtime subscriber token (v1.1.6). Backs the
|
||
/// `pubsub::subscriber_token(topics, ttl)` Rhai SDK call. The minted
|
||
/// token authorizes an external SSE client to subscribe to the given
|
||
/// `topics` for `ttl_seconds` (clamped to the configured bounds; the
|
||
/// configured default applies when `ttl_seconds` is `None`).
|
||
///
|
||
/// Every topic must already be registered as externally subscribable
|
||
/// in `cx.app_id`; `cx.principal` must be `Some` (anonymous
|
||
/// public-HTTP scripts can't mint). See [`PubsubError::SubscriberToken`]
|
||
/// for the rejection messages.
|
||
///
|
||
/// The default impl errors `Unavailable` so test fakes and the
|
||
/// `NoopPubsubService` keep compiling; the real minting lives in
|
||
/// manager-core's `PubsubServiceImpl`.
|
||
async fn mint_subscriber_token(
|
||
&self,
|
||
cx: &SdkCallCx,
|
||
topics: Vec<String>,
|
||
ttl_seconds: Option<i64>,
|
||
) -> Result<String, PubsubError> {
|
||
let _ = (cx, topics, ttl_seconds);
|
||
Err(PubsubError::Backend(
|
||
"subscriber tokens are not wired in".into(),
|
||
))
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Error)]
|
||
pub enum PubsubError {
|
||
/// Empty topic; rejected at the SDK boundary.
|
||
#[error("topic must not be empty")]
|
||
EmptyTopic,
|
||
|
||
/// Caller principal lacked the required capability. Only raised when
|
||
/// `cx.principal.is_some()` (script-as-gate; public HTTP skips it).
|
||
#[error("forbidden")]
|
||
Forbidden,
|
||
|
||
/// Serialization / validation failure on the message.
|
||
#[error("pubsub rejected: {0}")]
|
||
Rejected(String),
|
||
|
||
/// JSON-encoded message exceeded the configured `max_message_bytes`
|
||
/// cap (`PICLOUD_PUBSUB_MAX_MESSAGE_BYTES`). Defends against an
|
||
/// anonymous public script amplifying one publish into N outbox rows
|
||
/// × payload bytes.
|
||
#[error("pubsub: message too large ({actual} bytes; limit {limit})")]
|
||
MessageTooLarge { limit: usize, actual: usize },
|
||
|
||
/// A `pubsub::subscriber_token` mint was rejected (empty topics,
|
||
/// unregistered topic, ttl out of range, anonymous caller). The
|
||
/// string is the full user-facing message; the SDK surfaces it
|
||
/// verbatim so scripts see the documented wording.
|
||
#[error("{0}")]
|
||
SubscriberToken(String),
|
||
|
||
/// Anything else — Postgres unavailable, etc. Named `Backend` to
|
||
/// align with KvError / DocsError / FilesError / SecretsError.
|
||
#[error("pubsub backend error: {0}")]
|
||
Backend(String),
|
||
}
|
||
|
||
/// Match a stored `topic_pattern` against a published `topic`.
|
||
///
|
||
/// - `"*"` matches every topic.
|
||
/// - `"<prefix>.*"` matches any topic starting with `"<prefix>."`.
|
||
/// - anything else is an exact match.
|
||
///
|
||
/// Mid-pattern wildcards (`*.created`, `a.*.b`) are NOT supported — they
|
||
/// are rejected at trigger creation by [`validate_topic_pattern`], so
|
||
/// the only patterns reaching this matcher are exact / prefix / `*`.
|
||
#[must_use]
|
||
pub fn topic_matches(pattern: &str, topic: &str) -> bool {
|
||
if pattern == "*" {
|
||
return true;
|
||
}
|
||
if let Some(prefix) = pattern.strip_suffix('*') {
|
||
// `prefix` retains the trailing '.', e.g. "user." for "user.*".
|
||
return topic.starts_with(prefix);
|
||
}
|
||
pattern == topic
|
||
}
|
||
|
||
/// Validate a subscription topic pattern. Accepts exactly: `"*"`
|
||
/// (universal), `"<prefix>.*"` (prefix wildcard, single trailing star),
|
||
/// or a literal with no `*` (exact). Everything else — mid-pattern
|
||
/// wildcards, multiple stars, a star not at the end — is rejected.
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns `Err(message)` with `"unsupported pubsub topic pattern: …"`
|
||
/// for any unsupported shape (or an empty pattern).
|
||
pub fn validate_topic_pattern(pattern: &str) -> Result<(), String> {
|
||
if pattern.is_empty() {
|
||
return Err("unsupported pubsub topic pattern: <empty>".to_string());
|
||
}
|
||
if pattern == "*" {
|
||
return Ok(());
|
||
}
|
||
let stars = pattern.matches('*').count();
|
||
if stars == 0 {
|
||
return Ok(()); // exact
|
||
}
|
||
if stars == 1 && pattern.ends_with(".*") {
|
||
return Ok(()); // prefix wildcard
|
||
}
|
||
Err(format!("unsupported pubsub topic pattern: {pattern}"))
|
||
}
|
||
|
||
/// Stub for the test harness so executor-core integration tests can
|
||
/// build a `Services` bundle without a database. Every call errors.
|
||
#[derive(Debug, Default, Clone, Copy)]
|
||
pub struct NoopPubsubService;
|
||
|
||
#[async_trait]
|
||
impl PubsubService for NoopPubsubService {
|
||
async fn publish_durable(
|
||
&self,
|
||
_cx: &SdkCallCx,
|
||
_topic: &str,
|
||
_message: serde_json::Value,
|
||
) -> Result<(), PubsubError> {
|
||
Err(PubsubError::Backend("pubsub is not wired in".into()))
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn exact_match() {
|
||
assert!(topic_matches("user.created", "user.created"));
|
||
assert!(!topic_matches("user.created", "user.deleted"));
|
||
assert!(!topic_matches("user.created", "user.created.x"));
|
||
}
|
||
|
||
#[test]
|
||
fn prefix_wildcard() {
|
||
assert!(topic_matches("user.*", "user.created"));
|
||
assert!(topic_matches("user.*", "user.deleted"));
|
||
assert!(!topic_matches("user.*", "users.created"));
|
||
assert!(!topic_matches("user.*", "order.created"));
|
||
}
|
||
|
||
#[test]
|
||
fn universal() {
|
||
assert!(topic_matches("*", "anything"));
|
||
assert!(topic_matches("*", "a.b.c"));
|
||
}
|
||
|
||
#[test]
|
||
fn validation_accepts_supported_shapes() {
|
||
assert!(validate_topic_pattern("*").is_ok());
|
||
assert!(validate_topic_pattern("user.created").is_ok());
|
||
assert!(validate_topic_pattern("user.*").is_ok());
|
||
assert!(validate_topic_pattern("a.b.c").is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn validation_rejects_unsupported_shapes() {
|
||
for bad in ["*.created", "**", "a.*.b", "user.*x", "*user", ""] {
|
||
assert!(
|
||
validate_topic_pattern(bad).is_err(),
|
||
"expected {bad:?} to be rejected"
|
||
);
|
||
}
|
||
}
|
||
}
|