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) <noreply@anthropic.com>
This commit is contained in:
91
crates/manager-core/src/app_secrets_repo.rs
Normal file
91
crates/manager-core/src/app_secrets_repo.rs
Normal file
@@ -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<Vec<u8>, 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<Option<Vec<u8>>, 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<Vec<u8>, 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<u8>,) =
|
||||
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<Option<Vec<u8>>, AppSecretsRepoError> {
|
||||
let row: Option<(Vec<u8>,)> =
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user