The runtime for shared topics:
- pubsub_repo: fan_out_publish gains `AND t.shared = FALSE` (a shared
trigger never fires on a per-app publish); new fan_out_shared_publish
matches `shared = true` pubsub triggers on the resolved owning group,
each delivery stamped with the writer app_id (M2 model).
- GroupPubsubService trait (shared) + GroupPubsubServiceImpl: resolve the
owning group (kind='topic') from cx.app_id's chain, require editor+
(GroupPubsubPublish, fails closed for anon), size-cap, fan out.
- SDK: `pubsub::shared_topic("name")` -> GroupTopicHandle with
`.publish(subtopic, msg)` / `.publish(msg)`; wired through Services +
the picloud binary (reuses the one PubsubRepo).
- authz: Capability::GroupPubsubPublish(GroupId), editor+ / script:write.
The owning-group chain walk is the isolation boundary; a sibling-subtree
app never resolves the topic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Replace nine open-coded `if cx.principal.is_some() {
authz::require(...).await.map_err(...) }` blocks with a single
`authz::script_gate(repo, cx, cap, forbidden_fn, backend_fn)` helper.
The helper enshrines the script-as-gate semantics (anonymous public-
HTTP scripts skip the check) and the AuthzDenied::{Denied,Repo}
mapping in one place — eliminating drift between services.
Call sites migrated:
- kv_service::check_read / check_write
- docs_service::check_read / check_write
- files_service::check_read / check_write
- pubsub_service::check_publish
- queue_service::enqueue
The pubsub_service::mint_subscriber_token path keeps the explicit
match because it does a separate `principal` early-bind for other
validation; converting it would obscure intent.
AUDIT.md anchor: F-Q-003. Depends on F-Q-004 (Backend variant) and
F-Q-005 (Repo-passthrough pattern).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every check_read/check_write/check_publish helper across kv, docs,
files, pubsub, queue services used `.map_err(|_| KvError::Forbidden)?`,
collapsing AuthzDenied::Denied AND AuthzDenied::Repo(repo_err) into
a single Forbidden. A transient Postgres blip during the membership
lookup surfaced as 403 to scripts; operators couldn't distinguish
"real forbidden" from "DB flap during permission check".
Replace each call site with the explicit match pattern already used by
users_service::require (users_service.rs:177-181):
Ok(()) → continue
Err(AuthzDenied::Denied) → Err(ServiceError::Forbidden)
Err(AuthzDenied::Repo(e)) → Err(ServiceError::Backend(e.to_string()))
7 call sites updated (2 in kv_service, 2 in docs_service, 2 in
files_service, 2 in pubsub_service, 1 in queue_service).
AUDIT.md anchor: F-Q-005. Depends on F-Q-004 (which added Backend
to QueueError/PubsubError).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings QueueError / PubsubError / InvokeError in line with sibling
shape used by KvError / DocsError / FilesError / SecretsError:
- QueueError gains an explicit Forbidden variant (previously authz
denial was squashed into Rejected("forbidden") in queue_service.rs:68,
losing the structured variant a 403-translation layer would need).
- QueueError::Unavailable renamed → Backend.
- PubsubError::Unavailable renamed → Backend.
- InvokeError::Unavailable renamed → Backend.
- Call sites updated (queue_service, pubsub_service, invoke_service,
Noop* stubs, From<PubsubRepoError> impl, one test assertion).
- New unit test verifying authed-denied → QueueError::Forbidden.
AUDIT.md anchor: F-Q-004.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clears the workspace under `clippy --all-targets --all-features
-D warnings`. Four were pre-existing at v1.1.6 HEAD (latent finding,
see HANDBACK): double_must_use on realtime_router, map_unwrap_or in
pubsub_service, redundant_closure in topic_repo, needless_raw_string in
a subscriber-token test. The rest are v1.1.7 nits (needless_borrow +
semicolon in the dead-letter / realtime-migration code).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Durable pub/sub through the universal outbox — the sixth trigger kind.
- `pubsub::publish_durable(topic, message)` Rhai SDK (no handle; topics
ARE the grouping unit). Message JSON-encoded; Blobs base64 at any
depth.
- `PubsubService` trait in picloud-shared with the topic matcher +
validator (exact / `<prefix>.*` / `*`; mid-pattern wildcards
rejected). `PostgresPubsubRepo` + `PubsubServiceImpl` in manager-core.
- Publish-time fan-out: one outbox row per matching enabled pubsub
trigger, all in ONE transaction (no half-fan-out on crash). No
matching trigger → publish succeeds silently, zero rows.
- `pubsub:*` trigger kind via Layout-E (0020: widen both CHECKs +
pubsub_trigger_details + partial index), TriggerEvent::Pubsub +
ctx.event.pubsub, dispatcher arm, admin endpoint POST /triggers/pubsub
(validates topic pattern + reuses validate_trigger_target).
- AppPubsubPublish capability → script:write (seven-scope held).
- Dashboard Pub/Sub trigger form on the Triggers tab + list rendering.
publish_ephemeral stays deferred to v1.2. ~18 new tests (service
in-memory incl. transactional-rollback, shared matcher, bridge
encoding). No DB required for the suite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>