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:
200
crates/shared/src/subscriber_token.rs
Normal file
200
crates/shared/src/subscriber_token.rs
Normal file
@@ -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
|
||||
//! <base64url(payload)>.<base64url(signature)>
|
||||
//! ```
|
||||
//!
|
||||
//! 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<Sha256>;
|
||||
|
||||
/// 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<String>,
|
||||
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<TokenClaims, TokenError> {
|
||||
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<u8> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user