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:
MechaCat02
2026-06-04 20:18:50 +02:00
parent d064681c49
commit fcbcc576a2
35 changed files with 4333 additions and 63 deletions

View File

@@ -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();