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:
@@ -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"));
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<String>,
|
||||
ttl_seconds: Option<i64>,
|
||||
) -> Result<String, PubsubError> {
|
||||
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),
|
||||
|
||||
86
crates/shared/src/realtime.rs
Normal file
86
crates/shared/src/realtime.rs
Normal file
@@ -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<HashMap<(AppId, topic), broadcast::Sender>>`); 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<Utc>,
|
||||
}
|
||||
|
||||
#[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<broadcast::Receiver<RealtimeEvent>, 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<broadcast::Receiver<RealtimeEvent>, 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) {}
|
||||
}
|
||||
70
crates/shared/src/realtime_authority.rs
Normal file
70
crates/shared/src/realtime_authority.rs
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<dyn FilesService>`
|
||||
/// and `pubsub: Arc<dyn PubsubService>`.
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user