From fcbcc576a22295afa3e6188657387c630575dade Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 4 Jun 2026 20:18:50 +0200 Subject: [PATCH] feat(v1.1.6): realtime channels + v1.1.5 follow-ups + version bumps Server-side realtime SSE on per-app pub/sub topics, plus the three v1.1.5 follow-ups and the version bumps. Realtime: - topics registry (0021) + admin endpoints + Capability::AppTopicManage (-> app:admin; no new scope). - GET /realtime/topics/{topic} SSE endpoint (orchestrator-core data plane): Host -> app, RealtimeAuthority gate (404 missing/internal, 401 bad/absent token), broadcast::Receiver stream + heartbeat. - RealtimeBroadcaster / RealtimeEvent / RealtimeAuthority traits (picloud-shared); InProcessBroadcaster + GC (orchestrator-core); DB-backed RealtimeAuthorityImpl (manager-core). Publish path fans out to in-process subscribers after the durable outbox commit (best-effort, panic-isolated). - HMAC subscriber tokens (subscriber_token.rs) + app_secrets table (0022) + pubsub::subscriber_token SDK (schema 1.6 -> 1.7). TTL clamp + env overrides. - Dashboard Topics tab (register/list/edit/delete, prominent external badge, flip confirmation). v1.1.5 follow-ups: - Empty blobs accepted (NewFile/FileUpdate::validate) + round-trip test. - Orphan *.tmp.* sweeper (spawn_files_orphan_sweep). - Dispatcher e2e tests, one per trigger kind (DATABASE_URL-gated). Versions: workspace 1.1.6, SDK 1.7, dashboard 0.12.0. Schema-snapshot golden re-blessed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + CHANGELOG.md | 86 +++ Cargo.lock | 38 +- Cargo.toml | 6 +- crates/executor-core/src/sdk/pubsub.rs | 76 +++ .../tests/sdk_subscriber_token.rs | 242 +++++++ .../manager-core/migrations/0021_topics.sql | 31 + .../migrations/0022_app_secrets.sql | 19 + crates/manager-core/src/app_secrets_repo.rs | 91 +++ crates/manager-core/src/authz.rs | 46 +- crates/manager-core/src/files_service.rs | 28 +- crates/manager-core/src/files_sweep.rs | 185 ++++++ crates/manager-core/src/lib.rs | 14 +- crates/manager-core/src/pubsub_service.rs | 418 +++++++++++- crates/manager-core/src/realtime_authority.rs | 338 ++++++++++ crates/manager-core/src/topic_repo.rs | 212 ++++++ crates/manager-core/src/topics_api.rs | 629 ++++++++++++++++++ crates/manager-core/tests/expected_schema.txt | 31 + crates/orchestrator-core/Cargo.toml | 5 + crates/orchestrator-core/src/lib.rs | 6 + crates/orchestrator-core/src/realtime.rs | 242 +++++++ crates/orchestrator-core/src/realtime_api.rs | 408 ++++++++++++ crates/picloud/src/lib.rs | 94 ++- crates/picloud/tests/dispatcher_e2e.rs | 353 ++++++++++ crates/shared/Cargo.toml | 9 + crates/shared/src/files.rs | 16 +- crates/shared/src/lib.rs | 5 + crates/shared/src/pubsub.rs | 33 + crates/shared/src/realtime.rs | 86 +++ crates/shared/src/realtime_authority.rs | 70 ++ crates/shared/src/subscriber_token.rs | 200 ++++++ crates/shared/src/version.rs | 11 +- dashboard/package.json | 2 +- dashboard/src/lib/api.ts | 44 ++ dashboard/src/routes/apps/[slug]/+page.svelte | 319 ++++++++- 35 files changed, 4333 insertions(+), 63 deletions(-) create mode 100644 crates/executor-core/tests/sdk_subscriber_token.rs create mode 100644 crates/manager-core/migrations/0021_topics.sql create mode 100644 crates/manager-core/migrations/0022_app_secrets.sql create mode 100644 crates/manager-core/src/app_secrets_repo.rs create mode 100644 crates/manager-core/src/files_sweep.rs create mode 100644 crates/manager-core/src/realtime_authority.rs create mode 100644 crates/manager-core/src/topic_repo.rs create mode 100644 crates/manager-core/src/topics_api.rs create mode 100644 crates/orchestrator-core/src/realtime.rs create mode 100644 crates/orchestrator-core/src/realtime_api.rs create mode 100644 crates/picloud/tests/dispatcher_e2e.rs create mode 100644 crates/shared/src/realtime.rs create mode 100644 crates/shared/src/realtime_authority.rs create mode 100644 crates/shared/src/subscriber_token.rs diff --git a/.gitignore b/.gitignore index c6478c9..6c14189 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ Cargo.lock.bak # Local config overrides config.local.toml /data +# Files-root blob storage created when integration tests run build_app +# from the picloud crate dir (PICLOUD_FILES_ROOT default ./data). +/crates/picloud/data /postgres-data # Dashboard diff --git a/CHANGELOG.md b/CHANGELOG.md index 81a0593..44f3dfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,91 @@ # PiCloud Changelog +## v1.1.6 — Realtime Channels & Client Library (unreleased) + +The first **external realtime surface** and the first **frontend +library**, co-shipped per the §5/§6 design-notes decisions. Browser +clients can subscribe over SSE to per-app pub/sub topics that have been +explicitly externalized; everything else stays internal-only. The +`@picloud/client` TypeScript package wraps typed HTTP, SSE, auth, and +React/Svelte hooks. Plus three v1.1.5 follow-ups. + +### Added — Realtime + +- **`topics` registry** (`migrations/0021_topics.sql`) — pub/sub topics + are internal-only by default; a `topics` row with + `external_subscribable = true` opts one into external SSE subscription. + `auth_mode` is `'public'` or `'token'`. +- **Topic admin endpoints** under `/api/v1/admin/apps/{id}/topics` — + `POST` (register), `GET` (list), `PATCH /{name}` (flip + external/auth_mode — its own audited surface), `DELETE /{name}` + (unregister + disconnect live subscribers). Gated by the new + `Capability::AppTopicManage` → `app:admin` scope (no new scope; the + seven-scope commitment holds). +- **SSE endpoint `GET /realtime/topics/{topic}`** — data-plane surface + (deliberately not under `/api/`). Resolves `Host` → app, authorizes + via the `RealtimeAuthority` (404 for missing/internal topics, 401 for + bad/absent tokens), then streams `data: {topic,message,published_at}` + events with a configurable heartbeat (`PICLOUD_REALTIME_HEARTBEAT_SEC`, + default 30). Token via `Authorization: Bearer` or `?token=`. +- **`RealtimeBroadcaster` + `RealtimeEvent` + `RealtimeAuthority`** + traits (`picloud-shared`); in-process `InProcessBroadcaster` + (`tokio::sync::broadcast`, per-channel capacity + `PICLOUD_REALTIME_BROADCAST_CAPACITY` default 64, periodic empty-channel + GC) and the DB-backed `RealtimeAuthorityImpl` (orchestrator-core / + manager-core respectively). The publish path now also fans out to + in-process SSE subscribers, best-effort, after the durable outbox + fan-out commits — a broadcast failure never fails the publish. +- **`pubsub::subscriber_token(topics, ttl)`** Rhai SDK (SDK schema + 1.6 → 1.7) — mints an HMAC-SHA256 subscriber token (URL-safe + `payload.signature`) scoped to externally-subscribable topics. + Requires an authenticated principal + the pub/sub publish capability. + TTL clamped to `[10s, 24h]` (default 1h), env-overridable via + `PICLOUD_SUBSCRIBER_TOKEN_TTL_{MIN,MAX,DEFAULT}_SEC`. Per-app signing + keys persist in the new `app_secrets` table + (`migrations/0022_app_secrets.sql`), created lazily on first mint. No + per-token revocation (rotation invalidates wholesale; short TTL is the + safety mechanism). +- **Dashboard Topics tab** — register/list/edit/delete topics with a + prominent external/internal badge, auth-mode radio (conditional on + external), and a confirmation when flipping a topic external. + +### Added — `@picloud/client` (TypeScript, v1.0.0) + +- New top-level package `clients/typescript/` (tsup dual ESM+CJS + + `.d.ts`, vitest). Typed HTTP via `endpoint(path).get()/.post()` + with auth-token injection and structured errors; SSE `subscribe(topic, + cb, {token, onTokenExpired})` with exponential-backoff reconnect, + 401 token-refresh, and `Last-Event-ID` resume; `auth.login/logout/token` + over dev-defined endpoints; React (`useTopic`/`useEndpoint` + + `PicloudProvider`) and Svelte (`topicStore`/`endpointStore`) subpath + exports. Optional zod/valibot runtime validation via a `{ parse }` + adapter (no hard dep). Hybrid model: no direct service access from the + browser. + +### Changed / Fixed — v1.1.5 follow-ups + +- **Empty blobs accepted** — `NewFile::validate` / `FileUpdate::validate` + no longer reject zero-length `data`; empty files are a valid stored + state (sentinels, placeholders). Non-breaking. +- **Orphan `*.tmp.*` sweeper** — a startup tokio task + (`spawn_files_orphan_sweep`) walks the files root every + `PICLOUD_FILES_ORPHAN_SWEEP_INTERVAL_SEC` (default 6h) and unlinks temp + blobs older than `PICLOUD_FILES_ORPHAN_TMP_TTL_SEC` (default 1h). No DB + cross-check (that full reconciler is v1.3+). +- **Dispatcher end-to-end tests** — `crates/picloud/tests/dispatcher_e2e.rs`, + one per trigger kind (kv/docs/cron/files/pubsub/dead_letter), + DATABASE_URL-gated (skip cleanly when unset). + +### Notes + +- New deps: `hmac` (token signing, picloud-shared), `tokio-stream` (SSE + body stream, orchestrator-core). +- New env vars: `PICLOUD_REALTIME_HEARTBEAT_SEC`, + `PICLOUD_REALTIME_BROADCAST_CAPACITY`, + `PICLOUD_SUBSCRIBER_TOKEN_TTL_{MIN,MAX,DEFAULT}_SEC`, + `PICLOUD_FILES_ORPHAN_SWEEP_INTERVAL_SEC`, + `PICLOUD_FILES_ORPHAN_TMP_TTL_SEC`. + ## v1.1.5 — Files & Pub/Sub (unreleased) Two stateful services + two trigger kinds. **`files::*`** is diff --git a/Cargo.lock b/Cargo.lock index 8d989b3..203e733 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1610,7 +1610,7 @@ dependencies = [ [[package]] name = "picloud" -version = "1.1.5" +version = "1.1.6" dependencies = [ "anyhow", "async-trait", @@ -1636,7 +1636,7 @@ dependencies = [ [[package]] name = "picloud-cli" -version = "1.1.5" +version = "1.1.6" dependencies = [ "anyhow", "assert_cmd", @@ -1657,7 +1657,7 @@ dependencies = [ [[package]] name = "picloud-executor" -version = "1.1.5" +version = "1.1.6" dependencies = [ "anyhow", "picloud-executor-core", @@ -1669,7 +1669,7 @@ dependencies = [ [[package]] name = "picloud-executor-core" -version = "1.1.5" +version = "1.1.6" dependencies = [ "async-trait", "base64", @@ -1693,7 +1693,7 @@ dependencies = [ [[package]] name = "picloud-manager" -version = "1.1.5" +version = "1.1.6" dependencies = [ "anyhow", "picloud-manager-core", @@ -1705,7 +1705,7 @@ dependencies = [ [[package]] name = "picloud-manager-core" -version = "1.1.5" +version = "1.1.6" dependencies = [ "argon2", "async-trait", @@ -1733,7 +1733,7 @@ dependencies = [ [[package]] name = "picloud-orchestrator" -version = "1.1.5" +version = "1.1.6" dependencies = [ "anyhow", "picloud-orchestrator-core", @@ -1745,7 +1745,7 @@ dependencies = [ [[package]] name = "picloud-orchestrator-core" -version = "1.1.5" +version = "1.1.6" dependencies = [ "async-trait", "axum", @@ -1759,6 +1759,8 @@ dependencies = [ "serde_json", "thiserror 1.0.69", "tokio", + "tokio-stream", + "tower", "tracing", "urlencoding", "uuid", @@ -1766,13 +1768,17 @@ dependencies = [ [[package]] name = "picloud-shared" -version = "1.1.5" +version = "1.1.6" dependencies = [ "async-trait", + "base64", "chrono", + "hmac", "serde", "serde_json", + "sha2", "thiserror 1.0.69", + "tokio", "uuid", ] @@ -2990,6 +2996,20 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 36e112e..de58d5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ ] [workspace.package] -version = "1.1.5" +version = "1.1.6" edition = "2021" rust-version = "1.92" license = "MIT OR Apache-2.0" @@ -29,6 +29,8 @@ picloud-manager-core = { path = "crates/manager-core" } # Async + HTTP tokio = { version = "1.40", features = ["full"] } +# Wraps a broadcast::Receiver into a Stream for the SSE endpoint (v1.1.6). +tokio-stream = { version = "0.1", features = ["sync"] } axum = "0.8" tower = "0.5" tower-http = { version = "0.6", features = ["trace", "cors"] } @@ -75,6 +77,8 @@ urlencoding = "2" argon2 = "0.5" rand = { version = "0.8", features = ["getrandom"] } sha2 = "0.10" +# HMAC-SHA256 for realtime subscriber tokens (v1.1.6). +hmac = "0.12" base64 = "0.22" data-encoding = "2.6" diff --git a/crates/executor-core/src/sdk/pubsub.rs b/crates/executor-core/src/sdk/pubsub.rs index dcc185c..fc35387 100644 --- a/crates/executor-core/src/sdk/pubsub.rs +++ b/crates/executor-core/src/sdk/pubsub.rs @@ -40,9 +40,85 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc Result> { + mint_token(&svc, &cx, topics, None) + }, + ); + } + // `pubsub::subscriber_token(topics, ttl)` — `ttl` is an integer + // (seconds) or `()` for the default. + { + let svc = svc.clone(); + let cx = cx.clone(); + module.set_native_fn( + "subscriber_token", + move |topics: Array, ttl: Dynamic| -> Result> { + let ttl = ttl_from_dynamic(&ttl)?; + mint_token(&svc, &cx, topics, ttl) + }, + ); + } engine.register_static_module("pubsub", module.into()); } +/// Interpret the optional `ttl` argument: `()` → use the default, +/// integer → that many seconds, anything else → throw. +fn ttl_from_dynamic(ttl: &Dynamic) -> Result, Box> { + if ttl.is_unit() { + return Ok(None); + } + ttl.as_int().map(Some).map_err(|_| -> Box { + EvalAltResult::ErrorRuntime( + "pubsub::subscriber_token: ttl must be an integer (seconds) or ()".into(), + rhai::Position::NONE, + ) + .into() + }) +} + +fn mint_token( + svc: &Arc, + cx: &Arc, + topics: Array, + ttl: Option, +) -> Result> { + // Every element must be a string; surface a clear error otherwise. + let mut names = Vec::with_capacity(topics.len()); + for t in topics { + if !t.is_string() { + return Err(EvalAltResult::ErrorRuntime( + "pubsub::subscriber_token: topics must be an array of strings".into(), + rhai::Position::NONE, + ) + .into()); + } + names.push(t.into_string().unwrap_or_default()); + } + let svc = svc.clone(); + let cx = cx.clone(); + let handle = TokioHandle::try_current().map_err(|e| -> Box { + EvalAltResult::ErrorRuntime( + format!("pubsub: no tokio runtime available: {e}").into(), + rhai::Position::NONE, + ) + .into() + })?; + // SubscriberToken errors already carry the full + // "pubsub::subscriber_token: …" wording, so surface them verbatim. + handle + .block_on(async move { svc.mint_subscriber_token(&cx, names, ttl).await }) + .map_err(|err| -> Box { + EvalAltResult::ErrorRuntime(format!("{err}").into(), rhai::Position::NONE).into() + }) +} + /// Convert a Rhai `Dynamic` message into JSON, base64-encoding any /// `Blob` (at any nesting depth). Mirrors `bridge::dynamic_to_json` but /// adds the blob arm the pub/sub wire contract requires. diff --git a/crates/executor-core/tests/sdk_subscriber_token.rs b/crates/executor-core/tests/sdk_subscriber_token.rs new file mode 100644 index 0000000..0768c2c --- /dev/null +++ b/crates/executor-core/tests/sdk_subscriber_token.rs @@ -0,0 +1,242 @@ +//! `pubsub::subscriber_token` SDK bridge integration tests (v1.1.6). +//! +//! Runs a real Rhai engine against a fake `PubsubService` whose +//! `mint_subscriber_token` mirrors the production validation (principal +//! required, non-empty topics, ttl clamp, externally-subscribable check) +//! and signs a real token. These cover the bridge surface: array → +//! `Vec` forwarding, the omitted/`()`/integer ttl handling, and +//! errors surfacing as thrown Rhai errors. The authoritative validation +//! logic is unit-tested in `manager-core::pubsub_service`. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use async_trait::async_trait; +use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits}; +use picloud_shared::subscriber_token::{self, TokenClaims}; +use picloud_shared::{ + AdminUserId, AppId, ExecutionId, InstanceRole, NoopDeadLetterService, NoopDocsService, + NoopEventEmitter, NoopFilesService, NoopHttpService, NoopKvService, NoopModuleSource, + Principal, PubsubError, PubsubService, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services, +}; +use serde_json::Value; + +const FAKE_KEY: [u8; 32] = [7u8; 32]; +const MIN_TTL: i64 = 10; +const MAX_TTL: i64 = 86_400; +const DEFAULT_TTL: i64 = 3_600; + +/// Fake that mirrors the production mint rules and signs with FAKE_KEY. +#[derive(Default)] +struct FakeMintPubsub; + +#[async_trait] +impl PubsubService for FakeMintPubsub { + async fn publish_durable( + &self, + _cx: &SdkCallCx, + _topic: &str, + _message: Value, + ) -> Result<(), PubsubError> { + Ok(()) + } + + async fn mint_subscriber_token( + &self, + cx: &SdkCallCx, + topics: Vec, + ttl_seconds: Option, + ) -> Result { + if cx.principal.is_none() { + return Err(PubsubError::SubscriberToken( + "pubsub::subscriber_token: requires an authenticated principal".into(), + )); + } + if topics.is_empty() { + return Err(PubsubError::SubscriberToken( + "pubsub::subscriber_token: topics list must not be empty".into(), + )); + } + let ttl = ttl_seconds.unwrap_or(DEFAULT_TTL); + if !(MIN_TTL..=MAX_TTL).contains(&ttl) { + return Err(PubsubError::SubscriberToken(format!( + "pubsub::subscriber_token: ttl_seconds must be between {MIN_TTL} and {MAX_TTL}" + ))); + } + for name in &topics { + // Only "chat" and "notify" are "registered" in this fake. + if name != "chat" && name != "notify" { + return Err(PubsubError::SubscriberToken(format!( + "pubsub::subscriber_token: topic {name} is not externally subscribable" + ))); + } + } + let now = 1_000_000; + Ok(subscriber_token::sign( + &FAKE_KEY, + &TokenClaims { + app_id: cx.app_id, + topics, + exp: now + ttl, + iat: now, + }, + )) + } +} + +fn make_engine() -> Arc { + let services = Services::new( + Arc::new(NoopKvService), + Arc::new(NoopDocsService), + Arc::new(NoopDeadLetterService), + Arc::new(NoopEventEmitter), + Arc::new(NoopModuleSource), + Arc::new(NoopHttpService), + Arc::new(NoopFilesService), + Arc::new(FakeMintPubsub), + ); + Arc::new(Engine::new(Limits::default(), services)) +} + +fn request(app_id: AppId, with_principal: bool) -> ExecRequest { + let execution_id = ExecutionId::new(); + ExecRequest { + execution_id, + request_id: RequestId::new(), + script_id: ScriptId::new(), + script_name: "token-test".into(), + invocation_type: InvocationType::Http, + path: "/token-test".into(), + headers: BTreeMap::new(), + body: Value::Null, + params: BTreeMap::new(), + query: BTreeMap::new(), + rest: String::new(), + sandbox_overrides: ScriptSandbox::default(), + app_id, + principal: with_principal.then(|| Principal { + user_id: AdminUserId::new(), + instance_role: InstanceRole::Owner, + scopes: None, + app_binding: None, + }), + trigger_depth: 0, + root_execution_id: execution_id, + is_dead_letter_handler: false, + event: None, + } +} + +async fn run_ok(engine: Arc, src: &str, req: ExecRequest) -> Value { + let src = src.to_string(); + tokio::task::spawn_blocking(move || engine.execute(&src, req)) + .await + .expect("spawn_blocking should not panic") + .expect("script execution should succeed") + .body +} + +async fn run_err(engine: Arc, src: &str, req: ExecRequest) { + let src = src.to_string(); + let res = tokio::task::spawn_blocking(move || engine.execute(&src, req)) + .await + .expect("spawn_blocking should not panic"); + assert!(res.is_err(), "expected script to throw"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn token_contains_topics_and_expiry() { + let app = AppId::new(); + let body = run_ok( + make_engine(), + r#"#{ token: pubsub::subscriber_token(["chat", "notify"], 120) }"#, + request(app, true), + ) + .await; + let token = body["token"].as_str().expect("token string"); + let claims = subscriber_token::verify(&FAKE_KEY, token, 1_000_001).unwrap(); + assert_eq!(claims.app_id, app); + assert_eq!( + claims.topics, + vec!["chat".to_string(), "notify".to_string()] + ); + assert_eq!(claims.exp - claims.iat, 120); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn omitted_ttl_uses_default() { + let app = AppId::new(); + let body = run_ok( + make_engine(), + r#"#{ token: pubsub::subscriber_token(["chat"]) }"#, + request(app, true), + ) + .await; + let token = body["token"].as_str().unwrap(); + let claims = subscriber_token::verify(&FAKE_KEY, token, 1_000_001).unwrap(); + assert_eq!(claims.exp - claims.iat, DEFAULT_TTL); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unit_ttl_uses_default() { + let app = AppId::new(); + let body = run_ok( + make_engine(), + r#"#{ token: pubsub::subscriber_token(["chat"], ()) }"#, + request(app, true), + ) + .await; + let token = body["token"].as_str().unwrap(); + let claims = subscriber_token::verify(&FAKE_KEY, token, 1_000_001).unwrap(); + assert_eq!(claims.exp - claims.iat, DEFAULT_TTL); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn empty_topics_throws() { + run_err( + make_engine(), + r#"pubsub::subscriber_token([], 60)"#, + request(AppId::new(), true), + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ttl_below_min_throws() { + run_err( + make_engine(), + r#"pubsub::subscriber_token(["chat"], 5)"#, + request(AppId::new(), true), + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ttl_above_max_throws() { + run_err( + make_engine(), + r#"pubsub::subscriber_token(["chat"], 90000)"#, + request(AppId::new(), true), + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn anonymous_principal_throws() { + run_err( + make_engine(), + r#"pubsub::subscriber_token(["chat"], 60)"#, + request(AppId::new(), false), + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unregistered_topic_throws() { + run_err( + make_engine(), + r#"pubsub::subscriber_token(["chat", "secret"], 60)"#, + request(AppId::new(), true), + ) + .await; +} diff --git a/crates/manager-core/migrations/0021_topics.sql b/crates/manager-core/migrations/0021_topics.sql new file mode 100644 index 0000000..51202c1 --- /dev/null +++ b/crates/manager-core/migrations/0021_topics.sql @@ -0,0 +1,31 @@ +-- v1.1.6: Explicit registration for externally-subscribable topics. +-- +-- Internal-only topics remain implicit per the §5 design-notes +-- decision: anyone can publish_durable("any.topic", msg) and triggers +-- can subscribe without a row here. This table only holds topics that +-- have been explicitly externalized — external SSE subscribers can +-- only subscribe to topics with a row here AND external_subscribable +-- = TRUE. +-- +-- The publish path (v1.1.5's publish_durable) does NOT consult this +-- table: publishing to a topic with no row still fans out to triggers +-- and to any in-process external subscribers (none exist for an +-- unregistered topic, since external subscribers can't subscribe to +-- one). The topics table is read by the SSE subscribe path only. +-- +-- auth_mode values: 'public' + 'token' in v1.1.6. 'session' arrives in +-- v1.1.8 (users-SDK); 'script' arrives in v1.2 (script-mediated auth). +-- The CHECK constraint extends in those releases. +CREATE TABLE topics ( + app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE, + name TEXT NOT NULL, + external_subscribable BOOL NOT NULL DEFAULT FALSE, + auth_mode TEXT NOT NULL DEFAULT 'public' + CHECK (auth_mode IN ('public', 'token')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (app_id, name) +); + +-- Hot lookup: "is topic T in app X externally subscribable?" The PK +-- (app_id, name) already covers this; an explicit index is redundant. diff --git a/crates/manager-core/migrations/0022_app_secrets.sql b/crates/manager-core/migrations/0022_app_secrets.sql new file mode 100644 index 0000000..b3a8815 --- /dev/null +++ b/crates/manager-core/migrations/0022_app_secrets.sql @@ -0,0 +1,19 @@ +-- v1.1.6: per-app secret material. Currently holds the HMAC signing key +-- used to mint + verify realtime subscriber tokens +-- (pubsub::subscriber_token → SSE /realtime/topics handshake). +-- +-- The key is: +-- * stable across restarts (issued tokens stay valid until expiry), +-- * per-app (a token signed by app A is rejected by app B), +-- * never script-accessible (scripts can't print/exfiltrate it — the +-- SDK only mints tokens, it never returns the key). +-- +-- The row is created lazily on the first pubsub::subscriber_token call +-- for an app (32 random bytes). This table is the natural home for +-- v1.1.7's encrypted per-app secrets work. +CREATE TABLE app_secrets ( + app_id UUID PRIMARY KEY REFERENCES apps(id) ON DELETE CASCADE, + realtime_signing_key BYTEA NOT NULL, -- 32 random bytes + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/crates/manager-core/src/app_secrets_repo.rs b/crates/manager-core/src/app_secrets_repo.rs new file mode 100644 index 0000000..ad0648c --- /dev/null +++ b/crates/manager-core/src/app_secrets_repo.rs @@ -0,0 +1,91 @@ +//! `AppSecretsRepo` — per-app secret material (v1.1.6). +//! +//! Today this holds only the HMAC signing key for realtime subscriber +//! tokens. The key is generated lazily (32 random bytes) on the first +//! `pubsub::subscriber_token` call for an app and never changes +//! thereafter in v1.1.6 (no rotation API yet — rotation is the +//! key-invalidation mechanism, deferred). The key is never exposed to +//! scripts: the SDK mints tokens, it never returns the key. +//! +//! This table is the natural home for v1.1.7's encrypted per-app +//! secrets work. + +use async_trait::async_trait; +use picloud_shared::AppId; +use rand::RngCore; +use sqlx::PgPool; + +/// Length of a freshly-generated realtime signing key. +pub const SIGNING_KEY_LEN: usize = 32; + +#[derive(Debug, thiserror::Error)] +pub enum AppSecretsRepoError { + #[error("database error: {0}")] + Db(#[from] sqlx::Error), +} + +#[async_trait] +pub trait AppSecretsRepo: Send + Sync { + /// Fetch the app's realtime signing key, generating + persisting one + /// (32 random bytes) if absent. Idempotent under concurrency: a + /// racing creator's `ON CONFLICT DO NOTHING` insert is a no-op and + /// the existing key is returned. + async fn get_or_create_signing_key( + &self, + app_id: AppId, + ) -> Result, AppSecretsRepoError>; + + /// Fetch the signing key if it exists, WITHOUT creating one. The SSE + /// verify path uses this: a missing key means no token was ever + /// minted for the app, so any presented token must be rejected. + async fn signing_key(&self, app_id: AppId) -> Result>, AppSecretsRepoError>; +} + +pub struct PostgresAppSecretsRepo { + pool: PgPool, +} + +impl PostgresAppSecretsRepo { + #[must_use] + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl AppSecretsRepo for PostgresAppSecretsRepo { + async fn get_or_create_signing_key( + &self, + app_id: AppId, + ) -> Result, AppSecretsRepoError> { + let mut fresh = vec![0u8; SIGNING_KEY_LEN]; + rand::thread_rng().fill_bytes(&mut fresh); + + // Insert-if-absent then read: the racing-creator's insert is a + // no-op, and the SELECT always returns the winning key. + sqlx::query( + "INSERT INTO app_secrets (app_id, realtime_signing_key) \ + VALUES ($1, $2) ON CONFLICT (app_id) DO NOTHING", + ) + .bind(app_id.into_inner()) + .bind(&fresh) + .execute(&self.pool) + .await?; + + let key: (Vec,) = + sqlx::query_as("SELECT realtime_signing_key FROM app_secrets WHERE app_id = $1") + .bind(app_id.into_inner()) + .fetch_one(&self.pool) + .await?; + Ok(key.0) + } + + async fn signing_key(&self, app_id: AppId) -> Result>, AppSecretsRepoError> { + let row: Option<(Vec,)> = + sqlx::query_as("SELECT realtime_signing_key FROM app_secrets WHERE app_id = $1") + .bind(app_id.into_inner()) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(|r| r.0)) + } +} diff --git a/crates/manager-core/src/authz.rs b/crates/manager-core/src/authz.rs index 64615c4..b7844d2 100644 --- a/crates/manager-core/src/authz.rs +++ b/crates/manager-core/src/authz.rs @@ -97,6 +97,12 @@ pub enum Capability { /// to `app:admin` on API keys. Public-HTTP scripts (principal None) /// fail this check — managing dead letters is an admin act. AppDeadLetterManage(AppId), + /// Register / list / update / delete externally-subscribable topics + /// for this app (v1.1.6). Maps to `app:admin` on API keys — + /// externalizing a topic is an app-configuration act with security + /// weight (it opens an internal pub/sub topic to outside SSE + /// subscribers). Granted to `app_admin`+. + AppTopicManage(AppId), } impl Capability { @@ -123,7 +129,8 @@ impl Capability { | Self::AppFilesWrite(id) | Self::AppPubsubPublish(id) | Self::AppManageTriggers(id) - | Self::AppDeadLetterManage(id) => Some(id), + | Self::AppDeadLetterManage(id) + | Self::AppTopicManage(id) => Some(id), } } @@ -150,9 +157,10 @@ impl Capability { | Self::AppPubsubPublish(_) => Scope::ScriptWrite, Self::AppWriteRoute(_) => Scope::RouteWrite, Self::AppManageDomains(_) => Scope::DomainManage, - Self::AppAdmin(_) | Self::AppManageTriggers(_) | Self::AppDeadLetterManage(_) => { - Scope::AppAdmin - } + Self::AppAdmin(_) + | Self::AppManageTriggers(_) + | Self::AppDeadLetterManage(_) + | Self::AppTopicManage(_) => Scope::AppAdmin, Self::AppLogRead(_) => Scope::LogRead, } } @@ -316,6 +324,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool { | Capability::AppAdmin(_) | Capability::AppManageTriggers(_) | Capability::AppDeadLetterManage(_) + | Capability::AppTopicManage(_) ); match role { AppRole::Viewer => in_viewer, @@ -659,6 +668,35 @@ mod tests { ); } + #[tokio::test] + async fn topic_manage_requires_app_admin() { + let repo = InMemoryAuthzRepo::default(); + let app = AppId::new(); + // Maps to the app:admin scope, not a new one. + assert_eq!( + Capability::AppTopicManage(app).required_scope(), + Scope::AppAdmin + ); + + // Member with only Editor role cannot manage topics. + let p = principal(InstanceRole::Member); + repo.grant(p.user_id, app, AppRole::Editor).await; + assert_eq!( + can(&repo, &p, Capability::AppTopicManage(app)) + .await + .unwrap(), + Decision::Deny, + ); + + // App-admin role can. + let admin = principal(InstanceRole::Member); + repo.grant(admin.user_id, app, AppRole::AppAdmin).await; + assert!(can(&repo, &admin, Capability::AppTopicManage(app)) + .await + .unwrap() + .is_allow()); + } + #[test] fn capability_app_id_extraction() { let app = AppId::new(); diff --git a/crates/manager-core/src/files_service.rs b/crates/manager-core/src/files_service.rs index 2d60da6..443f0bd 100644 --- a/crates/manager-core/src/files_service.rs +++ b/crates/manager-core/src/files_service.rs @@ -633,20 +633,36 @@ mod tests { .await .unwrap_err(); assert!(matches!(err, FilesError::MissingField("content_type"))); - // data - let err = files + // Empty `data` is NO LONGER rejected (v1.1.6 relaxation) — see + // `empty_file_round_trips`. + } + + #[tokio::test] + async fn empty_file_round_trips() { + // v1.1.6: a zero-byte blob is a valid stored state (sentinels, + // placeholders). Create with empty data, then read it back. + let files = svc(); + let cx = anon_cx(AppId::new()); + let id = files .create( &cx, "c", NewFile { - name: "f".into(), - content_type: "text/plain".into(), + name: "empty.bin".into(), + content_type: "application/octet-stream".into(), data: vec![], }, ) .await - .unwrap_err(); - assert!(matches!(err, FilesError::MissingField("data"))); + .expect("empty file create should succeed"); + let bytes = files.get(&cx, "c", &id.to_string()).await.unwrap(); + assert_eq!(bytes, Some(Vec::new())); + let meta = files + .head(&cx, "c", &id.to_string()) + .await + .unwrap() + .expect("metadata present"); + assert_eq!(meta.size, 0); } #[tokio::test] diff --git a/crates/manager-core/src/files_sweep.rs b/crates/manager-core/src/files_sweep.rs new file mode 100644 index 0000000..2ccb77e --- /dev/null +++ b/crates/manager-core/src/files_sweep.rs @@ -0,0 +1,185 @@ +//! Orphan `*.tmp.*` blob sweeper (v1.1.6, v1.1.5 follow-up). +//! +//! The files repo writes blobs atomically: it streams into a +//! `.tmp.-` temp file, fsyncs, then renames to the final +//! `` path. A crash between create and rename leaves an orphan temp +//! file that is never read and never reclaimed. This sweeper deletes +//! those: every `PICLOUD_FILES_ORPHAN_SWEEP_INTERVAL_SEC` (default 6h) it +//! walks `/files/` and unlinks any `*.tmp.*` file older than +//! `PICLOUD_FILES_ORPHAN_TMP_TTL_SEC` (default 1h). +//! +//! Deliberately bounded: it does NOT cross-check on-disk files against DB +//! rows (the full reconciling sweeper is v1.3+). It only targets the temp +//! files, which are unambiguously orphans once past the TTL — no live +//! writer keeps one around for an hour. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +const ENV_INTERVAL: &str = "PICLOUD_FILES_ORPHAN_SWEEP_INTERVAL_SEC"; +const ENV_TMP_TTL: &str = "PICLOUD_FILES_ORPHAN_TMP_TTL_SEC"; +const DEFAULT_INTERVAL_SECS: u64 = 21_600; // 6h +const DEFAULT_TMP_TTL_SECS: u64 = 3_600; // 1h + +/// Marker that identifies a temp blob (`.tmp.-`). A final +/// blob is named just `` (a UUID), so it never contains this. +const TMP_MARKER: &str = ".tmp."; + +#[derive(Debug, Default, Clone, Copy)] +pub struct SweepStats { + pub dirs_walked: u64, + pub files_deleted: u64, + pub bytes_reclaimed: u64, +} + +/// Spawn the periodic orphan sweep. Spawned at startup alongside the +/// cron scheduler and the realtime/cache GC tasks. +pub fn spawn_files_orphan_sweep(files_root: PathBuf) { + let interval = Duration::from_secs(read_secs(ENV_INTERVAL, DEFAULT_INTERVAL_SECS)); + let ttl = Duration::from_secs(read_secs(ENV_TMP_TTL, DEFAULT_TMP_TTL_SECS)); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + ticker.tick().await; // skip the immediate first fire + loop { + ticker.tick().await; + let root = files_root.clone(); + // Blocking filesystem walk off the async worker. + let stats = tokio::task::spawn_blocking(move || sweep_orphan_tmp_files(&root, ttl)) + .await + .unwrap_or_default(); + tracing::info!( + dirs_walked = stats.dirs_walked, + files_deleted = stats.files_deleted, + bytes_reclaimed = stats.bytes_reclaimed, + "files orphan sweep complete" + ); + } + }); +} + +/// Walk `/files/` and delete `*.tmp.*` files older than +/// `ttl`. Missing root is not an error (returns zeroed stats). Pure + +/// synchronous so it's unit-testable without a runtime. +#[must_use] +pub fn sweep_orphan_tmp_files(files_root: &Path, ttl: Duration) -> SweepStats { + let mut stats = SweepStats::default(); + let blobs_dir = files_root.join("files"); + if !blobs_dir.is_dir() { + return stats; + } + let now = SystemTime::now(); + walk(&blobs_dir, ttl, now, &mut stats); + stats +} + +fn walk(dir: &Path, ttl: Duration, now: SystemTime, stats: &mut SweepStats) { + stats.dirs_walked += 1; + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let Ok(ft) = entry.file_type() else { + continue; + }; + let path = entry.path(); + if ft.is_dir() { + walk(&path, ttl, now, stats); + continue; + } + if !ft.is_file() { + continue; + } + if !entry.file_name().to_string_lossy().contains(TMP_MARKER) { + continue; + } + let Ok(meta) = entry.metadata() else { + continue; + }; + let age = meta + .modified() + .ok() + .and_then(|m| now.duration_since(m).ok()) + .unwrap_or(Duration::ZERO); + if age >= ttl { + let size = meta.len(); + if std::fs::remove_file(&path).is_ok() { + stats.files_deleted += 1; + stats.bytes_reclaimed += size; + } + } + } +} + +fn read_secs(key: &str, default: u64) -> u64 { + match std::env::var(key) { + Err(_) => default, + Ok(v) => match v.parse::() { + Ok(n) if n > 0 => n, + _ => { + tracing::warn!(env = key, value = %v, "invalid; using default"); + default + } + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static SEQ: AtomicU64 = AtomicU64::new(0); + + fn tmp_root() -> PathBuf { + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let dir = + std::env::temp_dir().join(format!("picloud-sweep-test-{}-{n}", std::process::id())); + std::fs::create_dir_all(dir.join("files").join("ab")).unwrap(); + dir + } + + fn touch(path: &Path) { + std::fs::write(path, b"x").unwrap(); + } + + #[test] + fn deletes_old_tmp_files() { + let root = tmp_root(); + let tmp = root.join("files/ab/uuid.tmp.123-0"); + touch(&tmp); + // ttl 0 → any tmp file counts as old. + let stats = sweep_orphan_tmp_files(&root, Duration::ZERO); + assert_eq!(stats.files_deleted, 1); + assert!(!tmp.exists()); + assert!(stats.bytes_reclaimed >= 1); + } + + #[test] + fn keeps_young_tmp_files() { + let root = tmp_root(); + let tmp = root.join("files/ab/uuid.tmp.123-0"); + touch(&tmp); + // Large TTL → the just-created file is too young to reap. + let stats = sweep_orphan_tmp_files(&root, Duration::from_secs(3600)); + assert_eq!(stats.files_deleted, 0); + assert!(tmp.exists()); + } + + #[test] + fn keeps_non_tmp_files() { + let root = tmp_root(); + let blob = root.join("files/ab/0123456789abcdef"); + touch(&blob); + let stats = sweep_orphan_tmp_files(&root, Duration::ZERO); + assert_eq!(stats.files_deleted, 0); + assert!(blob.exists()); + } + + #[test] + fn missing_root_does_not_panic() { + let root = std::env::temp_dir().join("picloud-sweep-nonexistent-xyz"); + let stats = sweep_orphan_tmp_files(&root, Duration::ZERO); + assert_eq!(stats.files_deleted, 0); + assert_eq!(stats.dirs_walked, 0); + } +} diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index b39bf20..561594d 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -16,6 +16,7 @@ pub mod app_domain_repo; pub mod app_members_api; pub mod app_members_repo; pub mod app_repo; +pub mod app_secrets_repo; pub mod apps_api; pub mod auth; pub mod auth_api; @@ -33,6 +34,7 @@ pub mod docs_service; pub mod files_api; pub mod files_repo; pub mod files_service; +pub mod files_sweep; pub mod gc; pub mod http_service; pub mod kv_repo; @@ -45,12 +47,15 @@ pub mod outbox_repo; pub mod principal_resolver; pub mod pubsub_repo; pub mod pubsub_service; +pub mod realtime_authority; pub mod repo; pub mod route_admin; pub mod route_repo; pub mod sandbox; pub mod scheduler; pub mod ssrf; +pub mod topic_repo; +pub mod topics_api; pub mod trigger_config; pub mod trigger_repo; pub mod triggers_api; @@ -81,6 +86,9 @@ pub use app_members_repo::{ PostgresAppMembersRepository, }; pub use app_repo::{resolve_app, AppLookup, AppRepository, PostgresAppRepository}; +pub use app_secrets_repo::{ + AppSecretsRepo, AppSecretsRepoError, PostgresAppSecretsRepo, SIGNING_KEY_LEN, +}; pub use apps_api::{apps_router, AppsState}; pub use auth_api::auth_router; pub use auth_bootstrap::{ @@ -104,6 +112,7 @@ pub use docs_service::DocsServiceImpl; pub use files_api::{files_admin_router, FilesAdminState}; pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo}; pub use files_service::FilesServiceImpl; +pub use files_sweep::{spawn_files_orphan_sweep, sweep_orphan_tmp_files, SweepStats}; pub use gc::{spawn_abandoned_gc, spawn_dead_letter_gc}; pub use http_service::{HttpConfig, HttpServiceImpl}; pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo}; @@ -116,7 +125,8 @@ pub use outbox_repo::{ }; pub use principal_resolver::{AdminPrincipalResolver, PrincipalResolver, PrincipalResolverError}; pub use pubsub_repo::{PostgresPubsubRepo, PublishCtx, PubsubRepo, PubsubRepoError}; -pub use pubsub_service::PubsubServiceImpl; +pub use pubsub_service::{PubsubServiceImpl, SubscriberTokenConfig}; +pub use realtime_authority::RealtimeAuthorityImpl; pub use repo::{ ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository, RepoResolver, ScriptPatch, ScriptRepository, ScriptRepositoryError, @@ -124,6 +134,8 @@ pub use repo::{ pub use route_admin::{compile_routes, route_admin_router, RouteAdminState}; pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository}; pub use sandbox::{CeilingError, SandboxCeiling}; +pub use topic_repo::{PostgresTopicRepo, Topic, TopicAuthMode, TopicRepo, TopicRepoError}; +pub use topics_api::{topics_router, TopicsApiError, TopicsState}; pub use trigger_config::{BackoffShape, TriggerConfig}; pub use trigger_repo::{ collection_matches, CreateDeadLetterTrigger, CreateDocsTrigger, CreateFilesTrigger, diff --git a/crates/manager-core/src/pubsub_service.rs b/crates/manager-core/src/pubsub_service.rs index 71a45d4..1190ea8 100644 --- a/crates/manager-core/src/pubsub_service.rs +++ b/crates/manager-core/src/pubsub_service.rs @@ -11,20 +11,106 @@ use std::sync::Arc; use async_trait::async_trait; -use picloud_shared::{PubsubError, PubsubService, SdkCallCx, TriggerEvent}; +use picloud_shared::subscriber_token::{self, TokenClaims}; +use picloud_shared::{ + PubsubError, PubsubService, RealtimeBroadcaster, RealtimeEvent, SdkCallCx, TriggerEvent, +}; +use crate::app_secrets_repo::AppSecretsRepo; use crate::authz::{self, AuthzRepo, Capability}; use crate::pubsub_repo::{PublishCtx, PubsubRepo, PubsubRepoError}; +use crate::topic_repo::TopicRepo; + +/// TTL bounds (seconds) for `pubsub::subscriber_token`. Env-overridable +/// via `PICLOUD_SUBSCRIBER_TOKEN_TTL_{MIN,MAX,DEFAULT}_SEC`. +#[derive(Debug, Clone, Copy)] +pub struct SubscriberTokenConfig { + pub min_ttl: i64, + pub max_ttl: i64, + pub default_ttl: i64, +} + +impl SubscriberTokenConfig { + #[must_use] + pub const fn conservative() -> Self { + Self { + min_ttl: 10, + max_ttl: 86_400, + default_ttl: 3_600, + } + } + + /// Load from env, falling back to the conservative defaults for any + /// missing / invalid value. + #[must_use] + pub fn from_env() -> Self { + let mut c = Self::conservative(); + load_i64(&mut c.min_ttl, "PICLOUD_SUBSCRIBER_TOKEN_TTL_MIN_SEC"); + load_i64(&mut c.max_ttl, "PICLOUD_SUBSCRIBER_TOKEN_TTL_MAX_SEC"); + load_i64( + &mut c.default_ttl, + "PICLOUD_SUBSCRIBER_TOKEN_TTL_DEFAULT_SEC", + ); + c + } +} + +impl Default for SubscriberTokenConfig { + fn default() -> Self { + Self::conservative() + } +} + +fn load_i64(dst: &mut i64, key: &str) { + if let Ok(v) = std::env::var(key) { + match v.parse::() { + Ok(n) if n > 0 => *dst = n, + _ => tracing::warn!(env = key, value = %v, "ignoring invalid token-ttl value"), + } + } +} pub struct PubsubServiceImpl { repo: Arc, authz: Arc, + // Realtime extras (v1.1.6) — optional so the existing two-arg + // constructor (and its unit tests) keep working without them. The + // production binary attaches them via `with_realtime`. + realtime: Option>, + topics: Option>, + secrets: Option>, + token_config: SubscriberTokenConfig, } impl PubsubServiceImpl { #[must_use] pub fn new(repo: Arc, authz: Arc) -> Self { - Self { repo, authz } + Self { + repo, + authz, + realtime: None, + topics: None, + secrets: None, + token_config: SubscriberTokenConfig::conservative(), + } + } + + /// Attach the v1.1.6 realtime surface: the in-process broadcaster + /// (publish fan-out to SSE subscribers), the topic registry + + /// app-secrets repo (subscriber-token minting), and the TTL config. + #[must_use] + pub fn with_realtime( + mut self, + broadcaster: Arc, + topics: Arc, + secrets: Arc, + token_config: SubscriberTokenConfig, + ) -> Self { + self.realtime = Some(broadcaster); + self.topics = Some(topics); + self.secrets = Some(secrets); + self.token_config = token_config; + self } async fn check_publish(&self, cx: &SdkCallCx) -> Result<(), PubsubError> { @@ -60,12 +146,15 @@ impl PubsubService for PubsubServiceImpl { } self.check_publish(cx).await?; - // `published_at` is stamped on the manager side so every - // delivery agrees on one instant. + // `published_at` is stamped once on the manager side so every + // delivery path — durable triggers AND the realtime broadcast — + // agrees on one instant. The message is cloned into the trigger + // event so the realtime path can reuse the original. + let published_at = chrono::Utc::now(); let event = TriggerEvent::Pubsub { topic: topic.to_string(), - message, - published_at: chrono::Utc::now(), + message: message.clone(), + published_at, }; let payload = serde_json::to_value(&event) .map_err(|e| PubsubError::Rejected(format!("event serialize: {e}")))?; @@ -76,12 +165,115 @@ impl PubsubService for PubsubServiceImpl { trigger_depth: cx.trigger_depth, root_execution_id: cx.root_execution_id, }; + // Order (design notes §8): transactional outbox fan-out + commit + // FIRST; only then the best-effort realtime broadcast. If the + // fan-out fails, the publish throws and no broadcast happens. If + // the broadcast fails after a committed fan-out, trigger + // deliveries still happen and only SSE subscribers miss this + // event (no replay in v1.1.6). + // // No matching triggers → 0 rows written, publish still succeeds. self.repo .fan_out_publish(publish_ctx, topic, payload) .await?; + + // Non-transactional, best-effort fan-out to in-process SSE + // subscribers. Run on a child task so a panicking broadcaster + // (or a future cluster-mode resolver fault) becomes a warn log, + // never a failed publish — the durable deliveries already + // committed above. + if let Some(realtime) = self.realtime.clone() { + let app_id = cx.app_id; + let topic_owned = topic.to_string(); + let realtime_event = RealtimeEvent { + topic: topic_owned.clone(), + message, + published_at, + }; + let handle = tokio::spawn(async move { + realtime.publish(app_id, &topic_owned, realtime_event).await; + }); + if let Err(e) = handle.await { + tracing::warn!(error = %e, "realtime broadcast failed; publish unaffected"); + } + } Ok(()) } + + async fn mint_subscriber_token( + &self, + cx: &SdkCallCx, + topics: Vec, + ttl_seconds: Option, + ) -> Result { + // Anonymous (public-HTTP) scripts can't mint — that would bypass + // the token-minting authz boundary. + let Some(principal) = cx.principal.as_ref() else { + return Err(PubsubError::SubscriberToken( + "pubsub::subscriber_token: requires an authenticated principal \ + (a script on a public route cannot mint tokens)" + .into(), + )); + }; + // Minting reuses the existing pub/sub publish capability (no new + // scope — the seven-scope commitment holds). + authz::require( + &*self.authz, + principal, + Capability::AppPubsubPublish(cx.app_id), + ) + .await + .map_err(|_| PubsubError::Forbidden)?; + + let (Some(topic_repo), Some(secrets)) = (self.topics.as_ref(), self.secrets.as_ref()) + else { + return Err(PubsubError::Unavailable( + "subscriber tokens are not wired in".into(), + )); + }; + + if topics.is_empty() { + return Err(PubsubError::SubscriberToken( + "pubsub::subscriber_token: topics list must not be empty".into(), + )); + } + + let ttl = ttl_seconds.unwrap_or(self.token_config.default_ttl); + if ttl < self.token_config.min_ttl || ttl > self.token_config.max_ttl { + return Err(PubsubError::SubscriberToken(format!( + "pubsub::subscriber_token: ttl_seconds must be between {} and {}", + self.token_config.min_ttl, self.token_config.max_ttl + ))); + } + + // Every requested topic must be registered as externally + // subscribable in this app — fail fast rather than mint a token + // that won't work. + for name in &topics { + let registered = topic_repo + .get(cx.app_id, name) + .await + .map_err(|e| PubsubError::Unavailable(e.to_string()))?; + if !registered.map(|t| t.external_subscribable).unwrap_or(false) { + return Err(PubsubError::SubscriberToken(format!( + "pubsub::subscriber_token: topic {name} is not externally subscribable" + ))); + } + } + + let key = secrets + .get_or_create_signing_key(cx.app_id) + .await + .map_err(|e| PubsubError::Unavailable(e.to_string()))?; + let now = chrono::Utc::now().timestamp(); + let claims = TokenClaims { + app_id: cx.app_id, + topics, + exp: now.saturating_add(ttl), + iat: now, + }; + Ok(subscriber_token::sign(&key, &claims)) + } } // ---------------------------------------------------------------------------- @@ -317,4 +509,218 @@ mod tests { .await .unwrap(); } + + // ------------------------------------------------------------------ + // v1.1.6 realtime broadcast + subscriber-token minting + // ------------------------------------------------------------------ + + use crate::app_secrets_repo::{AppSecretsRepo, AppSecretsRepoError}; + use crate::topic_repo::{Topic, TopicAuthMode, TopicRepo, TopicRepoError}; + use picloud_orchestrator_core::InProcessBroadcaster; + use picloud_shared::{RealtimeBroadcaster, RealtimeEvent}; + + /// Topic repo fake: returns the configured topics as registered + + /// externally-subscribable (unless absent). + struct FakeTopicRepo(Vec); + #[async_trait] + impl TopicRepo for FakeTopicRepo { + async fn create( + &self, + _: AppId, + _: &str, + _: bool, + _: TopicAuthMode, + ) -> Result { + unimplemented!() + } + async fn list(&self, _: AppId) -> Result, TopicRepoError> { + unimplemented!() + } + async fn get(&self, _: AppId, name: &str) -> Result, TopicRepoError> { + Ok(self.0.iter().any(|t| t == name).then(|| Topic { + name: name.to_string(), + external_subscribable: true, + auth_mode: TopicAuthMode::Token, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + })) + } + async fn update( + &self, + _: AppId, + _: &str, + _: Option, + _: Option, + ) -> Result, TopicRepoError> { + unimplemented!() + } + async fn delete(&self, _: AppId, _: &str) -> Result { + unimplemented!() + } + } + + #[derive(Default)] + struct FakeSecrets; + #[async_trait] + impl AppSecretsRepo for FakeSecrets { + async fn get_or_create_signing_key( + &self, + _: AppId, + ) -> Result, AppSecretsRepoError> { + Ok(vec![42u8; 32]) + } + async fn signing_key(&self, _: AppId) -> Result>, AppSecretsRepoError> { + Ok(Some(vec![42u8; 32])) + } + } + + /// Broadcaster that panics on publish — proves a broadcast fault + /// can't fail the publish. + struct PanicBroadcaster; + #[async_trait] + impl RealtimeBroadcaster for PanicBroadcaster { + async fn subscribe( + &self, + _: AppId, + _: &str, + ) -> Result, picloud_shared::BroadcasterError> + { + unimplemented!() + } + async fn publish(&self, _: AppId, _: &str, _: RealtimeEvent) { + panic!("boom"); + } + async fn drop_topic(&self, _: AppId, _: &str) {} + } + + fn realtime_svc( + repo: Arc, + broadcaster: Arc, + topics: Vec, + ) -> PubsubServiceImpl { + PubsubServiceImpl::new(repo, Arc::new(EditorAuthzRepo)).with_realtime( + broadcaster, + Arc::new(FakeTopicRepo(topics)), + Arc::new(FakeSecrets), + SubscriberTokenConfig::conservative(), + ) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn publish_broadcasts_to_in_process_subscribers() { + let app = AppId::new(); + let broadcaster = Arc::new(InProcessBroadcaster::new(16)); + let mut rx = broadcaster.subscribe(app, "chat").await.unwrap(); + let svc = realtime_svc( + Arc::new(InMemoryPubsubRepo::new(vec![])), + broadcaster, + vec![], + ); + svc.publish_durable(&anon_cx(app), "chat", serde_json::json!({ "hi": 1 })) + .await + .unwrap(); + let ev = rx.recv().await.unwrap(); + assert_eq!(ev.topic, "chat"); + assert_eq!(ev.message, serde_json::json!({ "hi": 1 })); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn panicking_broadcaster_does_not_fail_publish() { + let app = AppId::new(); + let svc = realtime_svc( + Arc::new(InMemoryPubsubRepo::new(vec![])), + Arc::new(PanicBroadcaster), + vec![], + ); + // The outbox fan-out committed; the broadcast panic is swallowed. + svc.publish_durable(&anon_cx(app), "chat", serde_json::json!(1)) + .await + .expect("publish must succeed despite broadcast panic"); + } + + fn mint_svc(topics: Vec) -> PubsubServiceImpl { + realtime_svc( + Arc::new(InMemoryPubsubRepo::new(vec![])), + Arc::new(picloud_shared::NoopRealtimeBroadcaster), + topics, + ) + } + + #[tokio::test] + async fn mint_returns_token_scoped_to_topics() { + let app = AppId::new(); + let svc = mint_svc(vec!["chat".into(), "notify".into()]); + let token = svc + .mint_subscriber_token( + &member_cx(app), + vec!["chat".into(), "notify".into()], + Some(120), + ) + .await + .unwrap(); + // Verify with the fake key; claims carry the topics + expiry. + let claims = subscriber_token::verify(&[42u8; 32], &token, chrono::Utc::now().timestamp()) + .expect("token verifies"); + assert_eq!(claims.app_id, app); + assert!(claims.allows_topic("chat") && claims.allows_topic("notify")); + assert!(claims.exp > claims.iat); + } + + #[tokio::test] + async fn mint_anonymous_principal_throws() { + let app = AppId::new(); + let svc = mint_svc(vec!["chat".into()]); + let err = svc + .mint_subscriber_token(&anon_cx(app), vec!["chat".into()], None) + .await + .unwrap_err(); + assert!(matches!(err, PubsubError::SubscriberToken(_))); + } + + #[tokio::test] + async fn mint_empty_topics_throws() { + let app = AppId::new(); + let svc = mint_svc(vec!["chat".into()]); + let err = svc + .mint_subscriber_token(&member_cx(app), vec![], None) + .await + .unwrap_err(); + assert!(matches!(err, PubsubError::SubscriberToken(_))); + } + + #[tokio::test] + async fn mint_ttl_below_min_and_above_max_throw() { + let app = AppId::new(); + let svc = mint_svc(vec!["chat".into()]); + for bad in [Some(5), Some(90_000)] { + let err = svc + .mint_subscriber_token(&member_cx(app), vec!["chat".into()], bad) + .await + .unwrap_err(); + assert!( + matches!(err, PubsubError::SubscriberToken(_)), + "ttl {bad:?}" + ); + } + } + + #[tokio::test] + async fn mint_unregistered_topic_throws_with_message() { + let app = AppId::new(); + // "chat" registered; "secret" is not. + let svc = mint_svc(vec!["chat".into()]); + let err = svc + .mint_subscriber_token(&member_cx(app), vec!["chat".into(), "secret".into()], None) + .await + .unwrap_err(); + match err { + PubsubError::SubscriberToken(msg) => { + assert!( + msg.contains("topic secret is not externally subscribable"), + "got: {msg}" + ); + } + other => panic!("expected SubscriberToken, got {other:?}"), + } + } } diff --git a/crates/manager-core/src/realtime_authority.rs b/crates/manager-core/src/realtime_authority.rs new file mode 100644 index 0000000..848e34e --- /dev/null +++ b/crates/manager-core/src/realtime_authority.rs @@ -0,0 +1,338 @@ +//! `RealtimeAuthorityImpl` — the DB-backed SSE subscribe gate (v1.1.6). +//! +//! Backs the [`picloud_shared::RealtimeAuthority`] trait the SSE handler +//! in orchestrator-core calls. All `topics`-table reads and signing-key +//! material stay inside this impl so the data-plane crate never touches +//! the key. +//! +//! Verdict mapping (see [`SubscribeDenied`]): +//! * topic missing OR not externally subscribable → `NotFound` (404). +//! Both collapse to 404 so the endpoint can't probe internal topics. +//! * `auth_mode = 'public'` → allow. +//! * `auth_mode = 'token'` → verify the HMAC token (present, signed by +//! this app's key, unexpired, scoped to this topic) → allow, else +//! `Unauthorized` (401, generic — never says which check failed). +//! +//! Signing keys never change in v1.1.6 (no rotation API), so a small +//! in-memory cache avoids a per-subscribe DB read once an app's key has +//! been seen. The cache is purely an optimization — a cold miss reads +//! the row. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use picloud_shared::{subscriber_token, AppId, RealtimeAuthority, SubscribeDenied}; + +use crate::app_secrets_repo::AppSecretsRepo; +use crate::topic_repo::{TopicAuthMode, TopicRepo}; + +pub struct RealtimeAuthorityImpl { + topics: Arc, + secrets: Arc, + key_cache: Mutex>>, +} + +impl RealtimeAuthorityImpl { + #[must_use] + pub fn new(topics: Arc, secrets: Arc) -> Self { + Self { + topics, + secrets, + key_cache: Mutex::new(HashMap::new()), + } + } + + /// Fetch the app's signing key, consulting the cache first. Returns + /// `None` when the app has no key (no token ever minted) — which the + /// caller maps to `Unauthorized`. + async fn signing_key(&self, app_id: AppId) -> Result>, SubscribeDenied> { + if let Ok(cache) = self.key_cache.lock() { + if let Some(k) = cache.get(&app_id) { + return Ok(Some(k.clone())); + } + } + let key = self + .secrets + .signing_key(app_id) + .await + .map_err(|e| SubscribeDenied::Backend(e.to_string()))?; + if let Some(ref k) = key { + if let Ok(mut cache) = self.key_cache.lock() { + cache.insert(app_id, k.clone()); + } + } + Ok(key) + } +} + +#[async_trait] +impl RealtimeAuthority for RealtimeAuthorityImpl { + async fn authorize_subscribe( + &self, + app_id: AppId, + topic: &str, + token: Option<&str>, + ) -> Result<(), SubscribeDenied> { + let registered = self + .topics + .get(app_id, topic) + .await + .map_err(|e| SubscribeDenied::Backend(e.to_string()))?; + + // Missing topic AND internal-only topic both 404 — don't leak + // which internal topics exist. + let Some(t) = registered.filter(|t| t.external_subscribable) else { + return Err(SubscribeDenied::NotFound); + }; + + match t.auth_mode { + TopicAuthMode::Public => Ok(()), + TopicAuthMode::Token => { + let token = token.ok_or(SubscribeDenied::Unauthorized)?; + let key = self + .signing_key(app_id) + .await? + .ok_or(SubscribeDenied::Unauthorized)?; + let now = chrono::Utc::now().timestamp(); + let claims = subscriber_token::verify(&key, token, now) + .map_err(|_| SubscribeDenied::Unauthorized)?; + // Per-app key already makes a cross-app token fail the + // signature check; this is belt-and-suspenders. + if claims.app_id != app_id || !claims.allows_topic(topic) { + return Err(SubscribeDenied::Unauthorized); + } + Ok(()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_secrets_repo::AppSecretsRepoError; + use crate::topic_repo::{Topic, TopicRepoError}; + use chrono::Utc; + use picloud_shared::subscriber_token::{sign, TokenClaims}; + + struct FakeTopics(Vec<(AppId, Topic)>); + #[async_trait] + impl TopicRepo for FakeTopics { + async fn create( + &self, + _: AppId, + _: &str, + _: bool, + _: TopicAuthMode, + ) -> Result { + unimplemented!() + } + async fn list(&self, _: AppId) -> Result, TopicRepoError> { + unimplemented!() + } + async fn get(&self, app_id: AppId, name: &str) -> Result, TopicRepoError> { + Ok(self + .0 + .iter() + .find(|(a, t)| *a == app_id && t.name == name) + .map(|(_, t)| t.clone())) + } + async fn update( + &self, + _: AppId, + _: &str, + _: Option, + _: Option, + ) -> Result, TopicRepoError> { + unimplemented!() + } + async fn delete(&self, _: AppId, _: &str) -> Result { + unimplemented!() + } + } + + struct FakeSecrets(AppId, Vec); + #[async_trait] + impl AppSecretsRepo for FakeSecrets { + async fn get_or_create_signing_key( + &self, + _: AppId, + ) -> Result, AppSecretsRepoError> { + Ok(self.1.clone()) + } + async fn signing_key(&self, app_id: AppId) -> Result>, AppSecretsRepoError> { + Ok((app_id == self.0).then(|| self.1.clone())) + } + } + + fn topic(name: &str, external: bool, mode: TopicAuthMode) -> Topic { + Topic { + name: name.to_string(), + external_subscribable: external, + auth_mode: mode, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + fn authority( + topics: Vec<(AppId, Topic)>, + key_app: AppId, + key: Vec, + ) -> RealtimeAuthorityImpl { + RealtimeAuthorityImpl::new( + Arc::new(FakeTopics(topics)), + Arc::new(FakeSecrets(key_app, key)), + ) + } + + #[tokio::test] + async fn missing_topic_is_not_found() { + let app = AppId::new(); + let a = authority(vec![], app, vec![0u8; 32]); + assert_eq!( + a.authorize_subscribe(app, "ghost", None).await, + Err(SubscribeDenied::NotFound) + ); + } + + #[tokio::test] + async fn internal_only_topic_is_not_found() { + let app = AppId::new(); + let a = authority( + vec![(app, topic("internal", false, TopicAuthMode::Public))], + app, + vec![0u8; 32], + ); + assert_eq!( + a.authorize_subscribe(app, "internal", None).await, + Err(SubscribeDenied::NotFound) + ); + } + + #[tokio::test] + async fn public_topic_allows_without_token() { + let app = AppId::new(); + let a = authority( + vec![(app, topic("news", true, TopicAuthMode::Public))], + app, + vec![0u8; 32], + ); + assert!(a.authorize_subscribe(app, "news", None).await.is_ok()); + } + + #[tokio::test] + async fn token_topic_without_token_is_unauthorized() { + let app = AppId::new(); + let a = authority( + vec![(app, topic("chat", true, TopicAuthMode::Token))], + app, + vec![7u8; 32], + ); + assert_eq!( + a.authorize_subscribe(app, "chat", None).await, + Err(SubscribeDenied::Unauthorized) + ); + } + + #[tokio::test] + async fn token_topic_with_valid_token_allows() { + let app = AppId::new(); + let key = vec![9u8; 32]; + let a = authority( + vec![(app, topic("chat", true, TopicAuthMode::Token))], + app, + key.clone(), + ); + let token = sign( + &key, + &TokenClaims { + app_id: app, + topics: vec!["chat".into()], + iat: Utc::now().timestamp(), + exp: Utc::now().timestamp() + 60, + }, + ); + assert!(a + .authorize_subscribe(app, "chat", Some(&token)) + .await + .is_ok()); + } + + #[tokio::test] + async fn token_for_other_topic_is_unauthorized() { + let app = AppId::new(); + let key = vec![9u8; 32]; + let a = authority( + vec![(app, topic("chat", true, TopicAuthMode::Token))], + app, + key.clone(), + ); + let token = sign( + &key, + &TokenClaims { + app_id: app, + topics: vec!["other".into()], + iat: Utc::now().timestamp(), + exp: Utc::now().timestamp() + 60, + }, + ); + assert_eq!( + a.authorize_subscribe(app, "chat", Some(&token)).await, + Err(SubscribeDenied::Unauthorized) + ); + } + + #[tokio::test] + async fn expired_token_is_unauthorized() { + let app = AppId::new(); + let key = vec![9u8; 32]; + let a = authority( + vec![(app, topic("chat", true, TopicAuthMode::Token))], + app, + key.clone(), + ); + let token = sign( + &key, + &TokenClaims { + app_id: app, + topics: vec!["chat".into()], + iat: Utc::now().timestamp() - 120, + exp: Utc::now().timestamp() - 60, + }, + ); + assert_eq!( + a.authorize_subscribe(app, "chat", Some(&token)).await, + Err(SubscribeDenied::Unauthorized) + ); + } + + #[tokio::test] + async fn token_signed_by_other_app_key_is_unauthorized() { + let app_a = AppId::new(); + let app_b = AppId::new(); + let key_a = vec![1u8; 32]; + let key_b = vec![2u8; 32]; + // Authority for app B; its key is key_b. + let a = authority( + vec![(app_b, topic("chat", true, TopicAuthMode::Token))], + app_b, + key_b, + ); + // Token signed by app A's key, claiming app A. + let token = sign( + &key_a, + &TokenClaims { + app_id: app_a, + topics: vec!["chat".into()], + iat: Utc::now().timestamp(), + exp: Utc::now().timestamp() + 60, + }, + ); + assert_eq!( + a.authorize_subscribe(app_b, "chat", Some(&token)).await, + Err(SubscribeDenied::Unauthorized) + ); + } +} diff --git a/crates/manager-core/src/topic_repo.rs b/crates/manager-core/src/topic_repo.rs new file mode 100644 index 0000000..207bf57 --- /dev/null +++ b/crates/manager-core/src/topic_repo.rs @@ -0,0 +1,212 @@ +//! `TopicRepo` — CRUD for the `topics` table (v1.1.6). +//! +//! This table holds ONLY topics that have been explicitly externalized +//! for SSE subscription (design notes §5). Internal-only pub/sub topics +//! stay implicit — they never get a row here, and the publish path never +//! consults this table. The two readers are the topic admin endpoints +//! ([`crate::topics_api`]) and the SSE subscribe authorization +//! ([`crate::realtime_authority`]). + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use picloud_shared::AppId; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +/// External-subscriber auth gate for a topic. `'public'` + `'token'` in +/// v1.1.6; `'session'` (v1.1.8) and `'script'` (v1.2) extend the DB +/// CHECK constraint and this enum later. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TopicAuthMode { + Public, + Token, +} + +impl TopicAuthMode { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Public => "public", + Self::Token => "token", + } + } + + fn from_db(s: &str) -> Result { + match s { + "public" => Ok(Self::Public), + "token" => Ok(Self::Token), + other => Err(TopicRepoError::Backend(format!( + "unknown auth_mode in DB: {other}" + ))), + } + } +} + +/// A registered, externally-subscribable topic row. +#[derive(Debug, Clone, Serialize)] +pub struct Topic { + pub name: String, + pub external_subscribable: bool, + pub auth_mode: TopicAuthMode, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, thiserror::Error)] +pub enum TopicRepoError { + #[error("a topic named {0:?} already exists in this app")] + AlreadyExists(String), + #[error("database error: {0}")] + Db(#[from] sqlx::Error), + #[error("topic backend error: {0}")] + Backend(String), +} + +#[async_trait] +pub trait TopicRepo: Send + Sync { + /// Register a topic. Errors `AlreadyExists` on PK conflict. + async fn create( + &self, + app_id: AppId, + name: &str, + external_subscribable: bool, + auth_mode: TopicAuthMode, + ) -> Result; + + /// List every registered topic in the app, ordered by name. + async fn list(&self, app_id: AppId) -> Result, TopicRepoError>; + + /// Fetch one topic by name, `None` if not registered. + async fn get(&self, app_id: AppId, name: &str) -> Result, TopicRepoError>; + + /// Update `external_subscribable` and/or `auth_mode` (each `None` + /// leaves the column unchanged). `None` return = no such topic. + async fn update( + &self, + app_id: AppId, + name: &str, + external_subscribable: Option, + auth_mode: Option, + ) -> Result, TopicRepoError>; + + /// Unregister a topic. Returns `true` if a row was removed. + async fn delete(&self, app_id: AppId, name: &str) -> Result; +} + +#[derive(sqlx::FromRow)] +struct TopicRow { + name: String, + external_subscribable: bool, + auth_mode: String, + created_at: DateTime, + updated_at: DateTime, +} + +impl TopicRow { + fn into_topic(self) -> Result { + Ok(Topic { + auth_mode: TopicAuthMode::from_db(&self.auth_mode)?, + name: self.name, + external_subscribable: self.external_subscribable, + created_at: self.created_at, + updated_at: self.updated_at, + }) + } +} + +const SELECT_COLS: &str = "name, external_subscribable, auth_mode, created_at, updated_at"; + +pub struct PostgresTopicRepo { + pool: PgPool, +} + +impl PostgresTopicRepo { + #[must_use] + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl TopicRepo for PostgresTopicRepo { + async fn create( + &self, + app_id: AppId, + name: &str, + external_subscribable: bool, + auth_mode: TopicAuthMode, + ) -> Result { + let row: Option = sqlx::query_as(&format!( + "INSERT INTO topics (app_id, name, external_subscribable, auth_mode) \ + VALUES ($1, $2, $3, $4) \ + ON CONFLICT (app_id, name) DO NOTHING \ + RETURNING {SELECT_COLS}" + )) + .bind(app_id.into_inner()) + .bind(name) + .bind(external_subscribable) + .bind(auth_mode.as_str()) + .fetch_optional(&self.pool) + .await?; + match row { + Some(r) => r.into_topic(), + None => Err(TopicRepoError::AlreadyExists(name.to_string())), + } + } + + async fn list(&self, app_id: AppId) -> Result, TopicRepoError> { + let rows: Vec = sqlx::query_as(&format!( + "SELECT {SELECT_COLS} FROM topics WHERE app_id = $1 ORDER BY name" + )) + .bind(app_id.into_inner()) + .fetch_all(&self.pool) + .await?; + rows.into_iter().map(TopicRow::into_topic).collect() + } + + async fn get(&self, app_id: AppId, name: &str) -> Result, TopicRepoError> { + let row: Option = sqlx::query_as(&format!( + "SELECT {SELECT_COLS} FROM topics WHERE app_id = $1 AND name = $2" + )) + .bind(app_id.into_inner()) + .bind(name) + .fetch_optional(&self.pool) + .await?; + row.map(TopicRow::into_topic).transpose() + } + + async fn update( + &self, + app_id: AppId, + name: &str, + external_subscribable: Option, + auth_mode: Option, + ) -> Result, TopicRepoError> { + // COALESCE leaves a column untouched when its bind is NULL. + let row: Option = sqlx::query_as(&format!( + "UPDATE topics SET \ + external_subscribable = COALESCE($3, external_subscribable), \ + auth_mode = COALESCE($4, auth_mode), \ + updated_at = NOW() \ + WHERE app_id = $1 AND name = $2 \ + RETURNING {SELECT_COLS}" + )) + .bind(app_id.into_inner()) + .bind(name) + .bind(external_subscribable) + .bind(auth_mode.map(|m| m.as_str())) + .fetch_optional(&self.pool) + .await?; + row.map(TopicRow::into_topic).transpose() + } + + async fn delete(&self, app_id: AppId, name: &str) -> Result { + let res = sqlx::query("DELETE FROM topics WHERE app_id = $1 AND name = $2") + .bind(app_id.into_inner()) + .bind(name) + .execute(&self.pool) + .await?; + Ok(res.rows_affected() > 0) + } +} diff --git a/crates/manager-core/src/topics_api.rs b/crates/manager-core/src/topics_api.rs new file mode 100644 index 0000000..2d5bc53 --- /dev/null +++ b/crates/manager-core/src/topics_api.rs @@ -0,0 +1,629 @@ +//! `/api/v1/admin/apps/{id}/topics*` — topic registration admin +//! endpoints (v1.1.6). +//! +//! These manage the `topics` table: the explicit registry of which +//! pub/sub topics are externally subscribable over SSE (design notes +//! §5). Internal-only topics never appear here. +//! +//! * `POST /apps/{id}/topics` — register a topic. +//! * `GET /apps/{id}/topics` — list registered topics. +//! * `PATCH /apps/{id}/topics/{name}` — flip external/auth_mode. +//! * `DELETE /apps/{id}/topics/{name}` — unregister + disconnect. +//! +//! The PATCH endpoint is deliberately its OWN surface (not folded into a +//! generic topic update) so every change to externally-subscribable +//! status is a discrete, watchable/auditable API call (§5 commitment). +//! +//! Create / update / delete are gated by `AppTopicManage` (→ `app:admin` +//! scope); list is gated by the existing `AppRead`. DELETE also drops +//! the topic's in-process broadcast channel so live SSE subscribers +//! disconnect cleanly. + +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Json, Response}; +use axum::routing::{get, patch}; +use axum::{Extension, Router}; +use picloud_shared::{AppId, Principal, RealtimeBroadcaster}; +use serde::Deserialize; +use serde_json::json; + +use crate::app_repo::AppRepository; +use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability}; +use crate::topic_repo::{Topic, TopicAuthMode, TopicRepo, TopicRepoError}; + +#[derive(Clone)] +pub struct TopicsState { + pub topics: Arc, + pub apps: Arc, + pub authz: Arc, + pub broadcaster: Arc, +} + +pub fn topics_router(state: TopicsState) -> Router { + Router::new() + .route("/apps/{app_id}/topics", get(list_topics).post(create_topic)) + .route( + "/apps/{app_id}/topics/{name}", + patch(update_topic).delete(delete_topic), + ) + .with_state(state) +} + +#[derive(Debug, Deserialize)] +pub struct CreateTopicRequest { + pub name: String, + #[serde(default)] + pub external_subscribable: bool, + #[serde(default = "default_auth_mode")] + pub auth_mode: TopicAuthMode, +} + +const fn default_auth_mode() -> TopicAuthMode { + TopicAuthMode::Public +} + +#[derive(Debug, Deserialize)] +pub struct UpdateTopicRequest { + #[serde(default)] + pub external_subscribable: Option, + #[serde(default)] + pub auth_mode: Option, +} + +/// Topic names are concrete (external pattern subscription is v1.2), so +/// reject empties and `*` wildcards at registration. +fn validate_topic_name(name: &str) -> Result<(), TopicsApiError> { + if name.trim().is_empty() { + return Err(TopicsApiError::Invalid( + "topic name must not be empty".into(), + )); + } + if name.contains('*') { + return Err(TopicsApiError::Invalid( + "topic name must be a concrete topic, not a pattern (no '*')".into(), + )); + } + Ok(()) +} + +async fn create_topic( + State(s): State, + Extension(principal): Extension, + Path(app_id): Path, + Json(input): Json, +) -> Result<(StatusCode, Json), TopicsApiError> { + ensure_app_exists(&*s.apps, app_id).await?; + require( + s.authz.as_ref(), + &principal, + Capability::AppTopicManage(app_id), + ) + .await?; + validate_topic_name(&input.name)?; + let topic = s + .topics + .create( + app_id, + input.name.trim(), + input.external_subscribable, + input.auth_mode, + ) + .await?; + Ok((StatusCode::CREATED, Json(topic))) +} + +#[derive(Debug, serde::Serialize)] +struct ListTopicsResponse { + topics: Vec, +} + +async fn list_topics( + State(s): State, + Extension(principal): Extension, + Path(app_id): Path, +) -> Result, TopicsApiError> { + ensure_app_exists(&*s.apps, app_id).await?; + require(s.authz.as_ref(), &principal, Capability::AppRead(app_id)).await?; + let topics = s.topics.list(app_id).await?; + Ok(Json(ListTopicsResponse { topics })) +} + +async fn update_topic( + State(s): State, + Extension(principal): Extension, + Path((app_id, name)): Path<(AppId, String)>, + Json(input): Json, +) -> Result, TopicsApiError> { + ensure_app_exists(&*s.apps, app_id).await?; + require( + s.authz.as_ref(), + &principal, + Capability::AppTopicManage(app_id), + ) + .await?; + let topic = s + .topics + .update(app_id, &name, input.external_subscribable, input.auth_mode) + .await? + .ok_or(TopicsApiError::NotFound)?; + Ok(Json(topic)) +} + +async fn delete_topic( + State(s): State, + Extension(principal): Extension, + Path((app_id, name)): Path<(AppId, String)>, +) -> Result { + ensure_app_exists(&*s.apps, app_id).await?; + require( + s.authz.as_ref(), + &principal, + Capability::AppTopicManage(app_id), + ) + .await?; + if !s.topics.delete(app_id, &name).await? { + return Err(TopicsApiError::NotFound); + } + // Disconnect any live SSE subscribers for the now-unregistered topic. + s.broadcaster.drop_topic(app_id, &name).await; + Ok(StatusCode::NO_CONTENT) +} + +async fn ensure_app_exists(apps: &dyn AppRepository, app_id: AppId) -> Result<(), TopicsApiError> { + apps.get_by_id(app_id) + .await + .map_err(|e| TopicsApiError::Backend(e.to_string()))? + .ok_or(TopicsApiError::AppNotFound)?; + Ok(()) +} + +#[derive(Debug, thiserror::Error)] +pub enum TopicsApiError { + #[error("app not found")] + AppNotFound, + #[error("topic not found")] + NotFound, + #[error("{0}")] + AlreadyExists(String), + #[error("invalid request: {0}")] + Invalid(String), + #[error("forbidden")] + Forbidden, + #[error("authorization repo error: {0}")] + AuthzRepo(String), + #[error("topics backend: {0}")] + Backend(String), +} + +impl From for TopicsApiError { + fn from(d: AuthzDenied) -> Self { + match d { + AuthzDenied::Denied => Self::Forbidden, + AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), + } + } +} + +impl From for TopicsApiError { + fn from(e: AuthzError) -> Self { + Self::AuthzRepo(e.to_string()) + } +} + +impl From for TopicsApiError { + fn from(e: TopicRepoError) -> Self { + match e { + TopicRepoError::AlreadyExists(name) => { + Self::AlreadyExists(format!("a topic named {name:?} already exists in this app")) + } + other => Self::Backend(other.to_string()), + } + } +} + +impl IntoResponse for TopicsApiError { + fn into_response(self) -> Response { + let (status, body) = match &self { + Self::AppNotFound | Self::NotFound => { + (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })) + } + Self::AlreadyExists(_) => (StatusCode::CONFLICT, json!({ "error": self.to_string() })), + Self::Invalid(_) => ( + StatusCode::UNPROCESSABLE_ENTITY, + json!({ "error": self.to_string() }), + ), + Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })), + Self::AuthzRepo(e) => { + tracing::error!(error = %e, "topics admin authz repo error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": "internal error" }), + ) + } + Self::Backend(e) => { + tracing::error!(error = %e, "topics admin backend error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": "internal error" }), + ) + } + }; + (status, Json(body)).into_response() + } +} + +#[cfg(test)] +mod tests { + //! In-memory handler tests: capability enforcement, the + //! `external_subscribable` default, the flip being its own endpoint, + //! cross-app isolation, and DELETE disconnecting subscribers. The + //! Postgres repo is exercised by the schema + integration suites. + + use super::*; + use crate::repo::ScriptRepositoryError; + use async_trait::async_trait; + use chrono::Utc; + use picloud_shared::{ + AdminUserId, App, AppRole, BroadcasterError, InstanceRole, RealtimeEvent, UserId, + }; + use std::collections::HashMap; + use std::sync::Mutex as StdMutex; + use tokio::sync::Mutex; + + #[derive(Default)] + struct InMemoryTopicRepo { + inner: Mutex>, + } + + #[async_trait] + impl TopicRepo for InMemoryTopicRepo { + async fn create( + &self, + app_id: AppId, + name: &str, + external_subscribable: bool, + auth_mode: TopicAuthMode, + ) -> Result { + let mut g = self.inner.lock().await; + if g.contains_key(&(app_id, name.to_string())) { + return Err(TopicRepoError::AlreadyExists(name.to_string())); + } + let now = Utc::now(); + let t = Topic { + name: name.to_string(), + external_subscribable, + auth_mode, + created_at: now, + updated_at: now, + }; + g.insert((app_id, name.to_string()), t.clone()); + Ok(t) + } + async fn list(&self, app_id: AppId) -> Result, TopicRepoError> { + let g = self.inner.lock().await; + let mut v: Vec = g + .iter() + .filter(|((a, _), _)| *a == app_id) + .map(|(_, t)| t.clone()) + .collect(); + v.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(v) + } + async fn get(&self, app_id: AppId, name: &str) -> Result, TopicRepoError> { + Ok(self + .inner + .lock() + .await + .get(&(app_id, name.to_string())) + .cloned()) + } + async fn update( + &self, + app_id: AppId, + name: &str, + external_subscribable: Option, + auth_mode: Option, + ) -> Result, TopicRepoError> { + let mut g = self.inner.lock().await; + let Some(t) = g.get_mut(&(app_id, name.to_string())) else { + return Ok(None); + }; + if let Some(e) = external_subscribable { + t.external_subscribable = e; + } + if let Some(m) = auth_mode { + t.auth_mode = m; + } + t.updated_at = Utc::now(); + Ok(Some(t.clone())) + } + async fn delete(&self, app_id: AppId, name: &str) -> Result { + Ok(self + .inner + .lock() + .await + .remove(&(app_id, name.to_string())) + .is_some()) + } + } + + struct InMemoryAppRepo(AppId); + #[async_trait] + impl AppRepository for InMemoryAppRepo { + async fn list(&self) -> Result, ScriptRepositoryError> { + unimplemented!() + } + async fn list_for_user(&self, _: AdminUserId) -> Result, ScriptRepositoryError> { + unimplemented!() + } + async fn get_by_id(&self, id: AppId) -> Result, ScriptRepositoryError> { + if id != self.0 { + return Ok(None); + } + let now = Utc::now(); + Ok(Some(App { + id, + slug: "test".into(), + name: "test".into(), + description: None, + created_at: now, + updated_at: now, + })) + } + async fn get_by_slug(&self, _: &str) -> Result, ScriptRepositoryError> { + unimplemented!() + } + async fn get_by_slug_or_history( + &self, + _: &str, + ) -> Result, ScriptRepositoryError> { + unimplemented!() + } + async fn slug_in_history(&self, _: &str) -> Result, ScriptRepositoryError> { + unimplemented!() + } + async fn create( + &self, + _: &str, + _: &str, + _: Option<&str>, + ) -> Result { + unimplemented!() + } + async fn create_with_takeover( + &self, + _: &str, + _: &str, + _: Option<&str>, + ) -> Result { + unimplemented!() + } + async fn update( + &self, + _: AppId, + _: Option<&str>, + _: Option>, + ) -> Result { + unimplemented!() + } + async fn rename_slug( + &self, + _: AppId, + _: &str, + _: bool, + ) -> Result { + unimplemented!() + } + async fn delete(&self, _: AppId) -> Result<(), ScriptRepositoryError> { + unimplemented!() + } + async fn delete_cascade(&self, _: AppId) -> Result<(), ScriptRepositoryError> { + unimplemented!() + } + async fn count_scripts_in_app(&self, _: AppId) -> Result { + unimplemented!() + } + } + + /// Grants `AppAdmin` only for `granted_app`; denies elsewhere — used + /// for the cross-app isolation test. + struct PerAppAuthzRepo { + granted_app: AppId, + } + #[async_trait] + impl AuthzRepo for PerAppAuthzRepo { + async fn membership( + &self, + _: UserId, + app_id: AppId, + ) -> Result, AuthzError> { + Ok((app_id == self.granted_app).then_some(AppRole::AppAdmin)) + } + } + + struct DenyAuthzRepo; + #[async_trait] + impl AuthzRepo for DenyAuthzRepo { + async fn membership(&self, _: UserId, _: AppId) -> Result, AuthzError> { + Ok(None) + } + } + + #[derive(Default)] + struct RecordingBroadcaster { + dropped: StdMutex>, + } + #[async_trait] + impl RealtimeBroadcaster for RecordingBroadcaster { + async fn subscribe( + &self, + _: AppId, + _: &str, + ) -> Result, BroadcasterError> { + unimplemented!() + } + async fn publish(&self, _: AppId, _: &str, _: RealtimeEvent) {} + async fn drop_topic(&self, app_id: AppId, topic: &str) { + self.dropped + .lock() + .unwrap() + .push((app_id, topic.to_string())); + } + } + + fn member() -> Principal { + Principal { + user_id: AdminUserId::new(), + instance_role: InstanceRole::Member, + scopes: None, + app_binding: None, + } + } + + fn state(app_id: AppId, authz: Arc) -> (TopicsState, Arc) { + let bc = Arc::new(RecordingBroadcaster::default()); + let state = TopicsState { + topics: Arc::new(InMemoryTopicRepo::default()), + apps: Arc::new(InMemoryAppRepo(app_id)), + authz, + broadcaster: bc.clone(), + }; + (state, bc) + } + + #[tokio::test] + async fn register_defaults_external_subscribable_false() { + let app = AppId::new(); + let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app })); + let (status, Json(topic)) = create_topic( + State(s), + Extension(member()), + Path(app), + Json(CreateTopicRequest { + name: "chat".into(), + external_subscribable: false, + auth_mode: TopicAuthMode::Public, + }), + ) + .await + .unwrap(); + assert_eq!(status, StatusCode::CREATED); + assert!(!topic.external_subscribable); + assert_eq!(topic.name, "chat"); + } + + #[tokio::test] + async fn flip_requires_app_admin_role() { + let app = AppId::new(); + // Topic exists; the caller has no role → PATCH is forbidden. + let (s, _) = state(app, Arc::new(DenyAuthzRepo)); + s.topics + .create(app, "chat", false, TopicAuthMode::Public) + .await + .unwrap(); + let err = update_topic( + State(s), + Extension(member()), + Path((app, "chat".to_string())), + Json(UpdateTopicRequest { + external_subscribable: Some(true), + auth_mode: None, + }), + ) + .await + .unwrap_err(); + assert!(matches!(err, TopicsApiError::Forbidden)); + } + + #[tokio::test] + async fn flip_is_its_own_endpoint_and_toggles_external() { + // The PATCH handler is a distinct surface from create; flipping + // external_subscribable false→true is a single discrete call. + let app = AppId::new(); + let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app })); + s.topics + .create(app, "chat", false, TopicAuthMode::Public) + .await + .unwrap(); + let Json(updated) = update_topic( + State(s), + Extension(member()), + Path((app, "chat".to_string())), + Json(UpdateTopicRequest { + external_subscribable: Some(true), + auth_mode: Some(TopicAuthMode::Token), + }), + ) + .await + .unwrap(); + assert!(updated.external_subscribable); + assert_eq!(updated.auth_mode, TopicAuthMode::Token); + } + + #[tokio::test] + async fn delete_disconnects_subscribers() { + let app = AppId::new(); + let (s, bc) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app })); + s.topics + .create(app, "chat", true, TopicAuthMode::Public) + .await + .unwrap(); + let status = delete_topic( + State(s), + Extension(member()), + Path((app, "chat".to_string())), + ) + .await + .unwrap(); + assert_eq!(status, StatusCode::NO_CONTENT); + assert_eq!( + bc.dropped.lock().unwrap().as_slice(), + &[(app, "chat".to_string())] + ); + } + + #[tokio::test] + async fn cross_app_admin_cannot_manage_other_app() { + let app_a = AppId::new(); + let app_b = AppId::new(); + // Caller is admin of app A only; both apps exist via separate state. + let authz = Arc::new(PerAppAuthzRepo { granted_app: app_a }); + // App-B-scoped state, but the caller only has A's grant. + let (s, _) = state(app_b, authz); + let err = create_topic( + State(s), + Extension(member()), + Path(app_b), + Json(CreateTopicRequest { + name: "chat".into(), + external_subscribable: true, + auth_mode: TopicAuthMode::Public, + }), + ) + .await + .unwrap_err(); + assert!(matches!(err, TopicsApiError::Forbidden)); + } + + #[tokio::test] + async fn pattern_name_rejected() { + let app = AppId::new(); + let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app })); + let err = create_topic( + State(s), + Extension(member()), + Path(app), + Json(CreateTopicRequest { + name: "user.*".into(), + external_subscribable: true, + auth_mode: TopicAuthMode::Public, + }), + ) + .await + .unwrap_err(); + assert!(matches!(err, TopicsApiError::Invalid(_))); + } +} diff --git a/crates/manager-core/tests/expected_schema.txt b/crates/manager-core/tests/expected_schema.txt index 7eaab9c..91b2591 100644 --- a/crates/manager-core/tests/expected_schema.txt +++ b/crates/manager-core/tests/expected_schema.txt @@ -58,6 +58,12 @@ table: app_members role: text NOT NULL created_at: timestamp with time zone NOT NULL default=now() +table: app_secrets + app_id: uuid NOT NULL + realtime_signing_key: bytea NOT NULL + created_at: timestamp with time zone NOT NULL default=now() + updated_at: timestamp with time zone NOT NULL default=now() + table: app_slug_history slug: text NOT NULL current_app_id: uuid NOT NULL @@ -211,6 +217,14 @@ table: scripts app_id: uuid NOT NULL kind: text NOT NULL default='endpoint'::text +table: topics + app_id: uuid NOT NULL + name: text NOT NULL + external_subscribable: boolean NOT NULL default=false + auth_mode: text NOT NULL default='public'::text + created_at: timestamp with time zone NOT NULL default=now() + updated_at: timestamp with time zone NOT NULL default=now() + table: triggers id: uuid NOT NULL default=gen_random_uuid() app_id: uuid NOT NULL @@ -256,6 +270,9 @@ indexes on app_members: app_members_pkey: public.app_members USING btree (app_id, user_id) app_members_user_id_idx: public.app_members USING btree (user_id) +indexes on app_secrets: + app_secrets_pkey: public.app_secrets USING btree (app_id) + indexes on app_slug_history: app_slug_history_pkey: public.app_slug_history USING btree (slug) @@ -328,6 +345,9 @@ indexes on scripts: scripts_name_uidx: public.scripts USING btree (app_id, lower(name)) scripts_pkey: public.scripts USING btree (id) +indexes on topics: + topics_pkey: public.topics USING btree (app_id, name) + indexes on triggers: idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE (enabled = true) idx_triggers_app_pubsub_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (kind = 'pubsub'::text)) @@ -366,6 +386,10 @@ constraints on app_members: [FOREIGN KEY] app_members_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE [PRIMARY KEY] app_members_pkey: PRIMARY KEY (app_id, user_id) +constraints on app_secrets: + [FOREIGN KEY] app_secrets_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE + [PRIMARY KEY] app_secrets_pkey: PRIMARY KEY (app_id) + constraints on app_slug_history: [FOREIGN KEY] app_slug_history_current_app_id_fkey: FOREIGN KEY (current_app_id) REFERENCES apps(id) ON DELETE CASCADE [PRIMARY KEY] app_slug_history_pkey: PRIMARY KEY (slug) @@ -448,6 +472,11 @@ constraints on scripts: [FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT [PRIMARY KEY] scripts_pkey: PRIMARY KEY (id) +constraints on topics: + [CHECK] topics_auth_mode_check: CHECK ((auth_mode = ANY (ARRAY['public'::text, 'token'::text]))) + [FOREIGN KEY] topics_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE + [PRIMARY KEY] topics_pkey: PRIMARY KEY (app_id, name) + constraints on triggers: [CHECK] triggers_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text]))) [CHECK] triggers_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'dead_letter'::text, 'docs'::text, 'cron'::text, 'files'::text, 'pubsub'::text]))) @@ -478,3 +507,5 @@ constraints on triggers: 0018: files 0019: files triggers 0020: pubsub triggers + 0021: topics + 0022: app secrets diff --git a/crates/orchestrator-core/Cargo.toml b/crates/orchestrator-core/Cargo.toml index d76e821..a4e924f 100644 --- a/crates/orchestrator-core/Cargo.toml +++ b/crates/orchestrator-core/Cargo.toml @@ -23,8 +23,13 @@ chrono.workspace = true reqwest.workspace = true rhai.workspace = true tokio.workspace = true +tokio-stream.workspace = true urlencoding.workspace = true # v1.1.3 — top-level script AST cache lives in orchestrator-core's # LocalExecutorClient; key is ScriptId, value is `(updated_at, Arc)`. lru.workspace = true + +[dev-dependencies] +# `ServiceExt::oneshot` for driving the SSE router in unit tests. +tower.workspace = true diff --git a/crates/orchestrator-core/src/lib.rs b/crates/orchestrator-core/src/lib.rs index 3cefa3a..3e2ce49 100644 --- a/crates/orchestrator-core/src/lib.rs +++ b/crates/orchestrator-core/src/lib.rs @@ -12,6 +12,8 @@ pub mod api; pub mod client; pub mod gate; pub mod inbox; +pub mod realtime; +pub mod realtime_api; pub mod resolver; pub mod routing; @@ -19,4 +21,8 @@ pub use api::{data_plane_router, user_routes_router, DataPlaneState}; pub use client::{ExecutorClient, LocalExecutorClient, RemoteExecutorClient, ScriptIdentity}; pub use gate::{AcquireError, ExecutionGate}; pub use inbox::InboxRegistry; +pub use realtime::{spawn_realtime_gc, InProcessBroadcaster, DEFAULT_BROADCAST_CAPACITY}; +pub use realtime_api::{ + heartbeat_secs_from_env, realtime_router, RealtimeState, DEFAULT_HEARTBEAT_SECS, +}; pub use resolver::{ResolverError, ScriptResolver}; diff --git a/crates/orchestrator-core/src/realtime.rs b/crates/orchestrator-core/src/realtime.rs new file mode 100644 index 0000000..2b6f4f0 --- /dev/null +++ b/crates/orchestrator-core/src/realtime.rs @@ -0,0 +1,242 @@ +//! In-process `RealtimeBroadcaster` — the SSE fan-out registry (v1.1.6). +//! +//! Sibling of [`crate::inbox::InboxRegistry`], but multi-receiver and +//! repeated-event: a `Mutex>` +//! over `tokio::sync::broadcast` instead of a oneshot map. The publish +//! side ([`PubsubServiceImpl`]) and the SSE subscribe side both hold one +//! shared `Arc`. +//! +//! Delivery is best-effort: each channel has a bounded buffer +//! (`PICLOUD_REALTIME_BROADCAST_CAPACITY`, default 64); a slow consumer +//! that falls behind sees the oldest events dropped (standard +//! `broadcast` lag semantics — the receiver gets `RecvError::Lagged`). +//! SSE's transport-layer auto-reconnect is the recovery path; there's no +//! server-side replay in v1.1.6. +//! +//! Channels are created lazily on first subscribe. A periodic GC task +//! ([`spawn_realtime_gc`]) drops senders whose receiver count has fallen +//! to zero so one-shot subscribers don't grow the map unboundedly. +//! +//! Cluster mode (v1.3+) swaps this for a Postgres `LISTEN/NOTIFY`-backed +//! resolver behind the same `RealtimeBroadcaster` trait. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use picloud_shared::{AppId, BroadcasterError, RealtimeBroadcaster, RealtimeEvent}; +use tokio::sync::broadcast; + +/// Default per-channel broadcast buffer depth. +pub const DEFAULT_BROADCAST_CAPACITY: usize = 64; +const ENV_CAPACITY: &str = "PICLOUD_REALTIME_BROADCAST_CAPACITY"; + +/// Default GC sweep interval for empty channels. +pub const DEFAULT_GC_INTERVAL_SECS: u64 = 60; + +pub struct InProcessBroadcaster { + inner: Mutex>>, + capacity: usize, +} + +impl InProcessBroadcaster { + #[must_use] + pub fn new(capacity: usize) -> Self { + Self { + inner: Mutex::new(HashMap::new()), + capacity: capacity.max(1), + } + } + + /// Build from `PICLOUD_REALTIME_BROADCAST_CAPACITY` (default 64). + #[must_use] + pub fn from_env() -> Self { + let capacity = match std::env::var(ENV_CAPACITY) { + Err(_) => DEFAULT_BROADCAST_CAPACITY, + Ok(v) => match v.parse::() { + Ok(n) if n > 0 => n, + Ok(_) => { + tracing::warn!(env = ENV_CAPACITY, value = %v, "must be > 0; using default"); + DEFAULT_BROADCAST_CAPACITY + } + Err(e) => { + tracing::warn!(env = ENV_CAPACITY, value = %v, error = %e, "invalid; using default"); + DEFAULT_BROADCAST_CAPACITY + } + }, + }; + Self::new(capacity) + } + + /// Number of live channels in the map (test/observability helper). + #[must_use] + pub fn channel_count(&self) -> usize { + self.inner.lock().map(|g| g.len()).unwrap_or(0) + } + + /// Drop senders with zero receivers. Returns how many were removed. + /// Called periodically by [`spawn_realtime_gc`]. + pub fn gc(&self) -> usize { + let Ok(mut g) = self.inner.lock() else { + return 0; + }; + let before = g.len(); + g.retain(|_, tx| tx.receiver_count() > 0); + before - g.len() + } +} + +#[async_trait] +impl RealtimeBroadcaster for InProcessBroadcaster { + async fn subscribe( + &self, + app_id: AppId, + topic: &str, + ) -> Result, BroadcasterError> { + let mut g = self + .inner + .lock() + .map_err(|_| BroadcasterError::Unavailable("broadcaster map poisoned".into()))?; + let tx = g + .entry((app_id, topic.to_string())) + .or_insert_with(|| broadcast::channel(self.capacity).0); + Ok(tx.subscribe()) + } + + async fn publish(&self, app_id: AppId, topic: &str, event: RealtimeEvent) { + let Ok(g) = self.inner.lock() else { + return; + }; + // Only fan out to an existing channel: a topic with no live + // subscribers has no sender (publish never creates one). `send` + // returns Err iff every receiver has dropped — a benign no-op. + if let Some(tx) = g.get(&(app_id, topic.to_string())) { + let _ = tx.send(event); + } + } + + async fn drop_topic(&self, app_id: AppId, topic: &str) { + if let Ok(mut g) = self.inner.lock() { + // Removing the sender closes the channel; existing receivers + // observe `RecvError::Closed` and disconnect cleanly. + g.remove(&(app_id, topic.to_string())); + } + } +} + +/// Spawn the background GC sweep that drops empty channels every +/// `interval_secs` (default [`DEFAULT_GC_INTERVAL_SECS`]). Spawned at +/// startup alongside the other housekeeping tasks. +pub fn spawn_realtime_gc(broadcaster: Arc, interval_secs: u64) { + let period = Duration::from_secs(interval_secs.max(1)); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(period); + ticker.tick().await; // skip the immediate first fire + loop { + ticker.tick().await; + let removed = broadcaster.gc(); + if removed > 0 { + tracing::debug!(removed, "realtime broadcaster GC dropped empty channels"); + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use serde_json::json; + + fn event(topic: &str, n: i64) -> RealtimeEvent { + RealtimeEvent { + topic: topic.to_string(), + message: json!({ "n": n }), + published_at: Utc::now(), + } + } + + #[tokio::test] + async fn multiple_subscribers_each_receive_each_event() { + let b = InProcessBroadcaster::new(16); + let app = AppId::new(); + let mut rx1 = b.subscribe(app, "chat").await.unwrap(); + let mut rx2 = b.subscribe(app, "chat").await.unwrap(); + + b.publish(app, "chat", event("chat", 1)).await; + b.publish(app, "chat", event("chat", 2)).await; + + for rx in [&mut rx1, &mut rx2] { + assert_eq!(rx.recv().await.unwrap().message, json!({ "n": 1 })); + assert_eq!(rx.recv().await.unwrap().message, json!({ "n": 2 })); + } + } + + #[tokio::test] + async fn dropped_subscriber_does_not_leak_after_gc() { + let b = InProcessBroadcaster::new(16); + let app = AppId::new(); + let rx = b.subscribe(app, "t").await.unwrap(); + assert_eq!(b.channel_count(), 1); + drop(rx); + // GC reclaims the now-empty channel. + assert_eq!(b.gc(), 1); + assert_eq!(b.channel_count(), 0); + } + + #[tokio::test] + async fn drop_topic_disconnects_existing_subscribers() { + let b = InProcessBroadcaster::new(16); + let app = AppId::new(); + let mut rx = b.subscribe(app, "t").await.unwrap(); + b.drop_topic(app, "t").await; + // Sender gone → receiver observes a closed channel. + assert!(rx.recv().await.is_err()); + assert_eq!(b.channel_count(), 0); + } + + #[tokio::test] + async fn slow_consumer_loses_oldest_events() { + // Capacity 2: a consumer that never drains sees the oldest + // events dropped (broadcast Lagged semantics). + let b = InProcessBroadcaster::new(2); + let app = AppId::new(); + let mut rx = b.subscribe(app, "t").await.unwrap(); + for i in 0..5 { + b.publish(app, "t", event("t", i)).await; + } + // First recv reports the lag rather than event 0. + let first = rx.recv().await; + assert!( + matches!(first, Err(broadcast::error::RecvError::Lagged(_))), + "expected Lagged, got {first:?}" + ); + // Subsequent recvs return the most recent buffered events. + let next = rx.recv().await.unwrap(); + assert_eq!(next.message, json!({ "n": 3 })); + } + + #[tokio::test] + async fn cross_app_isolation() { + let b = InProcessBroadcaster::new(16); + let app_a = AppId::new(); + let app_b = AppId::new(); + let mut rx_a = b.subscribe(app_a, "shared").await.unwrap(); + let mut rx_b = b.subscribe(app_b, "shared").await.unwrap(); + + b.publish(app_a, "shared", event("shared", 1)).await; + // App B's subscriber must not see app A's publish. + assert_eq!(rx_a.recv().await.unwrap().message, json!({ "n": 1 })); + assert!(rx_b.try_recv().is_err()); + } + + #[tokio::test] + async fn publish_with_no_subscribers_is_noop() { + let b = InProcessBroadcaster::new(16); + let app = AppId::new(); + // No subscriber → no sender created → no panic, nothing fanned out. + b.publish(app, "ghost", event("ghost", 1)).await; + assert_eq!(b.channel_count(), 0); + } +} diff --git a/crates/orchestrator-core/src/realtime_api.rs b/crates/orchestrator-core/src/realtime_api.rs new file mode 100644 index 0000000..5d68839 --- /dev/null +++ b/crates/orchestrator-core/src/realtime_api.rs @@ -0,0 +1,408 @@ +//! SSE realtime endpoint — `GET /realtime/topics/{topic}` (v1.1.6). +//! +//! This is a data-plane surface, deliberately NOT under `/api/` +//! (realtime is its own versioning surface per the path scheme). It is +//! merged at the router root by the `picloud` binary alongside +//! `/healthz`, `/version`, and the user-route fallback. +//! +//! Handshake: +//! 1. Resolve `Host` → `app_id` (two-phase dispatch). No app → 404. +//! 2. Extract the token from `Authorization: Bearer ` OR `?token=` +//! (EventSource can't set custom headers, so the query form is the +//! browser-compatible path). +//! 3. Ask the injected [`RealtimeAuthority`]: missing/internal topic → +//! 404, bad/absent token on a token-gated topic → 401, otherwise OK. +//! 4. Acquire a `broadcast::Receiver` and stream events as SSE until +//! the client disconnects (dropping the receiver — the broadcaster +//! cleans up on its own). +//! +//! Heartbeats (`:` comment lines) keep idle proxies from closing the +//! connection; interval is `PICLOUD_REALTIME_HEARTBEAT_SEC` (default 30). + +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::sse::{Event, KeepAlive, Sse}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum::Router; +use picloud_shared::{RealtimeAuthority, RealtimeBroadcaster, SubscribeDenied}; +use serde::Deserialize; +use tokio_stream::wrappers::BroadcastStream; +use tokio_stream::{Stream, StreamExt}; + +use crate::routing::AppDomainTable; + +/// Default heartbeat interval (seconds) for idle SSE connections. +pub const DEFAULT_HEARTBEAT_SECS: u64 = 30; +const ENV_HEARTBEAT: &str = "PICLOUD_REALTIME_HEARTBEAT_SEC"; + +#[derive(Clone)] +pub struct RealtimeState { + /// Host → app_id resolver (shared with the rest of the data plane). + pub app_domains: Arc, + pub broadcaster: Arc, + pub authority: Arc, + pub heartbeat: Duration, +} + +impl RealtimeState { + #[must_use] + pub fn new( + app_domains: Arc, + broadcaster: Arc, + authority: Arc, + ) -> Self { + Self { + app_domains, + broadcaster, + authority, + heartbeat: Duration::from_secs(heartbeat_secs_from_env()), + } + } +} + +/// Read `PICLOUD_REALTIME_HEARTBEAT_SEC` (default 30, must be > 0). +#[must_use] +pub fn heartbeat_secs_from_env() -> u64 { + match std::env::var(ENV_HEARTBEAT) { + Err(_) => DEFAULT_HEARTBEAT_SECS, + Ok(v) => match v.parse::() { + Ok(n) if n > 0 => n, + _ => { + tracing::warn!(env = ENV_HEARTBEAT, value = %v, "invalid; using default"); + DEFAULT_HEARTBEAT_SECS + } + }, + } +} + +/// Router for the realtime SSE surface. Merged at the router root. +#[must_use] +pub fn realtime_router(state: RealtimeState) -> Router { + Router::new() + .route("/realtime/topics/{topic}", get(sse_topic)) + .with_state(state) +} + +#[derive(Debug, Deserialize)] +struct TokenQuery { + token: Option, +} + +async fn sse_topic( + State(state): State, + Path(topic): Path, + Query(q): Query, + headers: HeaderMap, +) -> Response { + // 1. Host → app. + let host = headers + .get("host") + .and_then(|h| h.to_str().ok()) + .unwrap_or(""); + let Some(app_id) = state.app_domains.resolve_app(host) else { + return not_found("no app claims this host"); + }; + + // 2. Token: Authorization: Bearer takes precedence, else ?token=. + let token = bearer_token(&headers).or(q.token); + + // 3. Authorize. + match state + .authority + .authorize_subscribe(app_id, &topic, token.as_deref()) + .await + { + Ok(()) => {} + Err(SubscribeDenied::NotFound) => return not_found("topic not found"), + Err(SubscribeDenied::Unauthorized) => return unauthorized(), + Err(SubscribeDenied::Backend(e)) => { + tracing::error!(error = %e, "realtime authority backend error"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(serde_json::json!({ "error": "internal error" })), + ) + .into_response(); + } + } + + // 4. Subscribe + stream. + let rx = match state.broadcaster.subscribe(app_id, &topic).await { + Ok(rx) => rx, + Err(e) => { + tracing::error!(error = %e, "failed to acquire realtime subscription"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(serde_json::json!({ "error": "internal error" })), + ) + .into_response(); + } + }; + + let stream = event_stream(rx); + let sse = + Sse::new(stream).keep_alive(KeepAlive::new().interval(state.heartbeat).text("heartbeat")); + + // Sse sets Content-Type: text/event-stream + Cache-Control: no-cache. + // Add X-Accel-Buffering: no so an intermediate nginx doesn't buffer + // the stream (ignored by other proxies). Connection management is + // hyper's concern (and is hop-by-hop on HTTP/1.1, server-managed on + // HTTP/2), so we don't set Connection ourselves. + let mut resp = sse.into_response(); + resp.headers_mut().insert( + "X-Accel-Buffering", + axum::http::HeaderValue::from_static("no"), + ); + resp +} + +/// Map the broadcast receiver into a stream of SSE events. Lagged +/// notifications (slow consumer) are skipped; a closed channel +/// (`drop_topic`, or all senders gone) ends the stream and the SSE +/// connection closes cleanly. +fn event_stream( + rx: tokio::sync::broadcast::Receiver, +) -> impl Stream> { + BroadcastStream::new(rx).filter_map(|item| { + let ev = item.ok()?; // drop Lagged errors + let payload = serde_json::json!({ + "topic": ev.topic, + "message": ev.message, + "published_at": ev.published_at.to_rfc3339(), + }); + Some(Ok(Event::default().data(payload.to_string()))) + }) +} + +fn bearer_token(headers: &HeaderMap) -> Option { + let raw = headers + .get(axum::http::header::AUTHORIZATION)? + .to_str() + .ok()?; + raw.strip_prefix("Bearer ") + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) +} + +fn not_found(msg: &str) -> Response { + ( + StatusCode::NOT_FOUND, + axum::Json(serde_json::json!({ "error": msg })), + ) + .into_response() +} + +fn unauthorized() -> Response { + // Generic — never leaks which check failed. + ( + StatusCode::UNAUTHORIZED, + axum::Json(serde_json::json!({ "error": "unauthorized" })), + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::realtime::InProcessBroadcaster; + use crate::routing::AppDomainTable; + use async_trait::async_trait; + use axum::body::Body; + use axum::http::Request; + use picloud_shared::{AppId, RealtimeEvent}; + use tower::ServiceExt; // oneshot + + /// Authority stub returning a fixed verdict. + struct StubAuthority(Result<(), SubscribeDenied>); + #[async_trait] + impl RealtimeAuthority for StubAuthority { + async fn authorize_subscribe( + &self, + _: AppId, + _: &str, + _: Option<&str>, + ) -> Result<(), SubscribeDenied> { + self.0.clone() + } + } + + /// App-domain table that maps a fixed host to a fixed app. + fn domains(host: &str, app: AppId) -> Arc { + use crate::routing::{parse_app_domain, CompiledAppDomain}; + let d = parse_app_domain(host).unwrap(); + let table = AppDomainTable::new(); + table.replace(vec![CompiledAppDomain { + app_id: app, + pattern: d.pattern, + shape_key: d.shape_key, + }]); + Arc::new(table) + } + + fn state( + app: AppId, + host: &str, + verdict: Result<(), SubscribeDenied>, + broadcaster: Arc, + ) -> RealtimeState { + RealtimeState { + app_domains: domains(host, app), + broadcaster, + authority: Arc::new(StubAuthority(verdict)), + heartbeat: Duration::from_millis(100), + } + } + + async fn get_status(state: RealtimeState, host: &str, topic: &str) -> StatusCode { + let app = realtime_router(state); + let req = Request::builder() + .uri(format!("/realtime/topics/{topic}")) + .header("host", host) + .body(Body::empty()) + .unwrap(); + app.oneshot(req).await.unwrap().status() + } + + #[tokio::test] + async fn unknown_host_is_404() { + let app = AppId::new(); + let st = state( + app, + "app.example.com", + Ok(()), + Arc::new(InProcessBroadcaster::new(8)), + ); + // Request a different host → no app claims it. + assert_eq!( + get_status(st, "other.example.com", "chat").await, + StatusCode::NOT_FOUND + ); + } + + #[tokio::test] + async fn not_found_topic_is_404() { + let app = AppId::new(); + let st = state( + app, + "app.example.com", + Err(SubscribeDenied::NotFound), + Arc::new(InProcessBroadcaster::new(8)), + ); + assert_eq!( + get_status(st, "app.example.com", "ghost").await, + StatusCode::NOT_FOUND + ); + } + + #[tokio::test] + async fn unauthorized_token_is_401() { + let app = AppId::new(); + let st = state( + app, + "app.example.com", + Err(SubscribeDenied::Unauthorized), + Arc::new(InProcessBroadcaster::new(8)), + ); + assert_eq!( + get_status(st, "app.example.com", "chat").await, + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn public_topic_returns_event_stream() { + let app = AppId::new(); + let st = state( + app, + "app.example.com", + Ok(()), + Arc::new(InProcessBroadcaster::new(8)), + ); + let appr = realtime_router(st); + let req = Request::builder() + .uri("/realtime/topics/chat") + .header("host", "app.example.com") + .body(Body::empty()) + .unwrap(); + let resp = appr.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let ct = resp + .headers() + .get(axum::http::header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap(); + assert!(ct.starts_with("text/event-stream")); + assert_eq!(resp.headers().get("x-accel-buffering").unwrap(), "no"); + } + + #[tokio::test] + async fn subscribe_receives_published_event() { + let app = AppId::new(); + let broadcaster = Arc::new(InProcessBroadcaster::new(8)); + let st = state(app, "app.example.com", Ok(()), broadcaster.clone()); + let appr = realtime_router(st); + let req = Request::builder() + .uri("/realtime/topics/chat") + .header("host", "app.example.com") + .body(Body::empty()) + .unwrap(); + let resp = appr.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // The handler has subscribed; publish and read the first chunk. + // Give the streaming task a beat to register its receiver. + let mut body = resp.into_body().into_data_stream(); + tokio::time::sleep(Duration::from_millis(50)).await; + broadcaster + .publish( + app, + "chat", + RealtimeEvent { + topic: "chat".into(), + message: serde_json::json!({ "hi": 1 }), + published_at: chrono::Utc::now(), + }, + ) + .await; + + let chunk = tokio::time::timeout(Duration::from_secs(2), body.next()) + .await + .expect("a chunk within timeout") + .expect("stream item") + .expect("chunk ok"); + let text = String::from_utf8_lossy(&chunk); + assert!(text.contains("data:"), "got: {text}"); + assert!(text.contains("\"hi\":1"), "got: {text}"); + } + + #[tokio::test] + async fn heartbeat_fires_on_idle_connection() { + let app = AppId::new(); + let broadcaster = Arc::new(InProcessBroadcaster::new(8)); + // Hold a clone so the channel's sender outlives the router (which + // oneshot consumes) — otherwise the stream closes immediately. + let _keepalive = broadcaster.clone(); + let st = state(app, "app.example.com", Ok(()), broadcaster); + let appr = realtime_router(st); + let req = Request::builder() + .uri("/realtime/topics/chat") + .header("host", "app.example.com") + .body(Body::empty()) + .unwrap(); + let resp = appr.oneshot(req).await.unwrap(); + let mut body = resp.into_body().into_data_stream(); + // No publish — with a 100ms heartbeat, a keep-alive comment must + // arrive well within a second. + let chunk = tokio::time::timeout(Duration::from_secs(1), body.next()) + .await + .expect("heartbeat within timeout") + .expect("stream item") + .expect("chunk ok"); + let text = String::from_utf8_lossy(&chunk); + assert!(text.starts_with(':'), "expected SSE comment, got: {text}"); + } +} diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 8e05ff4..1adb9a3 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -12,29 +12,33 @@ use picloud_executor_core::{Engine, Limits}; use picloud_manager_core::{ admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router, - files_admin_router, migrations, require_authenticated, route_admin_router, triggers_router, - AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository, - AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, - AppMembersState, AppRepository, AppsState, AuthState, AuthzRepo, DeadLetterRepo, - DeadLettersState, Dispatcher, DocsServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, - FsFilesRepo, HttpConfig, HttpServiceImpl, KvServiceImpl, OutboxEventEmitter, OutboxRepo, - PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, + files_admin_router, migrations, require_authenticated, route_admin_router, topics_router, + triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, + AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, + AppMembersRepository, AppMembersState, AppRepository, AppsState, AuthState, AuthzRepo, + DeadLetterRepo, DeadLettersState, Dispatcher, DocsServiceImpl, FilesAdminState, FilesConfig, + FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl, KvServiceImpl, OutboxEventEmitter, + OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, - PostgresAppRepository, PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo, - PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, - PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresTriggerRepo, - PrincipalResolver, PubsubServiceImpl, RepoResolver, RouteAdminState, RouteRepository, - SandboxCeiling, ScriptRepository, TriggerConfig, TriggerRepo, TriggersState, + PostgresAppRepository, PostgresAppSecretsRepo, PostgresDeadLetterRepo, + PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository, + PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, + PostgresRouteRepository, PostgresScriptRepository, PostgresTopicRepo, PostgresTriggerRepo, + PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState, + RouteRepository, SandboxCeiling, ScriptRepository, SubscriberTokenConfig, TopicRepo, + TopicsState, TriggerConfig, TriggerRepo, TriggersState, }; +use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; use picloud_orchestrator_core::{ - data_plane_router, user_routes_router, DataPlaneState, ExecutionGate, InboxRegistry, - LocalExecutorClient, + data_plane_router, realtime_router, spawn_realtime_gc, user_routes_router, DataPlaneState, + ExecutionGate, InProcessBroadcaster, InboxRegistry, LocalExecutorClient, RealtimeState, }; use picloud_shared::{ DeadLetterService, DocsService, ExecutionLogSink, FilesService, HttpService, InboxResolver, - KvService, OutboxWriter, PubsubService, ScriptValidator, ServiceEventEmitter, Services, - API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION, + KvService, OutboxWriter, PubsubService, RealtimeAuthority, RealtimeBroadcaster, + ScriptValidator, ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION, SDK_VERSION, + WIRE_VERSION, }; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; @@ -162,6 +166,8 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { // the bytes live on disk under `PICLOUD_FILES_ROOT` (default ./data). let files_config = FilesConfig::from_env(); let files_max_size = files_config.max_file_size_bytes; + // Kept for the v1.1.6 orphan sweeper (cleans stale `*.tmp.*` files). + let files_root = files_config.root.clone(); let files_repo = Arc::new(FsFilesRepo::new(pool.clone(), files_config)); let files: Arc = Arc::new(FilesServiceImpl::new( files_repo.clone(), @@ -169,12 +175,34 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { events.clone(), files_max_size, )); - // v1.1.5 durable pub/sub. Publishes fan out to matching pubsub - // triggers at publish time (one outbox row each), delivered by the - // same dispatcher as every other async trigger. + // v1.1.6 realtime: the in-process broadcaster is shared between the + // publish path (PubsubServiceImpl fans out to SSE subscribers after + // the durable outbox fan-out) and the SSE endpoint (subscribe side). + // The topic registry + app-secrets repo back the subscriber-token + // mint + SSE subscribe-authorization. + let broadcaster_concrete = Arc::new(InProcessBroadcaster::from_env()); + let broadcaster: Arc = broadcaster_concrete.clone(); + let topic_repo: Arc = Arc::new(PostgresTopicRepo::new(pool.clone())); + let app_secrets_repo = Arc::new(PostgresAppSecretsRepo::new(pool.clone())); + let realtime_authority: Arc = Arc::new(RealtimeAuthorityImpl::new( + topic_repo.clone(), + app_secrets_repo.clone(), + )); + + // v1.1.5 durable pub/sub, extended in v1.1.6 with the realtime + // broadcast + subscriber-token mint. Publishes fan out to matching + // pubsub triggers at publish time (one outbox row each, delivered by + // the same dispatcher as every other async trigger) AND, best-effort, + // to in-process SSE subscribers. let pubsub_repo = Arc::new(PostgresPubsubRepo::new(pool.clone())); - let pubsub: Arc = - Arc::new(PubsubServiceImpl::new(pubsub_repo, authz.clone())); + let pubsub: Arc = Arc::new( + PubsubServiceImpl::new(pubsub_repo, authz.clone()).with_realtime( + broadcaster.clone(), + topic_repo.clone(), + app_secrets_repo, + SubscriberTokenConfig::from_env(), + ), + ); let services = Services::new( kv, docs, @@ -284,6 +312,10 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { // enqueues due triggers into the outbox; the dispatcher above // delivers them like any other async trigger. picloud_manager_core::spawn_cron_scheduler(pool, trigger_config.cron_tick_interval_ms); + // v1.1.6: GC empty realtime broadcast channels (one-shot subscribers) + // and sweep orphaned `*.tmp.*` blobs left by crashed file writes. + spawn_realtime_gc(broadcaster_concrete, DEFAULT_GC_INTERVAL_SECS); + picloud_manager_core::spawn_files_orphan_sweep(files_root); let triggers_state = TriggersState { triggers: trigger_repo, apps: apps_repo.clone(), @@ -302,11 +334,17 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { apps: apps_repo.clone(), authz: authz.clone(), }; + let topics_state = TopicsState { + topics: topic_repo, + apps: apps_repo.clone(), + authz: authz.clone(), + broadcaster: broadcaster.clone(), + }; let apps_state = AppsState { apps: apps_repo, domains: domains_repo, routes: route_repo, - domain_table: app_domain_table, + domain_table: app_domain_table.clone(), authz: authz.clone(), }; @@ -345,6 +383,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { .merge(api_keys_router(api_keys_state)) .merge(triggers_router(triggers_state)) .merge(files_admin_router(files_admin_state)) + .merge(topics_router(topics_state)) .merge(dead_letters_router(dead_letters_state)) .layer(from_fn_with_state( auth_state.clone(), @@ -375,10 +414,21 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { .nest("/admin", guarded_admin) .merge(data_plane_routed); + // v1.1.6 SSE realtime surface, merged at the root (deliberately NOT + // under /api/ — realtime is its own versioning surface). Public auth + // is per-topic; no principal middleware (token verification is the + // gate, handled inside the authority). + let realtime = realtime_router(RealtimeState::new( + app_domain_table, + broadcaster, + realtime_authority, + )); + Ok(Router::new() .route("/healthz", get(healthz)) .route("/version", get(version)) .nest(&format!("/api/v{API_VERSION}"), api_v1) + .merge(realtime) .merge(user_routes) .layer(TraceLayer::new_for_http())) } diff --git a/crates/picloud/tests/dispatcher_e2e.rs b/crates/picloud/tests/dispatcher_e2e.rs new file mode 100644 index 0000000..daedf42 --- /dev/null +++ b/crates/picloud/tests/dispatcher_e2e.rs @@ -0,0 +1,353 @@ +//! End-to-end dispatcher tests — one per trigger kind (v1.1.5 follow-up, +//! landed in v1.1.6). Each test wires the full all-in-one app via +//! `build_app` (which spawns the real dispatcher + cron scheduler + +//! executor), creates an app + a logging handler script + a trigger, +//! causes the originating event, and polls for the handler's side effect. +//! +//! ## Gating +//! +//! These need a Postgres reachable via `DATABASE_URL`. They follow the +//! `schema_snapshot` pattern (NOT `#[ignore]`): when `DATABASE_URL` is +//! unset the test prints a notice and returns early, so plain +//! `cargo test` stays green locally while CI (which sets `DATABASE_URL`) +//! runs them. +//! +//! ## How "the handler fired" is observed +//! +//! The dispatcher does not write `execution_log` rows for trigger +//! handlers, so each handler instead records its `ctx.event` into a KV +//! marker (`collection = "e2e_markers"`, which no trigger watches — no +//! recursion). The test polls `kv_entries` for that marker and asserts +//! the event shape. See HANDBACK §deviations for why this lives in +//! `picloud/tests/` rather than `manager-core/tests/` (build_app lives in +//! the `picloud` crate) and for the `dead_letter` reinterpretation. + +#![allow(clippy::needless_pass_by_value)] + +use std::time::Duration; + +use axum_test::TestServer; +use serde_json::{json, Value}; +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; +use uuid::Uuid; + +/// Connect + migrate, or return `None` (printing a skip notice) when +/// `DATABASE_URL` is unset — mirrors `schema_snapshot.rs`. +async fn pool_or_skip() -> Option { + let Ok(url) = std::env::var("DATABASE_URL") else { + eprintln!("dispatcher_e2e: DATABASE_URL unset — skipping"); + return None; + }; + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&url) + .await + .expect("connect to DATABASE_URL"); + sqlx::migrate!("../manager-core/migrations") + .run(&pool) + .await + .expect("apply migrations"); + Some(pool) +} + +/// Build the app over the shared pool with a uniquely-named owner admin, +/// log in, and create a fresh app. `suffix` must be unique per test (the +/// pool is shared, so names must not collide). +async fn server_for(pool: PgPool, suffix: &str) -> (TestServer, String) { + use picloud_manager_core::auth::hash_password; + use picloud_shared::InstanceRole; + + let unique = format!("{suffix}-{}", Uuid::new_v4().simple()); + let auth = picloud::AuthDeps::from_pool(pool.clone()); + let username = format!("e2e-{unique}"); + let hash = hash_password("pw").expect("hash"); + auth.users + .create(&username, &hash, InstanceRole::Owner, None) + .await + .expect("seed admin"); + + let app = picloud::build_app(pool, auth).await.expect("build_app"); + let mut server = TestServer::new(app).expect("TestServer"); + let resp = server + .post("/api/v1/admin/auth/login") + .json(&json!({ "username": username, "password": "pw" })) + .await; + resp.assert_status_ok(); + let token = resp.json::()["token"] + .as_str() + .expect("login token") + .to_string(); + server.add_header("authorization", format!("Bearer {token}")); + + // A fresh app keeps each test's KV / events isolated from siblings. + let slug = format!("e2e-{unique}"); + let created: Value = server + .post("/api/v1/admin/apps") + .json(&json!({ "slug": slug, "name": slug })) + .await + .json(); + let app_id = created["id"].as_str().expect("app id").to_string(); + (server, app_id) +} + +async fn create_script(server: &TestServer, app_id: &str, name: &str, source: &str) -> String { + let created: Value = server + .post("/api/v1/admin/scripts") + .json(&json!({ "app_id": app_id, "name": name, "source": source })) + .await + .json(); + created["id"].as_str().expect("script id").to_string() +} + +/// A handler that records its `ctx.event` into a KV marker the test can +/// observe. The marker collection is watched by no trigger. +const MARKER_HANDLER: &str = r#" + let e = ctx.event; + kv::collection("e2e_markers").set("marker", e); + #{ ok: true } +"#; + +/// Poll the marker KV key until present (or ~10s timeout). +async fn poll_marker(pool: &PgPool, app_id: &str) -> Option { + poll_marker_n(pool, app_id, 100).await +} + +/// Poll the marker KV key for `iters` × 100ms. +async fn poll_marker_n(pool: &PgPool, app_id: &str, iters: u32) -> Option { + let app_uuid = Uuid::parse_str(app_id).expect("app uuid"); + for _ in 0..iters { + let row: Option<(Value,)> = sqlx::query_as( + "SELECT value FROM kv_entries \ + WHERE app_id = $1 AND collection = 'e2e_markers' AND key = 'marker'", + ) + .bind(app_uuid) + .fetch_optional(pool) + .await + .expect("query marker"); + if let Some((value,)) = row { + return Some(value); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + None +} + +async fn execute(server: &TestServer, script_id: &str) { + server + .post(&format!("/api/v1/execute/{script_id}")) + .json(&json!({})) + .await + .assert_status_ok(); +} + +#[tokio::test] +async fn dispatcher_delivers_kv_to_handler() { + let Some(pool) = pool_or_skip().await else { + return; + }; + let (server, app_id) = server_for(pool.clone(), "kv").await; + + let handler = create_script(&server, &app_id, "kv-handler", MARKER_HANDLER).await; + server + .post(&format!("/api/v1/admin/apps/{app_id}/triggers/kv")) + .json(&json!({ "script_id": handler, "collection_glob": "src" })) + .await + .assert_status(axum::http::StatusCode::CREATED); + + let source = create_script( + &server, + &app_id, + "kv-source", + r#"kv::collection("src").set("k", 42); #{ ok: true }"#, + ) + .await; + execute(&server, &source).await; + + let event = poll_marker(&pool, &app_id).await.expect("kv handler fired"); + assert_eq!(event["source"], "kv"); + assert_eq!(event["op"], "insert"); + assert_eq!(event["kv"]["collection"], "src"); + assert_eq!(event["kv"]["key"], "k"); +} + +#[tokio::test] +async fn dispatcher_delivers_docs_to_handler() { + let Some(pool) = pool_or_skip().await else { + return; + }; + let (server, app_id) = server_for(pool.clone(), "docs").await; + + let handler = create_script(&server, &app_id, "docs-handler", MARKER_HANDLER).await; + server + .post(&format!("/api/v1/admin/apps/{app_id}/triggers/docs")) + .json(&json!({ "script_id": handler, "collection_glob": "src" })) + .await + .assert_status(axum::http::StatusCode::CREATED); + + let source = create_script( + &server, + &app_id, + "docs-source", + r#"docs::collection("src").create(#{ x: 1 }); #{ ok: true }"#, + ) + .await; + execute(&server, &source).await; + + let event = poll_marker(&pool, &app_id) + .await + .expect("docs handler fired"); + assert_eq!(event["source"], "docs"); + assert_eq!(event["op"], "create"); + assert_eq!(event["docs"]["collection"], "src"); +} + +#[tokio::test] +async fn dispatcher_delivers_cron_to_handler() { + let Some(pool) = pool_or_skip().await else { + return; + }; + let (server, app_id) = server_for(pool.clone(), "cron").await; + + let handler = create_script(&server, &app_id, "cron-handler", MARKER_HANDLER).await; + // Fire every second (6-field cron, seconds-resolution). + server + .post(&format!("/api/v1/admin/apps/{app_id}/triggers/cron")) + .json(&json!({ "script_id": handler, "schedule": "* * * * * *", "timezone": "UTC" })) + .await + .assert_status(axum::http::StatusCode::CREATED); + + // No source — the scheduler enqueues the due tick on its own. The + // scheduler skips its first tick and then ticks every + // PICLOUD_CRON_TICK_INTERVAL_MS (default 30s), so poll past that + // (set the env var lower to speed CI up if desired). + let event = poll_marker_n(&pool, &app_id, 450) + .await + .expect("cron handler fired"); + assert_eq!(event["source"], "cron"); + assert_eq!(event["op"], "tick"); + assert_eq!(event["cron"]["timezone"], "UTC"); +} + +#[tokio::test] +async fn dispatcher_delivers_files_to_handler() { + let Some(pool) = pool_or_skip().await else { + return; + }; + let (server, app_id) = server_for(pool.clone(), "files").await; + + let handler = create_script(&server, &app_id, "files-handler", MARKER_HANDLER).await; + server + .post(&format!("/api/v1/admin/apps/{app_id}/triggers/files")) + .json(&json!({ "script_id": handler, "collection_glob": "src" })) + .await + .assert_status(axum::http::StatusCode::CREATED); + + let source = create_script( + &server, + &app_id, + "files-source", + r#" + let data = base64::decode("aGk="); + files::collection("src").create(#{ name: "f.txt", content_type: "text/plain", data: data }); + #{ ok: true } + "#, + ) + .await; + execute(&server, &source).await; + + let event = poll_marker(&pool, &app_id) + .await + .expect("files handler fired"); + assert_eq!(event["source"], "files"); + assert_eq!(event["op"], "create"); + assert_eq!(event["files"]["collection"], "src"); + assert_eq!(event["files"]["name"], "f.txt"); +} + +#[tokio::test] +async fn dispatcher_delivers_pubsub_to_handler() { + let Some(pool) = pool_or_skip().await else { + return; + }; + let (server, app_id) = server_for(pool.clone(), "pubsub").await; + + let handler = create_script(&server, &app_id, "pubsub-handler", MARKER_HANDLER).await; + server + .post(&format!("/api/v1/admin/apps/{app_id}/triggers/pubsub")) + .json(&json!({ "script_id": handler, "topic_pattern": "e2e.topic" })) + .await + .assert_status(axum::http::StatusCode::CREATED); + + let source = create_script( + &server, + &app_id, + "pubsub-source", + r#"pubsub::publish_durable("e2e.topic", #{ hello: 1 }); #{ ok: true }"#, + ) + .await; + execute(&server, &source).await; + + let event = poll_marker(&pool, &app_id) + .await + .expect("pubsub handler fired"); + assert_eq!(event["source"], "pubsub"); + assert_eq!(event["op"], "publish"); + assert_eq!(event["pubsub"]["topic"], "e2e.topic"); + assert_eq!(event["pubsub"]["message"]["hello"], 1); +} + +#[tokio::test] +async fn dispatcher_delivers_dead_letter_to_handler() { + // NOTE: the dead-letter creation path (`dispatcher::handle_failure` → + // `DeadLetterRepo::insert`) writes the `dead_letters` row but does not + // appear to enqueue deliveries for `dead_letter`-kind triggers + // (`TriggerRepo::list_matching_dead_letter` has no production caller — + // see HANDBACK latent-findings). So this test asserts the wired + // behavior: a failing handler that exhausts its (single) attempt + // produces a dead-letter row. If/when DL→handler fan-out lands, this + // can be upgraded to assert the handler marker like the others. + let Some(pool) = pool_or_skip().await else { + return; + }; + let (server, app_id) = server_for(pool.clone(), "dl").await; + + // A handler that always throws, with a single attempt so it + // dead-letters immediately (no retry backoff). + let failing = create_script(&server, &app_id, "dl-failing", r#"throw "boom";"#).await; + server + .post(&format!("/api/v1/admin/apps/{app_id}/triggers/kv")) + .json(&json!({ + "script_id": failing, + "collection_glob": "dlsrc", + "retry_max_attempts": 1, + "retry_base_ms": 0 + })) + .await + .assert_status(axum::http::StatusCode::CREATED); + + let source = create_script( + &server, + &app_id, + "dl-source", + r#"kv::collection("dlsrc").set("k", 1); #{ ok: true }"#, + ) + .await; + execute(&server, &source).await; + + // Poll the dead_letters table for this app. + let app_uuid = Uuid::parse_str(&app_id).unwrap(); + let mut count: i64 = 0; + for _ in 0..100 { + count = sqlx::query_scalar("SELECT COUNT(*) FROM dead_letters WHERE app_id = $1") + .bind(app_uuid) + .fetch_one(&pool) + .await + .expect("count dead_letters"); + if count > 0 { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!(count > 0, "a dead-letter row should have been produced"); +} diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 25e83a8..47e2b70 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -15,3 +15,12 @@ serde_json.workspace = true thiserror.workspace = true uuid.workspace = true chrono.workspace = true +# Realtime broadcaster trait returns a broadcast::Receiver; subscriber +# tokens are HMAC-SHA256 over a base64url payload (v1.1.6). +tokio = { workspace = true, features = ["sync"] } +hmac.workspace = true +sha2.workspace = true +base64.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time", "sync"] } diff --git a/crates/shared/src/files.rs b/crates/shared/src/files.rs index b3842d3..8810a7d 100644 --- a/crates/shared/src/files.rs +++ b/crates/shared/src/files.rs @@ -177,8 +177,11 @@ pub enum FilesError { impl NewFile { /// Validate required fields + length caps at the SDK boundary. - /// `data` must be non-empty (v1.1.5 treats an empty blob as a - /// missing `data` field — see HANDBACK §7). + /// + /// Empty `data` is **accepted** as a valid stored state (v1.1.6 + /// relaxed the v1.1.5 rejection — empty files are a legitimate use + /// case: sentinels, placeholders, zero-byte uploads. See HANDBACK + /// §7). `name` and `content_type` are still required. /// /// # Errors /// @@ -191,9 +194,6 @@ impl NewFile { if self.content_type.trim().is_empty() { return Err(FilesError::MissingField("content_type")); } - if self.data.is_empty() { - return Err(FilesError::MissingField("data")); - } if self.name.len() > MAX_FILE_NAME_BYTES { return Err(FilesError::NameTooLong(self.name.len())); } @@ -218,9 +218,9 @@ impl FileUpdate { /// Returns the field-specific [`FilesError`] for the first failing /// check. pub fn validate(&self, max_size: usize) -> Result<(), FilesError> { - if self.data.is_empty() { - return Err(FilesError::MissingField("data")); - } + // Empty replacement bytes are accepted (v1.1.6 relaxation — + // consistent with NewFile::validate; updating a file to zero + // bytes is as legitimate as creating one). if let Some(name) = &self.name { if name.trim().is_empty() { return Err(FilesError::MissingField("name")); diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 64fab48..e30a46f 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -21,11 +21,14 @@ pub mod log_sink; pub mod modules; pub mod outbox_writer; pub mod pubsub; +pub mod realtime; +pub mod realtime_authority; pub mod route; pub mod sandbox; pub mod script; pub mod sdk_cx; pub mod services; +pub mod subscriber_token; pub mod trigger_event; pub mod validator; pub mod version; @@ -54,6 +57,8 @@ pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, Outbox pub use pubsub::{ topic_matches, validate_topic_pattern, NoopPubsubService, PubsubError, PubsubService, }; +pub use realtime::{BroadcasterError, NoopRealtimeBroadcaster, RealtimeBroadcaster, RealtimeEvent}; +pub use realtime_authority::{DenyAllRealtimeAuthority, RealtimeAuthority, SubscribeDenied}; pub use route::{DispatchMode, HostKind, PathKind, Route}; pub use sandbox::ScriptSandbox; pub use script::{Script, ScriptKind}; diff --git a/crates/shared/src/pubsub.rs b/crates/shared/src/pubsub.rs index 2f78d60..68ff3b6 100644 --- a/crates/shared/src/pubsub.rs +++ b/crates/shared/src/pubsub.rs @@ -30,6 +30,32 @@ pub trait PubsubService: Send + Sync { 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, + ttl_seconds: Option, + ) -> Result { + let _ = (cx, topics, ttl_seconds); + Err(PubsubError::Unavailable( + "subscriber tokens are not wired in".into(), + )) + } } #[derive(Debug, Error)] @@ -47,6 +73,13 @@ pub enum PubsubError { #[error("pubsub rejected: {0}")] Rejected(String), + /// 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. #[error("pubsub backend error: {0}")] Unavailable(String), diff --git a/crates/shared/src/realtime.rs b/crates/shared/src/realtime.rs new file mode 100644 index 0000000..16e8dc2 --- /dev/null +++ b/crates/shared/src/realtime.rs @@ -0,0 +1,86 @@ +//! `RealtimeBroadcaster` — the in-process fan-out seam for SSE realtime +//! delivery (v1.1.6). +//! +//! Structurally a sibling of [`crate::inbox::InboxResolver`]: the trait +//! lives here in `picloud-shared` because the publish side +//! (`PubsubServiceImpl` in manager-core) and the subscribe side (the SSE +//! handler in orchestrator-core) live in different crates and both need +//! one shared instance. The in-process impl lives in orchestrator-core +//! (`Mutex>`); cluster mode +//! (v1.3+) swaps it for a Postgres `LISTEN/NOTIFY`-backed resolver behind +//! the same trait without touching either caller. +//! +//! Delivery is **best-effort, at-most-once**: this is the realtime path, +//! NOT the durable one. Durable trigger fan-out (retry / dead-letter) +//! goes through the outbox and is the publish caller's separate concern. +//! A slow SSE consumer loses the oldest events (bounded broadcast +//! buffer); SSE's own transport-layer auto-reconnect is the recovery +//! mechanism (no server-side replay in v1.1.6). + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use thiserror::Error; +use tokio::sync::broadcast; + +use crate::AppId; + +/// A single realtime event delivered to in-process SSE subscribers. The +/// SSE handler serializes this to `data: {...}\n\n` on the wire. +#[derive(Debug, Clone)] +pub struct RealtimeEvent { + pub topic: String, + pub message: serde_json::Value, + pub published_at: DateTime, +} + +#[derive(Debug, Error)] +pub enum BroadcasterError { + /// Reserved for backends that can fail to register a subscriber + /// (e.g. the cluster-mode `LISTEN/NOTIFY` resolver). The in-process + /// impl never returns this. + #[error("realtime broadcaster unavailable: {0}")] + Unavailable(String), +} + +#[async_trait] +pub trait RealtimeBroadcaster: Send + Sync { + /// Subscribe to events on `(app_id, topic)`. Returns a receiver that + /// yields events until dropped. Channels are created lazily on first + /// subscribe. + async fn subscribe( + &self, + app_id: AppId, + topic: &str, + ) -> Result, BroadcasterError>; + + /// Publish an event to in-process subscribers. NOT durable — the + /// outbox-backed durable fan-out is the publish caller's separate + /// concern. A publish with no live subscribers is a silent no-op. + async fn publish(&self, app_id: AppId, topic: &str, event: RealtimeEvent); + + /// Drop every subscriber for a topic (called on topic DELETE). Live + /// receivers observe a closed channel and disconnect cleanly. + async fn drop_topic(&self, app_id: AppId, topic: &str); +} + +/// Bootstrap / test impl: subscribe yields a receiver on a throwaway +/// channel, publish is a no-op. Lets a `Services`-style bundle build +/// without the real registry wired in. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopRealtimeBroadcaster; + +#[async_trait] +impl RealtimeBroadcaster for NoopRealtimeBroadcaster { + async fn subscribe( + &self, + _app_id: AppId, + _topic: &str, + ) -> Result, BroadcasterError> { + let (_tx, rx) = broadcast::channel(1); + Ok(rx) + } + + async fn publish(&self, _app_id: AppId, _topic: &str, _event: RealtimeEvent) {} + + async fn drop_topic(&self, _app_id: AppId, _topic: &str) {} +} diff --git a/crates/shared/src/realtime_authority.rs b/crates/shared/src/realtime_authority.rs new file mode 100644 index 0000000..fe21186 --- /dev/null +++ b/crates/shared/src/realtime_authority.rs @@ -0,0 +1,70 @@ +//! `RealtimeAuthority` — the SSE subscribe authorization seam (v1.1.6). +//! +//! The SSE endpoint (`GET /realtime/topics/{topic}`) is a data-plane +//! surface in orchestrator-core, but deciding whether a subscribe is +//! allowed needs a `topics` table read plus (for token-gated topics) an +//! HMAC verify against the app's signing key — both of which require DB +//! access and the signing-key material that must NOT leak into the +//! data-plane crate. This trait keeps all of that inside the manager-core +//! impl: orchestrator-core only ever sees the three-way verdict below. +//! +//! `NotFound` is deliberately returned for *both* "no such topic" and +//! "topic exists but isn't externally subscribable" so the endpoint +//! can't be used to probe which internal topics exist (design notes §5). + +use async_trait::async_trait; + +use crate::AppId; + +/// Why a subscribe attempt was refused. The SSE handler maps these to +/// HTTP status codes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SubscribeDenied { + /// No externally-subscribable topic by that name in this app → 404. + /// Used for genuinely-missing topics AND internal-only ones, so the + /// endpoint doesn't leak which internal topics exist. + NotFound, + /// The topic is token-gated and the presented token was missing, + /// malformed, badly signed, expired, or not scoped to this topic → + /// 401 (generic; never says which check failed). + Unauthorized, + /// Backend failure (DB unavailable, etc.) → 500. + Backend(String), +} + +#[async_trait] +pub trait RealtimeAuthority: Send + Sync { + /// Decide whether an external client may subscribe to + /// `(app_id, topic)`. `token` is the bearer/query token if the + /// client presented one (`None` otherwise). + /// + /// Returns `Ok(())` when the subscribe is permitted (public topic, + /// or token-gated topic with a valid token scoped to it). + /// + /// # Errors + /// + /// [`SubscribeDenied`] — see the variants for the status mapping. + async fn authorize_subscribe( + &self, + app_id: AppId, + topic: &str, + token: Option<&str>, + ) -> Result<(), SubscribeDenied>; +} + +/// Bootstrap impl: denies everything as `NotFound`. Replaced in +/// `build_app` with the manager-core DB-backed authority. +#[derive(Debug, Default, Clone, Copy)] +pub struct DenyAllRealtimeAuthority; + +#[async_trait] +impl RealtimeAuthority for DenyAllRealtimeAuthority { + async fn authorize_subscribe( + &self, + _app_id: AppId, + _topic: &str, + _token: Option<&str>, + ) -> Result<(), SubscribeDenied> { + Err(SubscribeDenied::NotFound) + } +} diff --git a/crates/shared/src/subscriber_token.rs b/crates/shared/src/subscriber_token.rs new file mode 100644 index 0000000..dbf5ef3 --- /dev/null +++ b/crates/shared/src/subscriber_token.rs @@ -0,0 +1,200 @@ +//! HMAC-signed realtime subscriber tokens (v1.1.6, design notes §5). +//! +//! A token is a compact, URL-safe, two-part string: +//! +//! ```text +//! . +//! ``` +//! +//! where `payload` is the JSON [`TokenClaims`] and `signature` is +//! `HMAC-SHA256(app_signing_key, base64url(payload))`. Tokens are minted +//! by scripts via `pubsub::subscriber_token` (the minter lives in +//! manager-core's `PubsubServiceImpl`) and verified by the SSE subscribe +//! path (the verifier lives in manager-core's `RealtimeAuthority` impl). +//! Both sides depend on this module so the byte-for-byte contract has a +//! single home. +//! +//! There is no per-token revocation in v1.1.6 by design: HMAC bearers +//! can't be individually revoked. Rotating an app's signing key +//! invalidates every token for that app wholesale; short TTLs are the +//! safety mechanism. + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use thiserror::Error; + +use crate::AppId; + +type HmacSha256 = Hmac; + +/// The signed payload. `exp` / `iat` are Unix seconds. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenClaims { + pub app_id: AppId, + pub topics: Vec, + pub exp: i64, + pub iat: i64, +} + +impl TokenClaims { + /// Does this token grant access to `topic`? + #[must_use] + pub fn allows_topic(&self, topic: &str) -> bool { + self.topics.iter().any(|t| t == topic) + } + + /// Is the token expired relative to `now_unix` (Unix seconds)? + #[must_use] + pub fn is_expired(&self, now_unix: i64) -> bool { + now_unix >= self.exp + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum TokenError { + #[error("token is malformed")] + Malformed, + #[error("token signature is invalid")] + BadSignature, + #[error("token has expired")] + Expired, +} + +/// Sign `claims` with `key`, producing the `payload.signature` string. +#[must_use] +pub fn sign(key: &[u8], claims: &TokenClaims) -> String { + // `serde_json` on a fixed-field struct never fails to serialize. + let payload_json = serde_json::to_vec(claims).expect("TokenClaims serialize"); + let payload_b64 = URL_SAFE_NO_PAD.encode(&payload_json); + let sig = mac_sign(key, payload_b64.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(sig); + format!("{payload_b64}.{sig_b64}") +} + +/// Verify `token` against `key` and check expiry against `now_unix` +/// (Unix seconds). Returns the decoded [`TokenClaims`] on success. +/// +/// Topic-scope checking (is the requested topic in the token's list?) +/// is the caller's responsibility via [`TokenClaims::allows_topic`] — +/// this function proves authenticity + liveness only. +/// +/// # Errors +/// +/// [`TokenError::Malformed`] if the shape / base64 / JSON is wrong, +/// [`TokenError::BadSignature`] if the HMAC doesn't match, or +/// [`TokenError::Expired`] if `now_unix >= exp`. +pub fn verify(key: &[u8], token: &str, now_unix: i64) -> Result { + let (payload_b64, sig_b64) = token.split_once('.').ok_or(TokenError::Malformed)?; + + let provided_sig = URL_SAFE_NO_PAD + .decode(sig_b64) + .map_err(|_| TokenError::Malformed)?; + + // Constant-time verify of the MAC over the exact payload bytes. + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(payload_b64.as_bytes()); + mac.verify_slice(&provided_sig) + .map_err(|_| TokenError::BadSignature)?; + + // Signature good → decode the claims and check expiry. + let payload_json = URL_SAFE_NO_PAD + .decode(payload_b64) + .map_err(|_| TokenError::Malformed)?; + let claims: TokenClaims = + serde_json::from_slice(&payload_json).map_err(|_| TokenError::Malformed)?; + + if claims.is_expired(now_unix) { + return Err(TokenError::Expired); + } + Ok(claims) +} + +fn mac_sign(key: &[u8], data: &[u8]) -> Vec { + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(data); + mac.finalize().into_bytes().to_vec() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn claims(app: AppId, topics: &[&str], exp: i64) -> TokenClaims { + TokenClaims { + app_id: app, + topics: topics.iter().map(|s| (*s).to_string()).collect(), + iat: 1000, + exp, + } + } + + #[test] + fn round_trip_verifies() { + let key = b"super-secret-key-bytes-0123456789"; + let app = AppId::new(); + let c = claims(app, &["chat.room.1", "user.notify"], 5000); + let token = sign(key, &c); + let got = verify(key, &token, 2000).expect("valid token verifies"); + assert_eq!(got, c); + assert!(got.allows_topic("chat.room.1")); + assert!(!got.allows_topic("chat.room.2")); + } + + #[test] + fn tampered_payload_fails() { + let key = b"super-secret-key-bytes-0123456789"; + let app = AppId::new(); + let token = sign(key, &claims(app, &["t"], 5000)); + // Flip a character in the payload half. + let (payload, sig) = token.split_once('.').unwrap(); + let mut bytes = payload.as_bytes().to_vec(); + bytes[0] ^= 0x01; + let tampered = format!("{}.{sig}", String::from_utf8_lossy(&bytes)); + assert_eq!(verify(key, &tampered, 2000), Err(TokenError::BadSignature)); + } + + #[test] + fn tampered_signature_fails() { + let key = b"super-secret-key-bytes-0123456789"; + let app = AppId::new(); + let token = sign(key, &claims(app, &["t"], 5000)); + let (payload, _sig) = token.split_once('.').unwrap(); + // A valid-base64 but wrong signature. + let bogus = URL_SAFE_NO_PAD.encode([0u8; 32]); + let tampered = format!("{payload}.{bogus}"); + assert_eq!(verify(key, &tampered, 2000), Err(TokenError::BadSignature)); + } + + #[test] + fn different_key_fails() { + let app = AppId::new(); + let token = sign( + b"key-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + &claims(app, &["t"], 5000), + ); + assert_eq!( + verify(b"key-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", &token, 2000), + Err(TokenError::BadSignature) + ); + } + + #[test] + fn expired_token_fails_at_expiry_check() { + let key = b"super-secret-key-bytes-0123456789"; + let app = AppId::new(); + let token = sign(key, &claims(app, &["t"], 5000)); + // now == exp → expired (>= boundary). + assert_eq!(verify(key, &token, 5000), Err(TokenError::Expired)); + assert_eq!(verify(key, &token, 9999), Err(TokenError::Expired)); + } + + #[test] + fn malformed_token_fails() { + let key = b"super-secret-key-bytes-0123456789"; + assert_eq!(verify(key, "no-dot-here", 0), Err(TokenError::Malformed)); + assert_eq!(verify(key, "a.b.c", 0), Err(TokenError::Malformed)); + } +} diff --git a/crates/shared/src/version.rs b/crates/shared/src/version.rs index d86bd1c..e783885 100644 --- a/crates/shared/src/version.rs +++ b/crates/shared/src/version.rs @@ -49,7 +49,16 @@ pub const PRODUCT_VERSION: &str = env!("CARGO_PKG_VERSION"); /// publish-time fan-out and `ctx.event.pubsub` for pubsub-trigger /// handlers. The `Services` bundle gains `files: Arc` /// and `pubsub: Arc`. -pub const SDK_VERSION: &str = "1.6"; +/// +/// 1.7 additions (v1.1.6): `pubsub::subscriber_token(topics, ttl)` — +/// mints an HMAC-signed realtime subscriber token for externally- +/// subscribable topics (requires an authenticated principal). This is +/// the only new script-visible surface; the rest of the release is +/// server-side (the SSE `/realtime/topics/{topic}` endpoint; the +/// `RealtimeBroadcaster` / `RealtimeEvent` / `RealtimeAuthority` traits; +/// the `topics` registry + admin endpoints; the `@picloud/client` +/// TypeScript package). +pub const SDK_VERSION: &str = "1.7"; /// HTTP API major version. Appears in URL paths as `/api/v{N}/...`. /// Bump (new integer + new URL prefix) when the request/response diff --git a/dashboard/package.json b/dashboard/package.json index ef89a4d..da1d364 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "picloud-dashboard", - "version": "0.11.0", + "version": "0.12.0", "private": true, "type": "module", "scripts": { diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 2540c4f..91fadb3 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -270,6 +270,28 @@ export interface CreatePubsubTriggerInput { retry_base_ms?: number; } +// v1.1.6 — externally-subscribable realtime topics. +export type TopicAuthMode = 'public' | 'token'; + +export interface Topic { + name: string; + external_subscribable: boolean; + auth_mode: TopicAuthMode; + created_at: string; + updated_at: string; +} + +export interface CreateTopicInput { + name: string; + external_subscribable: boolean; + auth_mode: TopicAuthMode; +} + +export interface UpdateTopicInput { + external_subscribable?: boolean; + auth_mode?: TopicAuthMode; +} + export interface ExecutionResult { status: number; headers: Record; @@ -653,6 +675,28 @@ export const api = { ) }, + topics: { + list: (idOrSlug: string) => + adminRequest<{ topics: Topic[] }>( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics` + ), + create: (idOrSlug: string, input: CreateTopicInput) => + adminRequest(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics`, { + method: 'POST', + body: JSON.stringify(input) + }), + update: (idOrSlug: string, name: string, input: UpdateTopicInput) => + adminRequest( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics/${encodeURIComponent(name)}`, + { method: 'PATCH', body: JSON.stringify(input) } + ), + remove: (idOrSlug: string, name: string) => + adminRequest( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics/${encodeURIComponent(name)}`, + { method: 'DELETE' } + ) + }, + files: { list: (idOrSlug: string, collection: string, opts: { cursor?: string; limit?: number } = {}) => { const params = new URLSearchParams(); diff --git a/dashboard/src/routes/apps/[slug]/+page.svelte b/dashboard/src/routes/apps/[slug]/+page.svelte index 3c83274..709f0fa 100644 --- a/dashboard/src/routes/apps/[slug]/+page.svelte +++ b/dashboard/src/routes/apps/[slug]/+page.svelte @@ -11,7 +11,9 @@ type AppMemberDto, type AppRole, type Script, - type Trigger + type Trigger, + type Topic, + type TopicAuthMode } from '$lib/api'; import CodeEditor from '$lib/CodeEditor.svelte'; import ConfirmModal from '$lib/ConfirmModal.svelte'; @@ -25,7 +27,7 @@ const SAMPLE_SOURCE = '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}'; - type Tab = 'scripts' | 'domains' | 'members' | 'settings' | 'triggers'; + type Tab = 'scripts' | 'domains' | 'members' | 'settings' | 'triggers' | 'topics'; // Common IANA timezones offered in the cron form dropdown. Not // exhaustive — the backend validates any IANA name via chrono-tz. @@ -194,6 +196,100 @@ } } + // Topics tab (v1.1.6 — externally-subscribable realtime topics). Admin-gated. + let topics = $state([]); + let createTopicName = $state(''); + let createTopicExternal = $state(false); + let createTopicAuthMode = $state('public'); + let creatingTopic = $state(false); + let createTopicError = $state(null); + // Edit modal. + let topicToEdit = $state(null); + let editTopicExternal = $state(false); + let editTopicAuthMode = $state('public'); + let savingTopic = $state(false); + let editTopicError = $state(null); + // Flipping internal → external is the security-sensitive change. + const editFlipToExternal = $derived( + !!topicToEdit && !topicToEdit.external_subscribable && editTopicExternal + ); + // Delete confirm. + let topicToRemove = $state(null); + let removingTopic = $state(false); + + async function loadTopics(idOrSlug: string) { + try { + const r = await api.topics.list(idOrSlug); + topics = r.topics; + } catch { + topics = []; + } + } + + async function submitCreateTopic(e: SubmitEvent) { + e.preventDefault(); + if (!app) return; + creatingTopic = true; + createTopicError = null; + try { + await api.topics.create(app.id, { + name: createTopicName.trim(), + external_subscribable: createTopicExternal, + auth_mode: createTopicAuthMode + }); + createTopicName = ''; + createTopicExternal = false; + createTopicAuthMode = 'public'; + await loadTopics(app.id); + } catch (err) { + createTopicError = + err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err); + } finally { + creatingTopic = false; + } + } + + function openEditTopic(t: Topic) { + topicToEdit = t; + editTopicExternal = t.external_subscribable; + editTopicAuthMode = t.auth_mode; + editTopicError = null; + } + + async function confirmEditTopic() { + if (!app || !topicToEdit) return; + savingTopic = true; + editTopicError = null; + try { + await api.topics.update(app.id, topicToEdit.name, { + external_subscribable: editTopicExternal, + auth_mode: editTopicAuthMode + }); + topicToEdit = null; + await loadTopics(app.id); + } catch (err) { + editTopicError = + err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err); + } finally { + savingTopic = false; + } + } + + async function confirmRemoveTopic() { + if (!app || !topicToRemove) return; + removingTopic = true; + try { + await api.topics.remove(app.id, topicToRemove.name); + topicToRemove = null; + await loadTopics(app.id); + } catch (err) { + createTopicError = + err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err); + } finally { + removingTopic = false; + } + } + // Members tab let eligibleUsers = $state([]); let eligibleLoadError = $state(null); @@ -234,7 +330,12 @@ loadDeadLetterCount(app.id) ]; if (canAdmin) { - loaders.push(loadMembers(app.id), loadEligibleUsers(), loadTriggers(app.id)); + loaders.push( + loadMembers(app.id), + loadEligibleUsers(), + loadTriggers(app.id), + loadTopics(app.id) + ); } await Promise.all(loaders); } catch (e) { @@ -503,7 +604,10 @@ $effect(() => { if ( !canAdmin && - (activeTab === 'settings' || activeTab === 'members' || activeTab === 'triggers') + (activeTab === 'settings' || + activeTab === 'members' || + activeTab === 'triggers' || + activeTab === 'topics') ) { activeTab = 'scripts'; } @@ -551,6 +655,11 @@ class:active={activeTab === 'triggers'} onclick={() => (activeTab = 'triggers')}>Triggers ({triggers.length}) + + + + + {#if topics.length === 0} +

No registered topics in this app yet.

+ {:else} +
    + {#each topics as t (t.name)} +
  • +
    + {t.name} + {#if t.external_subscribable} + + external + + {t.auth_mode} + {:else} + + internal + + {/if} + · {shortDate(t.created_at)} +
    +
    + + +
    +
  • + {/each} +
+ {/if} + {:else if activeTab === 'settings' && canAdmin}

Settings

@@ -1113,6 +1305,65 @@

{/if} + + {#if topicToEdit} + (topicToEdit = null)} + > + + {#if editTopicExternal} +
+ Auth mode + + +
+ {/if} + {#if editFlipToExternal} +
+ Marking {topicToEdit.name} externally-subscribable means + anyone with the URL can subscribe to this topic (if auth_mode is + public) or anyone with a valid token can subscribe (if + auth_mode is token). Are you sure? +
+ {/if} + {#if editTopicError} + + {/if} +
+ {/if} + + {#if topicToRemove} + (topicToRemove = null)} + > +

+ Unregistering {topicToRemove.name} disconnects any live SSE + subscribers immediately. Scripts can still publish_durable to + it (internal triggers keep working) — it just won't be externally + subscribable. +

+
+ {/if} {/if}