From 1b824cb45cb708b8fff9248210210a41108e2b5e Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 10 Sep 2026 00:02:30 +0200 Subject: [PATCH 01/21] feat(msg-bus): prepare engine --- Cargo.lock | 72 +- Cargo.toml | 2 +- docs/plan/message-bus.md | 1053 +++++++++++++++++ src/application/ports/mod.rs | 1 + src/application/ports/realtime_ports.rs | 493 ++++++++ .../services/file_upload_service.rs | 42 + src/application/services/folder_service.rs | 48 + src/common/di.rs | 45 +- .../services/in_process_realtime_bus.rs | 347 ++++++ src/infrastructure/services/mod.rs | 1 + src/interfaces/api/handlers/mod.rs | 1 + src/interfaces/api/handlers/rt_ws.rs | 552 +++++++++ src/interfaces/api/routes.rs | 10 + 13 files changed, 2650 insertions(+), 17 deletions(-) create mode 100644 docs/plan/message-bus.md create mode 100644 src/application/ports/realtime_ports.rs create mode 100644 src/infrastructure/services/in_process_realtime_bus.rs create mode 100644 src/interfaces/api/handlers/rt_ws.rs diff --git a/Cargo.lock b/Cargo.lock index 1dd449b5..870a7493 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -166,7 +166,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -177,7 +177,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -765,12 +765,13 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.9" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", "axum-macros", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -790,8 +791,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1 0.10.6", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -1998,6 +2001,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "datasketches" version = "0.2.0" @@ -2289,7 +2298,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3320,7 +3329,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -3671,7 +3680,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4419,7 +4428,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5265,7 +5274,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.40", - "socket2 0.6.4", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -5302,7 +5311,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -5820,7 +5829,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6343,7 +6352,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6855,7 +6864,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7086,6 +7095,18 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -7363,6 +7384,23 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.4", + "sha1 0.10.6", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "type1-encoding-parser" version = "0.1.1" @@ -7539,6 +7577,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8-ranges" version = "1.0.5" @@ -8323,7 +8367,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index aeb0fa2d..250ec8a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ bin-dir = "oxicloud-{ version }-{ target }/{ bin }{ binary-ext }" [dependencies] mimalloc = { version = "0.1.52", default-features = false } -axum = { version = "0.8.9", features = ["multipart", "http1", "http2", "tokio", "macros"] } +axum = { version = "0.8.8", features = ["multipart", "http1", "http2", "tokio", "macros", "ws"] } # "process" was previously enabled implicitly through aws-config's feature # unification; ffmpeg_video_frame_service needs it, so declare it ourselves. tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "io-util", "net", "time", "sync", "fs", "process", "signal"] } diff --git a/docs/plan/message-bus.md b/docs/plan/message-bus.md new file mode 100644 index 00000000..56ed4845 --- /dev/null +++ b/docs/plan/message-bus.md @@ -0,0 +1,1053 @@ +# Plan — Realtime message bus over WebSocket + +## Context + +OxiCloud today has no server-push channel. Every "live-ish" surface +(folder listing, job dashboard, share dialog, admin session count) is +either stale-until-refresh or polled by the SPA. That leaves a whole +category of features unreachable — collab editing, presence, +notifications, sync-client push invalidation — and it makes existing +surfaces feel dated compared to Google Drive, Notion, Nextcloud, and +M365. + +This plan introduces a single realtime bus over WebSocket that any +service can publish facts to and any client can subscribe to. Collab +editing is one consumer on top; folder-live updates, notifications, +job progress, presence, and sync-client push invalidation follow with +almost no extra scaffolding. + +## Non-goals + +- Persistent event log with "you missed these" replay. Durable state + lives in real tables (`notifications`, `collab.doc_sessions`, …); + the bus is a live-delivery optimization, always best-effort. +- Chat / DM / voice / video / screen share. Explicitly out of scope + for OxiCloud — that's Nextcloud Talk territory, not a file-server + job. +- Wildcard subscriptions (`folder:*`). Breaks per-subscribe AuthZ and + makes revocation semantics fuzzy. +- Cross-user subscriptions. Privacy + AuthZ risk. Admins subscribe to + `admin:*` topics, never to another user's private feed. + +## Architecture — 3 layers, clean seams + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ SERVICE LAYER │ +│ │ +│ FileMgmtService.create_file() ── after commit ──▶ bus.publish(...) │ +│ ShareService.grant() ── after commit ──▶ bus.publish(...) │ +│ JobRegistry step progress ─────────────────▶ bus.publish(...) │ +│ CollabSessionService.apply() ─────────────────▶ bus.publish(...) │ +│ │ +└───────────────────────────────┬──────────────────────────────────────┘ + │ publish(&Topic, RealtimeEvent) + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ REALTIME BUS (RealtimeBus trait — application/ports) │ +│ │ +│ InProcessRealtimeBus (v1) │ +│ DashMap> │ +│ │ +└──────┬───────────────────────────────────────────────────────────────┘ + │ + │ ┌──────────────────────────────────────────────────────┐ + │ │ REPLICATOR (optional, feature-flagged) │ + │ │ │ + │ │ BusReplicator trait ── separate port │ + │ │ - v1: NoopReplicator (single-instance) │ + │ │ - v2: PgListenReplicator (pg_notify) │ + │ │ - v3: BrokerReplicator (RabbitMQ / NATS) │ + │ │ │ + │ │ Sits BESIDE InProcessRealtimeBus, forwards │ + │ │ local publishes outbound + inbound events │ + │ │ from the broker back into local publish. │ + │ └──────────────────────────────────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ WS HANDLER (interfaces/api/handlers/rt_ws.rs) │ +│ │ +│ One RealtimeSession per WS: HashSet + outbound mpsc │ +│ - subscribe/unsubscribe frames → bus.subscribe(topic) │ +│ - each subscribed stream drains into the outbound mpsc │ +│ - AuthZ at subscribe (once), evict on grant-revoked │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +**The seam that keeps RabbitMQ/NATS doors open is the replicator, not +the bus.** Services and the WS handler only ever see the local +`RealtimeBus`. A future `BrokerReplicator` publishes outbound + injects +inbound. Zero touch to callers. + +## Backend components + +### 1. Port + event types (`application/ports/realtime_ports.rs`) + +```rust +// Topic is a typed enum, not a string. Prevents typos, gives +// exhaustive matching for the AuthZ gate, encodes stably to +// wire keys for any broker (RabbitMQ topic exchange, NATS subject). +pub enum Topic { + Folder(FolderId), + File(FileId), + Drive(DriveId), + UserNotifications(UserId), + UserAuthz(UserId), + UserSessions(UserId), + UserUploads(UserId), + Job(JobId), + Collab(FileId), + CollabAwareness(FileId), + FolderPresence(FolderId), + FilePresence(FileId), + FileComments(FileId), + Calendar(CalendarId), + AddressBook(AddressBookId), + AdminSessions, // admin-only + AdminAudit, // admin-only, sampled +} + +impl Topic { + pub fn to_wire_key(&self) -> String; // stable dotted form + pub fn parse(s: &str) -> Result; + pub fn required_perm(&self) -> AuthzCheck; // used by the AuthZ gate +} + +/// Wire mirror of `domain::services::authorization::Subject`. +/// Kept as its own type so the bus payload schema can evolve +/// independently of the domain enum. +#[derive(Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PrincipalRef { + User { id: UserId }, + Group { id: GroupId }, + Token { id: TokenId }, // anonymous public share link +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum RealtimeEvent { + // Folder / File verbs — thin facts only, client refetches details. + FileCreated { file_id: FileId, name: String, parent_id: FolderId, actor: UserId }, + FileDeleted { file_id: FileId, parent_id: FolderId, actor: UserId }, + FileRenamed { file_id: FileId, old_name: String, new_name: String, actor: UserId }, + FileMoved { file_id: FileId, from: FolderId, to: FolderId, actor: UserId }, + FolderCreated { folder_id: FolderId, parent_id: FolderId, actor: UserId }, + // Jobs + JobStep { job_id: JobId, step: u32, message: String }, + JobFinished { job_id: JobId, outcome: JobOutcome }, + // Notifications + Notification { notification_id: NotifId, kind: NotifKind }, + // Sharing — principal is any Subject (user, group, or public-link token). + // `affected_users` is populated by the producer only for the group case, + // so consumers of `file:{id}:shares` don't need to expand membership. + ShareGranted { + file_id: FileId, + principal: PrincipalRef, + role: GrantRole, + affected_users: Option>, + }, + ShareRevoked { + file_id: FileId, + principal: PrincipalRef, + affected_users: Option>, + }, + // Group membership changes — cascade grants to/from the affected user. + GroupMemberAdded { group_id: GroupId, user_id: UserId, actor: UserId }, + GroupMemberRemoved { group_id: GroupId, user_id: UserId, actor: UserId }, + // AuthZ eviction / re-evaluation signal + AuthzChanged { affected: Vec }, + // Presence (Phase B) + PresenceJoined { user_id: UserId, display_name: String, color: String }, + PresenceLeft { user_id: UserId }, + PresenceCursor { user_id: UserId, position: PresencePosition }, + // Collab (binary bytes on the wire, kept opaque in the enum) + CrdtUpdate { bytes: Bytes }, + // ... one variant per verb; enum > strings per project convention. +} + +#[async_trait] +pub trait RealtimeBus: Send + Sync { + /// Fire-and-forget. SYNC (not async) — services must not await + /// under a DB transaction. + fn publish(&self, topic: &Topic, event: RealtimeEvent); + + /// Returns a Stream so the impl can change (broadcast, mpsc, + /// pg listener) without churn. + fn subscribe(&self, topic: &Topic) -> Pin + Send>>; +} + +/// Kept SEPARATE from RealtimeBus so v2/v3 wiring is drop-in. +#[async_trait] +pub trait BusReplicator: Send + Sync { + /// Called whenever the local bus publishes; may forward to broker. + fn on_local_publish(&self, topic: &Topic, event: &RealtimeEvent); + + /// Long-running consumer task: reads remote messages and + /// re-publishes locally. Started by DI, returns on shutdown. + async fn run(self: Arc, shutdown: CancellationToken) -> Result<(), BusErr>; +} +``` + +#### Group principals and fan-out + +Grants target any `Subject` — `User(Uuid)`, `Group(Uuid)`, or +`Token(Uuid)` (public share link). A single `ShareGranted` event +therefore has **two distinct audiences with different delivery +paths**: + +| Audience | Topic | Payload use | +|---|---|---| +| Share dialog on the resource (anyone with `Share` watching it) | `file:{F}:shares` | Thin fact: "new grantee X with role R"; UI refetches grant list | +| Each affected user (persistent "shared with you") | `user:{member}:notifications` (one publish per member) | Becomes a `notif.notifications` row via `NotificationService::create` | + +The bus **never expands groups**. `NotificationService` is the +group-expansion boundary. `RealtimeBus` only fans out topics that +already exist as concrete `user:*` streams. + +Post-commit sequence for `ShareService::grant(file=F, principal=Group(G), role=R)`: + +``` +1. Insert grant row, COMMIT. +2. members = GroupService::expand_transitive(G) // via closure table +3. bus.publish(Topic::File(F).shares(), + ShareGranted { + principal: PrincipalRef::Group { id: G }, + role: R, + affected_users: Some(members.iter().copied().collect()), + }) // share-dialog fan-out +4. for member in &members { + notification_service.create(NewNotification { + recipient_id: *member, + kind: "share.granted_via_group_membership", + subject_type: "file", subject_id: F, + actor_id: caller, + data: json!({ "via_group": G, "role": R }), + }); + // create() inserts row AND publishes to user:{member}:notifications + } +5. for member in &members { + bus.publish(Topic::UserAuthz(*member), + AuthzChanged { affected: vec![Resource::File(F).into()] }); + } // WS handler re-checks subs +``` + +`Token(_)` principals (public share links) fan out to `file:{F}:shares` +and to a single `user:{creator}:notifications` (kind +`share.link_created` / `share.link_revoked`). No `AuthzChanged` — token +holders don't have WS sessions in this model. + +`GroupMemberAdded { group_id, user_id }` triggers the mirror cascade: +enumerate the group's grants → synthesize one +`share.granted_via_group_membership` notification per affected resource +for the new user → one `AuthzChanged` for the resource set. `Removed` +runs the revocation mirror. + +Bounded fan-out is baked in from day 1 (see § Limits & backpressure): +groups larger than `max_notification_fanout` (default 1000) drop the +per-user notifications with an `event = "notification.fanout_truncated"` +audit line; the `file:{F}:shares` event still fires, so the share dialog +stays accurate, and the recipients discover the grant via UI on next +visit. This case is realistically the "all-employees" scenario where +individual bell pings are noise anyway. + +Membership snapshotting: `GroupService::expand_transitive` runs at the +transaction boundary — we do NOT re-expand at publish time, because a +concurrent membership edit would then leak or duplicate deliveries. The +`members` set is captured then closed over into the post-commit block. + +Coalescing: `NotificationService::create` de-dupes on +`(recipient, kind, subject_type, subject_id, day)` within a short +window. Alice in both `G1` and `G2`, both granted `F`, gets one +notification, not two. + +### 2. In-process impl (`infrastructure/services/in_process_realtime_bus.rs`) + +- `DashMap>`, capacity 256 per topic. +- `subscribe` creates the entry lazily; wraps `Receiver` in + `BroadcastStream` (converts `Lagged` into a stream-level marker; WS + handler kills that session with a `revoked` frame, reason + `slow_consumer`). +- Background GC: when a topic's `receiver_count() == 0` for >60 s, + drop the sender. + +### 3. Replicator scaffolding (day-1) + +- `NoopReplicator` in v1. Wired in DI as `Arc`. +- `InProcessRealtimeBus::publish` calls + `replicator.on_local_publish(...)` **after** local fan-out. + +Futures: + +- **v2 — `PgListenReplicator`.** `pg_notify('oxi_rt', serde_json::to_string(event))` outbound; dedicated `sqlx::PgListener` connection inbound. Payload cap ~8 KB fine because events are thin. No new deployed service — reuses the existing PG. +- **v3 — `BrokerReplicator`.** + - **RabbitMQ:** topic exchange `oxi_rt`, per-server exclusive auto-delete queue bound to `#` (or per-topic bindings for broker-side filtering). Non-durable messages, no user-level queues. + - **NATS:** subject hierarchy = `oxi.rt.folder.{id}`, `oxi.rt.job.{id}`, etc. `Topic::to_wire_key()` maps directly. Core NATS (no JetStream) — ephemeral is the point. + +**Invariant for any broker impl:** no user- or session-scoped state at the broker. Servers hold sessions; the broker is stateless fan-out. Keeps replicator swaps painless and prevents per-user queue leaks. + +### 4. WS handler (`interfaces/api/handlers/rt_ws.rs`) + +- Route `GET /api/rt/ws`. +- **Auth strategy — three accepted paths, all reuse the existing + auth middleware:** + - **Browser session cookie** (`oxicloud_access` or whichever cookie + the auth middleware validates on REST). The WS upgrade request + carries cookies by default; the same `auth_middleware` + + `CurrentUserId` extractor produces `caller_id`. No new code path. + - **Bearer JWT via `Sec-WebSocket-Protocol`** — + `Sec-WebSocket-Protocol: oxi.rt.v1, authorization.bearer.`. + Standard workaround for browser `WebSocket` (which can't set an + `Authorization` header) and native clients like our + `rt-hurl-helper`. The handler reads the second subprotocol + element, validates the JWT via the same path as the REST auth + middleware, and echoes back the base subprotocol name in the + handshake response. + - **Ticket flow** (deferred until DPoP deployments matter) — + `POST /api/rt/ticket` issues a 30-second one-shot ticket, URL is + `/api/rt/ws?ticket=…`. NOT MVP; added when DPoP-strict + deployments make cookie/bearer over WS awkward. Callers today + have no functional need for it. +- On upgrade: + - Extract `caller_id` from the auth mechanism above. + - Auto-subscribe to `user:{caller}:notifications`, + `user:{caller}:authz`, `user:{caller}:sessions`. + - Spawn `RealtimeSession` actor: owns `HashSet`, outbound + `mpsc::Sender` (bounded 512), one reader task per + subscribed topic. +- Per-frame: + - `subscribe`: dispatch on `topic.required_perm() -> AuthzCheck` + and run the matching gate. Three classes exist — + resource-scoped (typically `Read`, sometimes `Share` / + `Comment`), identity-scoped (`caller_id == subject_uuid`, no + admin bypass), role-scoped (`caller.role == Admin`); plus the + bespoke job-originator-or-admin check for `job:{id}`. Full + matrix in **§ AuthZ model**. Deny → `denied` frame + audit + `event = "realtime.subscribe_denied"`. Allow → subscribe on bus, + ack. + - `unsubscribe`: drop the reader task for that topic. + - `ping/pong` for keepalive. + - Binary CRDT frame: route to `CollabSessionService` (see + `docs/plan/markdown-collab.md`), not the generic path. +- On `user:{caller}:authz` event: walk session's subs, re-check each, + evict any that lost access (`revoked` frame with reason + `grant_revoked`). +- On resource-delete: bus publishes `AuthzChanged` for affected → same + eviction path. +- Outbound queue full → close WS with 1013 "try again later"; client + reconnects, refetches, resubs. + +### 5. Service integration — publish AFTER commit + +Rule: **`bus.publish` is called after the DB transaction commits, +never before, never inside**. If publish were inside the tx, a +rollback would still fan out to clients. If publish were async and +awaited, a slow subscriber could hold the tx open. + +Pattern: services return `(result, Vec)` from the tx +boundary; the calling layer publishes after commit. Or a +`TxCommitHook` queues events and flushes on commit. Pick one, apply +everywhere. + +## Frontend components + +### 1. Singleton client (`lib/stores/realtime.svelte.ts`) + +- Fetches a ticket via `POST /api/rt/ticket` (through `apiFetch`, so + DPoP is applied). +- Opens `wss:///api/rt/ws?ticket=…`. +- **Refcounted subscriptions**: + `subs: Map }>`. +- On subscribe by first component: send frame; on last unsubscribe: + send frame. +- On reconnect: reissue ticket, re-establish WS, re-send `subscribe` + for every live topic — components don't care. +- Backoff: exponential (250 ms → 30 s), full-jitter. +- Health: `$state({ connected, latencyMs, subscribedTopics })` + exposed for a debug indicator. + +### 2. Composable (`lib/composables/useTopic.ts`) + +```ts +useTopic(`folder:${folderId}`, (evt) => { /* mutate local $state */ }); +``` + +Handles `$effect` lifecycle (subscribe on mount, unsubscribe on +destroy). Zero connection awareness in components. + +## Wire protocol + +Two wire formats share the same WS connection: + +- **Control + notifications: JSON-RPC 2.0** — universally recognized, + no library needed on either side, standard `id`-correlated + responses, standard `error` object shape, id-less notifications for + server-pushed events. Adopts the same well-known framing as + Ethereum node WS APIs, LSP-over-WS, and countless other services; + costs ~30 bytes/message over a bespoke shape and buys instant "oh, + it's JSON-RPC" recognition + off-the-shelf client compat. +- **CRDT binary frames: Yjs sync protocol** — de-facto standard in the + Yjs ecosystem, kept as-is because it's the reason we picked Yjs. + +Method namespace for our JSON-RPC methods: `rt.*` (short for +realtime). Prevents collisions if we ever expose additional RPCs on +the same WS (not planned, but the namespace costs nothing). + +### JSON-RPC frames (control + events) + +```jsonc +// c → s (requests — id-correlated) +{ "jsonrpc": "2.0", "id": 42, "method": "rt.subscribe", + "params": { "topic": "folder:abc" } } +{ "jsonrpc": "2.0", "id": 43, "method": "rt.unsubscribe", + "params": { "topic": "folder:abc" } } +{ "jsonrpc": "2.0", "id": 44, "method": "rt.ping" } + +// s → c (responses to requests — same id) +{ "jsonrpc": "2.0", "id": 42, + "result": { "subscribed": "folder:abc" } } +{ "jsonrpc": "2.0", "id": 43, + "result": { "unsubscribed": "folder:abc" } } +{ "jsonrpc": "2.0", "id": 44, + "result": { "pong": true } } + +// s → c (denials — same id, standard JSON-RPC error object) +{ "jsonrpc": "2.0", "id": 42, + "error": { "code": -32001, "message": "no_read", + "data": { "topic": "drive:xyz" } } } + +// s → c (events — id-less = JSON-RPC notification) +{ "jsonrpc": "2.0", "method": "rt.event", + "params": { + "topic": "folder:abc", + "event": "file_created", + "data": { "file_id": "…", "name": "notes.md", + "parent_id": "abc" }, + "actor": { "user_id": "…" }, + "ts": "2026-09-08T20:12:00Z" + }} + +// s → c (server-initiated eviction — also a notification) +{ "jsonrpc": "2.0", "method": "rt.revoked", + "params": { "topic": "folder:abc", "reason": "grant_revoked" } } +``` + +### Yjs binary frames (CRDT — Phase A collab consumer) + +``` +[1 byte kind][16 bytes doc_id][payload…] + 0x01 = Yjs update → collab:{doc_id} + 0x02 = Yjs awareness → collab:{doc_id}:awareness + 0x03 = Yjs sync-step → collab:{doc_id} +``` + +The WS handler classifies incoming frames by the `MessageType` +(text/binary). Text frames are JSON-RPC; binary frames are Yjs sync +protocol routed to `CollabSessionService` (see +`docs/plan/markdown-collab.md`). + +### JSON-RPC error codes (stable — never repurpose) + +Uses the JSON-RPC 2.0 "server-defined" range `-32000` to `-32099`, +per spec (`-32700..=-32000` is the reserved-by-spec block; `-32000` +downward is application-defined). + +| `code` | `message` | Meaning | Audit `reason` variants | +|---|---|---|---| +| `-32001` | `"no_read"` | Resource-scoped topic, caller lacks Read (or resource doesn't exist — indistinguishable to caller by design). Anti-enum invariant. | `no_read`, `no_such_resource` | +| `-32002` | `"no_share"` | Resource-scoped topic requiring `Share`, caller has Read but not Share. Applies to `file:{id}:shares` (Phase B). | `no_share` | +| `-32003` | `"no_comment"` | Resource-scoped topic requiring `Comment` (`file:{id}:comments` Phase B). | `no_comment` | +| `-32004` | `"topic_forbidden"` | Identity-scoped mismatch OR unknown/malformed topic. Same wire code regardless of whether the target user exists — anti-enum. | `identity_mismatch`, `unknown_topic`, `not_admin` | +| `-32005` | `"sub_limit"` | Per-connection sub cap hit. | `sub_limit` | +| `-32006` | `"rate_limited"` | Subscribe-frame token bucket exhausted. | `rate_limited` | +| `-32007` | `"no_edit"` | CRDT edit frame from a caller without `Edit`. Emitted as a `rt.write_denied` notification (not tied to a request `id`). | `no_edit` | +| `-32603` | `"internal_error"` | Standard JSON-RPC internal error — server-side failure the client should retry. | — (server log) | +| `-32600` | `"invalid_request"` | Malformed JSON-RPC envelope (missing `method`, wrong `jsonrpc` version). Standard JSON-RPC. | `bad_envelope` | +| `-32601` | `"method_not_found"` | Method outside the `rt.*` allowlist. Standard JSON-RPC. | `unknown_method` | +| `-32602` | `"invalid_params"` | Method known but `params` shape wrong (missing `topic`, unparseable). Standard JSON-RPC. | `bad_params` | + +Codes `-32001..=-32007` are our application-defined vocabulary; the +`-326xx` range is JSON-RPC's own standard set and we honour it for +envelope-level problems. Both are stable — a new denial cause gets a +new code, we never repurpose an existing one, per project convention. + +### Sec-WebSocket-Protocol subprotocol advertisement + +Client's WS handshake sends: +`Sec-WebSocket-Protocol: oxi.rt.v1, authorization.bearer.` + +Server accepts the handshake with `Sec-WebSocket-Protocol: oxi.rt.v1` +(the bearer half is consumed for auth, not echoed). The `v1` gives +us a bump-when-we-break contract handle; adding new methods stays +backward-compatible under `oxi.rt.v1`. + +### Payload discipline + +Event payloads are **thin facts** (IDs + actor + verb). Never full +DTOs — client refetches details via REST if it needs them. Keeps the +AuthZ surface small (thin payloads can't leak fields the caller +couldn't already read via REST for that resource) and makes the pg +NOTIFY 8 KB cap a non-issue. + +## AsyncAPI generation + +Mirror OpenAPI's role for the REST surface. The WS surface gets a +machine-readable AsyncAPI 3.0 document generated from the same Rust +enums the server uses, so the wire contract stays in sync with +implementation by construction — no hand-written spec that drifts. + +### What it documents + +- **Server info + subprotocol** — `oxi.rt.v1` under + `Sec-WebSocket-Protocol`, connect URL, auth mechanisms. +- **Channels** — one per topic-kind (`folder`, `file`, `job`, + `user-notifications`, `collab`, …), parameterized by their id: + `folder/{folderId}`, `job/{jobId}`, etc. +- **Operations per channel:** + - `send` — client subscribe / unsubscribe via `rt.subscribe` / + `rt.unsubscribe` (JSON-RPC request messages). + - `receive` — server events via `rt.event` notifications. +- **Message schemas** — the JSON-RPC envelope and one schema per + `event` variant (`file_created`, `folder_created`, + `share_granted`, `notification`, …). Generated via `schemars` from + the same Rust `RealtimeEvent` enum the server publishes, so the + schema is authoritative, not aspirational. +- **Error object shape + `code`/`message` catalog** — the JSON-RPC + error table above becomes an AsyncAPI-declared `errors` block on + the subscribe operation. +- **Binary frame schema** — a `application/octet-stream` message + binding for the Yjs sync protocol frames, with a text description + of the `[kind][doc_id][payload]` layout. AsyncAPI schemas can't + fully describe the Yjs framing (it's out-of-band from the JSON + envelope), so we document the structure in prose alongside a + placeholder schema — same tradeoff every WS spec makes with binary + bodies. + +### Generator — `cargo run --bin generate-asyncapi` + +Follows the same shape as `generate-openapi`: + +- New binary `src/bin/generate_asyncapi.rs` that constructs the + spec from `Topic`, `RealtimeEvent`, `AuthzCheck`, and the JSON-RPC + method/error tables — all live in `application/ports/realtime_ports.rs` + as the single source of truth. +- Uses `schemars` for JSON Schema of each event variant (already + compatible with `serde` derives; no re-annotation needed). +- Emits `resources/gen/asyncapi.yaml` (YAML for human-diffability, + same choice AsyncAPI tooling defaults to). +- Add `just asyncapi` recipe alongside `just openapi`. +- CI check: same as the OpenAPI check — regenerate on every build, + fail if the working tree is dirty after regeneration. Keeps spec + and code from drifting. + +### Consumers + +- **Docs site** — AsyncAPI has a first-class HTML renderer + (`@asyncapi/html-template` or the Studio playground). Point the + docs at `resources/gen/asyncapi.yaml` and the WS surface has the + same discoverability as `openapi.json`. +- **Client SDK generation (later)** — `@asyncapi/generator` produces + typed clients (TS, Go, Python, Java). Not needed for v1, but the + door is open when a third-party integration asks for one. +- **Contract testing (later)** — the spec doubles as a contract the + smoke tests can assert against; `rt-hurl-helper` could validate + incoming events against the schema before asserting on values. + Cheap follow-up. + +### Scope for the first PR + +- Generator produces spec covering the Phase-A-MVP surface only + (`rt.subscribe` / `rt.unsubscribe` / `rt.ping` methods, + `rt.event` / `rt.revoked` notifications, `Folder(id)` and + `UserAuthz(u)` topics, `FileCreated` / `FolderCreated` events, + the error-code table). +- Adding a new topic/event/method later is an enum variant + serde + derive → regenerate → commit. Same discipline as OpenAPI. + +## AuthZ model (audit rules per AGENTS.md) + +### The subscribe gate + +"At least Read on the resource" is the **default** for resource-scoped +topics, but not the whole story. Every topic variant declares its own +gate via `Topic::required_perm() -> AuthzCheck`. Three classes exist — +the WS handler dispatches on the returned enum, it does not assume a +single check applies everywhere. + +#### Class 1 — Resource-scoped (majority) + +Default gate: `AuthorizationEngine::require(caller, resource, Read)`. + +| Topic | Resource | Permission | +|---|---|---| +| `folder:{id}` | folder | `Read` | +| `folder:{id}:presence` | folder | `Read` | +| `file:{id}` | file | `Read` | +| `file:{id}:presence` | file | `Read` | +| `collab:{file_id}` | file | `Read` (Reader = view + own cursor; edits gate separately, see below) | +| `collab:{file_id}:awareness` | file | `Read` | +| `drive:{id}` | drive | `Read` (drive membership) | +| `calendar:{id}` | calendar | `Read` | +| `addressbook:{id}` | address book | `Read` | + +Two Phase-B resource topics use a **stricter** permission because the +topic itself would leak enumeration metadata a Reader can't otherwise +see today: + +| Topic | Actual permission | Why not Read | +|---|---|---| +| `file:{id}:shares` | `Share` (Owner-tier) | Reader sees the file's content, not who else has access. The share list is management metadata; the REST share endpoints already gate this way. | +| `file:{id}:comments` | Whatever the REST comments API decides — `Read` if comments are public to Readers; `Comment` if commenter-tier only | Consistency with REST. The bus does not invent a new policy. | + +#### Class 2 — Identity-scoped + +Gate: `caller_id == subject_uuid`. Plain equality. **No admin bypass** +— an admin cannot subscribe to `user:{other}:notifications`. Privacy is +a hard rule; cross-user monitoring uses admin topics, never a user's +private stream. + +| Topic | Gate | +|---|---| +| `user:{u}:notifications` | caller == u | +| `user:{u}:authz` | caller == u | +| `user:{u}:sessions` | caller == u | +| `user:{u}:uploads` | caller == u | +| `user:{u}:trash` | caller == u | + +Auto-subscribed topics (`user:{caller}:*`) at connect go through the +same check for consistency — the caller identity is derived from the +validated ticket, so this is by construction, but the code path must +not short-circuit. + +#### Class 3 — Role-scoped + +Gate: `caller.role == Admin` (or specific admin sub-role once we +introduce them). + +| Topic | Gate | +|---|---| +| `admin:sessions` | admin role | +| `admin:audit` | admin role | + +#### One non-resource topic — bespoke check + +| Topic | Gate | +|---|---| +| `job:{id}` | `jobs.created_by == caller` **OR** admin role. Jobs are not in the AuthZ engine's resource set; the check lives in `Topic::required_perm()` and queries the job registry. | + +### Enforcement rules + +1. **AuthZ at subscribe time, not per event.** Fan-out is hot; + subscribe is the choke point. Checking every event against every + subscriber's grants would burn CPU on busy topics. +2. **Evict on grant loss** — do NOT keep re-checking to preserve a + sub. The write path publishes `AuthzChanged { affected }` to + `user:{u}:authz`; the WS handler walks that session's + `HashSet` and drops any sub whose resource intersects + `affected`. Same eviction path for resource-delete, group-member + removal, and admin kicks. +3. **Anti-enumeration on denials.** Per the graduated-denial + convention (see `authz_require_graduated_denial`), the wire reason + collapses cases the caller cannot distinguish; the audit line + records the truth. +4. **CRDT edit frames re-check on the write side.** A Reader can hold + a `collab:{file}` sub (view + cursor); their `0x01` update frames + are dropped by the WS handler with `collab.write_denied` audit + (`reason = no_edit`). Verified once per session and re-verified on + `user:{caller}:authz` events. + +### Wire-reason vocabulary (stable — never repurpose) + +The wire uses JSON-RPC 2.0 `error` objects — see **§ Wire protocol → +JSON-RPC error codes** for the full `code`/`message`/audit-`reason` +mapping. That table is the authoritative one; this section +cross-references its audit-reason column for the AuthZ dispatch and +confirms the anti-enumeration collapse rules the wire honours. + +### Audit-line convention + +- **Connect reject** — `event = "auth.rt_ticket_rejected"`, + `reason ∈ {expired, unknown, ip_mismatch, replay}`. +- **Subscribe deny** — `event = "realtime.subscribe_denied"`, `reason` + from the audit column above, plus `caller_id`, `topic`. Emitted + BEFORE the wire `denied` frame. +- **Evict** — `event = "realtime.subscription_evicted"`, + `reason ∈ {grant_revoked, resource_deleted, admin_kick, group_membership_lost}`, + plus `caller_id`, `topic`. +- **Collab edit rejected** — `event = "collab.write_denied"`, + `reason ∈ {no_edit, session_evicted, external_write_conflict}`. +- **Notification fanout truncated** — `event = + "notification.fanout_truncated"`, `reason = "over_max_fanout"`, + `resource_id`, `principal`, `member_count`. + +Every audit line uses `target: "audit"` per project convention. Wire +reasons are the compressed public vocabulary; audit reasons are the +uncompressed private truth. + +## Limits & backpressure + +| Limit | Default | Rationale | +|---|---|---| +| Subs per connection | 128 | Prevents runaway/malicious pinning of server memory | +| Subscribe frames/sec/conn | 50 | Token bucket, prevents storm-subscribing | +| Outbound mpsc slots/conn | 512 | Full → close WS 1013 | +| Broadcast ring slots/topic | 256 | Slow subscriber → lag → close WS + audit | +| Max event size | 8 KB | Fail-fast dev assertion; keeps pg NOTIFY cap a non-issue | +| Ticket TTL | 30 s | Short window, one-shot | +| `max_notification_fanout` | 1000 recipients / event | Beyond this, drop per-user notifications + audit `notification.fanout_truncated`; the `file:{id}:shares` event still fires. Covers the "all-employees" group case where individual bell pings would be noise. | + +## Failure modes + +- WS drop mid-session → client reconnects, ticket flow again, + re-subscribes. Server discards session state. +- Publish under load → `broadcast::Sender::send` never blocks; slow + subs lag out. Never let the publish path stall. +- Ticket replay → ticket is one-shot in-memory; second use is + `denied` + audit. +- Post-commit publish failing → log a warning and move on. Do NOT + retry into a queue; ephemeral events are best-effort by design. +- Replicator down (v2+) → local bus keeps working for same-instance + subs; log the outage; alert. + +## First PR — MVP scope and hurl smoke test + +The smallest slice that proves fan-out works, topics are isolated, +and the AuthZ gate rejects unauthorized subscribes. Everything +larger (notifications table, presence, collab) rides on top later. + +### Scope in + +- `RealtimeBus` port + `InProcessRealtimeBus`. +- WS handler at `GET /api/rt/ws` with `subscribe` / `unsubscribe` / + `ping` frames only (no CRDT binary frames yet). +- Auth: reuse existing `auth_middleware` — session cookie for + browsers OR bearer JWT via `Sec-WebSocket-Protocol: + oxi.rt.v1, authorization.bearer.` for programmatic clients. + Ticket flow deferred. +- Topics: `Folder(id)` (Class 1 — Resource-scoped, `Read`) and + `UserAuthz(u)` (Class 2 — Identity-scoped, auto-subscribed at + connect). No other topics accepted in MVP; parser returns + `Unknown` → `denied` with `reason = topic_forbidden`. +- Events: `FileCreated`, `FolderCreated`. Publish hooks added in + `FolderService::create_folder_with_perms` and + `FileManagementService`'s file-create path (upload / chunked + upload commit — publish AFTER commit only). + +### Scope out (later PRs, not this one) + +- Delete / rename / move publishes (same pattern, verified after + create works). +- `user:{u}:notifications` topic, notifications table, bell UI. +- `job:{id}` topic, `collab:{id}` binary frames. +- Grant-revocation eviction (still enforced structurally via + `Topic::required_perm` at subscribe, but no live evict-on-change + wiring — that comes with the `AuthzChanged` publish hook in a + follow-up). +- Ticket flow, rate limiting on subscribe frames, slow-subscriber + metrics. +- Frontend integration (`useTopic`, folder-view autorefresh). +- `PgListenReplicator` — v2 multi-instance. + +### Test surface — `rt-hurl-helper` (follows existing convention) + +The api-test suite is entirely HTTP via hurl and cannot drive +WebSocket. Precedent for auxiliary Rust binaries exists in +`opaque-hurl-helper` and `dpop-hurl-helper` (both built with +`--features test_utils`, both invoked from `tests/api/run.sh` +outside the main hurl block). The bus test follows the same +pattern. + +**New binary:** `src/bin/rt_hurl_helper.rs`, gated on +`test_utils`. Sole new crate dependency: +`tokio-tungstenite` — added under `[dependencies.tokio-tungstenite] +optional = true` and pulled in by the `test_utils` feature so the +release binary is unaffected. Never ships in production. + +**CLI shape:** + +``` +oxi-rt-hurl-helper [flags] + + subscribe-and-collect # runs in background alongside hurl + --url ws://.../api/rt/ws + --token JWT # bearer, passed via Sec-WebSocket-Protocol + --subscribe TOPIC # may repeat + --expect-events N # exit 0 when N events arrive + --timeout DURATION # overall cap, default 3s + --output PATH # write JSON summary on exit + + expect-denied # runs synchronously + --url ws://.../api/rt/ws + --token JWT + --subscribe TOPIC + --reason KEY # expected denial reason, default: any + --timeout DURATION # default 2s +``` + +Exit codes: `0` = expectation met, `1` = expectation failed +(wrong event, unexpected event, timeout without hitting the target, +denied when expecting event, or vice versa), `2` = protocol error +/ connect failure. + +Output JSON schema (for post-mortem assertions in shell): + +```jsonc +{ + "subscribed": ["folder:"], + "denied": [], + "events": [ { "topic": "folder:", "event": "file_created", + "data": { "file_id": "…", "name": "…", + "parent_id": "", "actor": "…" }, + "ts": "2026-…" } ], + "timed_out": false, + "protocol_err": null +} +``` + +### Coverage — four scenarios, each in the same test file + +Orchestrated by a single `tests/api/rt_bus_check.sh` invoked from +`tests/api/run.sh` after the main hurl block. Follows the +`refcount_cascade` / `thumb_import_check` patterns already in place. + +**Scenario 1 — Positive delivery** (fan-out works) + +``` +setup.hurl: + - user1 logs in → capture $USER1_TOKEN + - user1 creates folder A → capture $FOLDER_A + +shell: + rt-hurl-helper subscribe-and-collect \ + --token $USER1_TOKEN --subscribe folder:$FOLDER_A \ + --expect-events 1 --timeout 3s --output /tmp/rt_s1.json & + sleep 0.3 # give the subscribe frame time to ack + +actions.hurl: + - user1 creates a file in $FOLDER_A + +wait rt-hurl-helper +``` + +Assertion (jq on `/tmp/rt_s1.json`): +- `.timed_out == false` +- `.events | length == 1` +- `.events[0].event == "file_created"` +- `.events[0].data.parent_id == $FOLDER_A` + +**Scenario 2 — Topic isolation** (no event on unsubscribed folder) + +Verifies: a user subscribed only to folder A does NOT receive +events for actions in folder B, even when the user has full access +to both. + +``` +setup.hurl: + - user1 creates folder B → capture $FOLDER_B (folder A from S1 reused) + +shell: + rt-hurl-helper subscribe-and-collect \ + --token $USER1_TOKEN --subscribe folder:$FOLDER_A \ + --expect-events 1 --timeout 3s --output /tmp/rt_s2.json & + sleep 0.3 + +actions.hurl: + # First: create a file in B — user1 has full access, but we're + # not subscribed to B, so nothing should arrive on the helper. + - user1 creates a file in $FOLDER_B + # Second: create a file in A — this triggers the helper's exit. + - user1 creates a file in $FOLDER_A + +wait +``` + +Assertion: +- `.events | length == 1` +- `.events[0].data.parent_id == $FOLDER_A` ← NOT B +- no event with `parent_id == $FOLDER_B` present + +The key invariant this locks in: **the server fans out per topic, +not per user or per drive**. A subscriber to `folder:A` sees only +`folder:A` events, even for topics they'd have permission to +subscribe to but didn't. + +**Scenario 3 — AuthZ denial** (subscribe rejected on missing Read) + +Verifies: a user without `Read` on a folder cannot subscribe to +its topic. Denial wire reason is `no_read`; audit line records +`realtime.subscribe_denied` with `reason ∈ {no_read, +no_such_resource}`. + +``` +setup.hurl: + - user2 registers and logs in → capture $USER2_TOKEN + - (user2 has no grant on $FOLDER_A, which is user1's private folder) + +shell: + rt-hurl-helper expect-denied \ + --token $USER2_TOKEN --subscribe folder:$FOLDER_A \ + --reason no_read --timeout 2s + # exit 0 = denied frame received with reason=no_read +``` + +Assertion is the helper's exit code (`0` pass, `1` fail). No +`/tmp` output file needed for a binary pass/fail. + +**Scenario 4 — Anti-enumeration parity** (nonexistent folder ≡ no +access, from the caller's POV) + +Verifies: subscribing to a folder that does not exist returns the +**same** wire reason as subscribing to a folder the caller can't +Read. Protects against a folder-enumeration oracle. + +``` +shell: + rt-hurl-helper expect-denied \ + --token $USER2_TOKEN --subscribe folder:00000000-0000-0000-0000-000000000000 \ + --reason no_read --timeout 2s + # exit 0 = same wire reason as scenario 3 +``` + +Assertion: exit code 0. The audit line (checked out-of-band if we +wire log capture) records `reason = "no_such_resource"` — but the +wire reason is `no_read`, matching scenario 3. This is the +graduated-denial invariant from `authz_require_graduated_denial`. + +### How the scenarios chain + +All four run in one shell script, one WS connection is opened per +scenario for isolation (a helper invocation = a fresh WS). No +state carries between scenarios except the folder ids and tokens +captured in `setup.hurl`. Total wall-clock ≤ 10 s including +sleeps. + +### Cleanup + +Follows the existing api-test convention (per project memory +`api_tests`): + +- Shared DB is dropped between full test-suite runs by + `tests/common/stop-db.sh`. +- Storage is wiped at run start. +- No per-scenario teardown; folders A and B persist for the rest + of the run — no test that runs after this cares about them. + +### justfile / CI hook + +Add to the existing `test-api` recipe list of Rust helper builds +(there's already a compile step for `opaque-hurl-helper` / +`dpop-hurl-helper`); the new binary joins the same +`--features test_utils` build. `tests/api/run.sh` gets one line — +`./rt_bus_check.sh || die "rt bus smoke failed"` — inserted after +the main hurl block, before the existing storage-cleanup / thumb +checks. + +### What this coverage locks in + +- Subscribe path AuthZ gate is real (S3, S4). +- Anti-enumeration parity between "no perm" and "no resource" (S4) + — the invariant the plan promises. +- Fan-out is topic-scoped, not user-scoped (S2). +- Publish-after-commit produces exactly one event per action (S1), + not zero (rollback lost the publish) and not multiple (retry / + double-hook). +- End-to-end wire format is stable (S1 asserts on + `event = "file_created"` string). + +Everything else in the plan — evict-on-revoke, slow-subscriber +kick, rate limiting, ticket flow, PgListen replicator — is +follow-up test work with its own scenarios, layered on top of +this baseline once the baseline is green. + +--- + +## Roadmap + +### Phase A — Foundation (bus + notifications + MD collab) + +Ships the infrastructure and the two most visible consumers together. + +- Bus port + `InProcessRealtimeBus` + `NoopReplicator` + WS handler + + ticket endpoint. +- Frontend singleton + `useTopic` composable. +- Topics live: `folder:{id}`, `user:{u}:notifications`, `job:{id}`, + `collab:{file_id}`, `collab:{file_id}:awareness`. +- **Folder-live updates**: `FolderService` / `FileManagementService` + publish `file.created` / `file.deleted` / `file.renamed` / + `file.moved` after commit; FE folder view subscribes and mutates + local state — no manual refresh. +- **Job dashboard live**: `JobRegistry` publishes step progress and + terminal state; FE job dashboard subscribes and replaces the + current polling. +- **Notifications table + bell**: new `notifications` table + + `NotificationService` port; initial ingesters for `share-granted`, + `new-login-from-new-device`, `job-completed-for-you`, + `storage-quota-threshold`. FE bell with unread count, slide-out + panel, toast pop on receive. +- **MD collab editor**: see companion plan + `docs/plan/markdown-collab.md` — depends on this phase's WS + handler + binary frame routing. + +Deliverables sized ~4 weeks end-to-end. + +### Phase B — Presence + comments + +Everything that turns OxiCloud from a file store into a shared +workspace. + +- **Presence topics** — `folder:{id}:presence`, `file:{id}:presence`. + Awareness-style: joined/left/cursor. Ephemeral, not persisted. +- **FE presence UI**: "N people viewing" badge in folder header; + avatar rail; hover to highlight; "someone is previewing this photo + right now" in the lightbox. +- **Comments on any file** — new `comments` table (threaded, per + file, supports reactions), `CommentService` port, + `file:{id}:comments` topic for live delivery. +- **@mentions**: mention autocomplete in the comment editor; + mention → notification into the mentioned user's + `user:{u}:notifications` topic + `notifications` row + optional + email (reuses existing `MagicLinkMailer`-style templating). +- **Reactions**: 👍❤️🎉 on comments and on files themselves; live + fan-out on the same `file:{id}:comments` topic. +- **Comment resolutions**: Google-Docs-style thread markers. + +Deliverables sized ~3 weeks after Phase A. + +### Phase C — Sync client push + album live + +Where the bus starts paying for itself on infrastructure cost too. + +- **Sync-client push invalidation**: WebDAV / NextCloud DAV handlers + publish `file:{id}` and `folder:{id}` deltas after commit. Sync + clients get a lightweight `Sync-Invalidate` mechanism (or a + dedicated WS endpoint for headless clients) so they refetch only + changed paths instead of polling PROPFIND. Cuts a large chunk of + Nextcloud-style client chatter. +- **Album live updates**: `folder:{album_id}` reused — as photos are + added to an album, everyone viewing sees them appear. +- **Slideshow sync**: one presenter picks "Present"; other viewers of + the album can opt-in to follow the presenter's current frame. + Uses `folder:{album_id}` with a `presenter_frame` event kind. + +Deliverables sized ~2–3 weeks after Phase B. + +### Later — multi-instance & broker + +Only invoked when the deployment actually needs it. Nothing above +depends on these landing on any fixed date. + +- **`PgListenReplicator`** — ship when we run more than one server + instance. Same port, no consumer changes. +- **`BrokerReplicator`** for RabbitMQ or NATS — ship when either + cross-datacenter fan-out or a shared broker with other services + matters. Same port, no consumer changes. + +## What this bus does NOT replace + +- Message queue / job queue — jobs stay in `job_registry`; bus just + carries their progress live. +- Audit log — stays `tracing target: "audit"`. +- Email — `NotificationService`'s deliverer for offline users. +- Durable per-user "inbox" — the `notifications` table is the source + of truth; bus is the live-delivery optimization. diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index 53c8e8db..a246d661 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -22,6 +22,7 @@ pub mod music_ports; pub mod opaque_ports; pub mod outbound; pub mod plugin_ports; +pub mod realtime_ports; pub mod recent_ports; pub mod resource_access_hook; pub mod share_ports; diff --git a/src/application/ports/realtime_ports.rs b/src/application/ports/realtime_ports.rs new file mode 100644 index 00000000..a3bd1165 --- /dev/null +++ b/src/application/ports/realtime_ports.rs @@ -0,0 +1,493 @@ +//! Realtime message-bus port — the seam every service publishes through and +//! every WS session subscribes on. +//! +//! # Design (see `docs/plan/message-bus.md`) +//! +//! - [`RealtimeBus`] is the **local-facing** trait: services publish, the WS +//! handler subscribes. It never involves the network. +//! - [`BusReplicator`] is the OPTIONAL seam that mirrors local publishes to +//! and from a broker (pg `LISTEN/NOTIFY`, RabbitMQ, NATS). Callers see only +//! [`RealtimeBus`]; a real replicator plugs into the in-process impl without +//! touching consumers. Day-1 impl is [`NoopReplicator`]. +//! +//! # MVP scope +//! +//! Ships the smallest slice that lets the smoke test verify a folder +//! subscription receives file/folder-created events and rejects subscribes +//! to folders the caller can't `Read`: +//! +//! - Topics: [`Topic::Folder`] and [`Topic::UserAuthz`] +//! - Events: [`RealtimeEvent::FileCreated`], [`RealtimeEvent::FolderCreated`] +//! +//! Adding a variant is a one-line change plus a match arm in `to_wire_key` / +//! `parse` / `required_perm`. Other topics (`file:{id}`, `job:{id}`, +//! `collab:{id}`, `user:{u}:notifications`, …) land with their producers in +//! Phase-A follow-ups. +//! +//! # Wire protocol +//! +//! JSON-RPC 2.0 for control + events (text frames), Yjs sync protocol for +//! CRDT (binary frames). This module owns the JSON-RPC error-code +//! vocabulary; see [`error_code`]. + +use std::pin::Pin; +use std::sync::Arc; + +use futures::Stream; +use serde::{Deserialize, Serialize}; +use tokio::sync::Notify; +use uuid::Uuid; + +use crate::common::errors::DomainError; + +// ════════════════════════════════════════════════════════════════════════════ +// Topic — a typed key on the bus +// ════════════════════════════════════════════════════════════════════════════ + +/// A topic on the realtime bus. Typed enum, not a string — prevents typos +/// and gives exhaustive matching in the AuthZ dispatch and the wire encoder. +/// +/// Encodes to a stable dotted wire key that maps naturally onto RabbitMQ +/// topic-exchange routing keys or NATS subjects when the [`BusReplicator`] +/// seam is filled in later. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Topic { + /// A folder's mutation stream — file/subfolder created/deleted/renamed/ + /// moved in or out. Consumed by the folder view for live refresh. + Folder(Uuid), + + /// A user's private authz-change channel. The WS handler will auto- + /// subscribe the caller and evict stale subs when its events fire once + /// the eviction wiring lands (Phase-A follow-up). + UserAuthz(Uuid), +} + +impl Topic { + /// Stable dotted wire form used by the JSON-RPC control frames and any + /// future broker routing keys. Reverse of [`Topic::parse`]. + pub fn to_wire_key(&self) -> String { + match self { + Topic::Folder(id) => format!("folder:{id}"), + Topic::UserAuthz(id) => format!("user:{id}:authz"), + } + } + + /// Parse a wire-form topic string. Rejects unknown shapes with a stable + /// error kind so the WS handler can respond with a JSON-RPC error object + /// (`topic_forbidden` for unknown topic shapes, `no_read` for known + /// shapes the caller can't reach — the latter after the AuthZ check). + pub fn parse(s: &str) -> Result { + if let Some(rest) = s.strip_prefix("folder:") { + let id = Uuid::parse_str(rest).map_err(|_| ParseTopicErr::BadUuid)?; + return Ok(Topic::Folder(id)); + } + if let Some(rest) = s.strip_prefix("user:") + && let Some((id_str, "authz")) = rest.rsplit_once(':') + { + let id = Uuid::parse_str(id_str).map_err(|_| ParseTopicErr::BadUuid)?; + return Ok(Topic::UserAuthz(id)); + } + Err(ParseTopicErr::Unknown) + } + + /// Which permission check the WS handler must run before allowing a + /// subscribe. Three classes per plan (see + /// `docs/plan/message-bus.md § AuthZ model`): + /// + /// - Resource-scoped: default `Read` on the resource (Phase-B adds + /// `Share`/`Comment` for the stricter topics). + /// - Identity-scoped: `caller_id == subject_uuid`. No admin bypass. + /// - Role-scoped / bespoke: not represented in this MVP. + pub fn required_perm(&self) -> AuthzCheck { + match self { + Topic::Folder(id) => AuthzCheck::ResourceRead { + resource: BusResource::Folder(*id), + }, + Topic::UserAuthz(id) => AuthzCheck::IdentityMatch { user_id: *id }, + } + } +} + +/// Parse failure for a wire-form topic string. Kept small — the WS handler +/// maps every variant to `topic_forbidden` on the wire (both a bad UUID and +/// an unknown shape are indistinguishable from the caller's perspective). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParseTopicErr { + /// The prefix was recognized but the UUID inside didn't parse. + BadUuid, + /// The topic string didn't match any known shape (typo, or a topic + /// that isn't in this MVP). + Unknown, +} + +// ════════════════════════════════════════════════════════════════════════════ +// AuthzCheck — the gate class the WS handler dispatches on +// ════════════════════════════════════════════════════════════════════════════ + +/// Resource kinds the bus knows how to gate on. Deliberately a small closed +/// enum, not the full `domain::authorization::Resource` — the bus does not +/// need every resource type in the domain, and keeping this separate avoids +/// dragging domain-shaped churn into the port. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BusResource { + Folder(Uuid), + // File(Uuid), Drive(Uuid), Calendar(Uuid), AddressBook(Uuid) land with + // their topic variants. +} + +/// The check the WS handler must run at subscribe time. Split into the three +/// classes described in `docs/plan/message-bus.md § AuthZ model`, so a new +/// topic variant with a new gate shape is a compile error at the dispatch +/// site rather than a runtime "unhandled" bug. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AuthzCheck { + /// Class 1 — Resource-scoped, default gate is Read on the resource. + /// Extend to `ResourceShare`/`ResourceComment` when the Phase-B topics + /// (`file:{id}:shares`, `file:{id}:comments`) land. + ResourceRead { resource: BusResource }, + + /// Class 2 — Identity-scoped. `caller_id` must equal `user_id`. + /// No admin bypass — privacy is a hard rule. + IdentityMatch { user_id: Uuid }, + // Class 3 (role-scoped `admin:*`) and the bespoke `job:{id}` check + // land with their topic variants. +} + +// ════════════════════════════════════════════════════════════════════════════ +// RealtimeEvent — the payload +// ════════════════════════════════════════════════════════════════════════════ + +/// A fact that has just become true. Emitted by services AFTER commit, +/// never inside a DB transaction — a rollback would otherwise fan out a +/// lie. +/// +/// Payloads are **thin facts** (ids + actor + verb): the client refetches +/// details via REST when it needs them. This keeps the AuthZ surface small +/// (thin payloads can't leak fields the caller couldn't already read via +/// REST) and keeps events well under the ~8 KB pg NOTIFY cap when the +/// `PgListenReplicator` seam is filled in later. +/// +/// Wire form uses `#[serde(tag = "event", rename_all = "snake_case")]`; +/// discriminator strings are the JSON-RPC notification `event` field. New +/// denial cause / new event = new variant, never repurpose an existing one, +/// per project convention. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum RealtimeEvent { + /// A file was created inside `parent_id`. + FileCreated { + file_id: Uuid, + name: String, + parent_id: Uuid, + actor: Uuid, + }, + /// A sub-folder was created inside `parent_id`. + FolderCreated { + folder_id: Uuid, + name: String, + parent_id: Uuid, + actor: Uuid, + }, +} + +// ════════════════════════════════════════════════════════════════════════════ +// JSON-RPC 2.0 error codes — stable, never repurpose +// ════════════════════════════════════════════════════════════════════════════ + +/// JSON-RPC 2.0 `error.code` values used on the WS wire. Follows the spec's +/// "server-defined" range `-32000` to `-32099` for our application-defined +/// codes; the standard `-326xx` envelope codes are re-exported here too so +/// the WS handler has one place to reach for. +/// +/// See `docs/plan/message-bus.md § JSON-RPC error codes` for the +/// wire-`message`/audit-`reason` mapping. +pub mod error_code { + /// Resource-scoped topic, caller lacks Read (or resource doesn't exist — + /// indistinguishable to caller by design). Anti-enum invariant. + pub const NO_READ: i32 = -32001; + + /// Resource-scoped topic requiring `Share`, caller has Read but not + /// Share. Applies to `file:{id}:shares` (Phase B). + pub const NO_SHARE: i32 = -32002; + + /// Resource-scoped topic requiring `Comment` (`file:{id}:comments` + /// Phase B). + pub const NO_COMMENT: i32 = -32003; + + /// Identity-scoped mismatch OR unknown/malformed topic. Same wire code + /// regardless of whether the target user exists — anti-enum. + pub const TOPIC_FORBIDDEN: i32 = -32004; + + /// Per-connection sub cap hit. + pub const SUB_LIMIT: i32 = -32005; + + /// Subscribe-frame token bucket exhausted. + pub const RATE_LIMITED: i32 = -32006; + + /// CRDT edit frame from a caller without `Edit`. Emitted as an + /// `rt.write_denied` notification (not tied to a request `id`). + pub const NO_EDIT: i32 = -32007; + + // ────────────────────── JSON-RPC 2.0 standard codes ───────────────────── + // Re-exported so the WS handler doesn't reach for two constant lists. + + /// Server-side failure the client should retry. + pub const INTERNAL_ERROR: i32 = -32603; + + /// Malformed JSON-RPC envelope (missing `method`, wrong `jsonrpc` + /// version). + pub const INVALID_REQUEST: i32 = -32600; + + /// Method outside the `rt.*` allowlist. + pub const METHOD_NOT_FOUND: i32 = -32601; + + /// Method known but `params` shape wrong (missing `topic`, unparseable). + pub const INVALID_PARAMS: i32 = -32602; +} + +// ════════════════════════════════════════════════════════════════════════════ +// RealtimeBus — the port +// ════════════════════════════════════════════════════════════════════════════ + +/// The local-facing message bus. Fire-and-forget publish, stream subscribe. +/// +/// `publish` is intentionally synchronous — services must not `await` under +/// a DB transaction (a slow subscriber could hold the tx open) and services +/// should not care whether fan-out is happening in a background task or not. +/// +/// `subscribe` returns a `Stream` so the impl can change (broadcast, mpsc, +/// pg listener) without churn at the consumer. +pub trait RealtimeBus: Send + Sync + 'static { + /// Fan an event out to every current subscriber of `topic`. Never + /// blocks; slow subscribers are dropped by the impl (they'll reconnect + /// and refetch). + fn publish(&self, topic: &Topic, event: RealtimeEvent); + + /// Subscribe to `topic`. The returned stream yields events until the + /// subscriber is dropped or the impl kicks it out (e.g. for lagging + /// too far behind). + fn subscribe(&self, topic: &Topic) -> BusStream; +} + +/// Boxed stream returned by [`RealtimeBus::subscribe`]. Aliased so +/// consumers don't need to spell out the `Pin>` shape. +pub type BusStream = Pin + Send>>; + +// ════════════════════════════════════════════════════════════════════════════ +// BusReplicator — the multi-instance seam (day-1 noop) +// ════════════════════════════════════════════════════════════════════════════ + +/// Cross-instance replicator. Sits BESIDE [`RealtimeBus`], not in front of +/// it — the bus does the local fan-out; the replicator forwards outbound +/// publishes to the broker (pg NOTIFY, RabbitMQ, NATS) and injects inbound +/// broker messages back into the local bus. +/// +/// V1 ships [`NoopReplicator`]. The trait is declared today so wiring the +/// day the second impl arrives is drop-in. +#[async_trait::async_trait] +pub trait BusReplicator: Send + Sync + 'static { + /// Called by the local bus for every publish. Fire-and-forget — must not + /// block or await; forwarding to the broker happens on a background task + /// owned by the impl. + fn on_local_publish(&self, topic: &Topic, event: &RealtimeEvent); + + /// Long-running consumer task: reads remote messages and re-publishes + /// locally. Returns when `shutdown` is notified — DI calls + /// `shutdown.notify_one()` on graceful shutdown. + /// + /// **Shutdown semantics:** use `Notify::notify_one` (not + /// `notify_waiters`) at the signalling site: `notify_one` stores a + /// permit if no waiter is currently parked, so signal-before-park is + /// safe. `notify_waiters` silently drops signals sent before parking + /// and creates a race. This constrains the impl to a single-waiter + /// shutdown handle; multi-task replicators must spin their own + /// `CancellationToken`-style fan-out internally. + async fn run(self: Arc, shutdown: Arc) -> Result<(), DomainError>; +} + +/// Day-1 replicator: does nothing. Wired unconditionally so callers hold +/// `Arc` uniformly. Swapped for a real impl when +/// multi-instance deployment matters. +#[derive(Default)] +pub struct NoopReplicator; + +#[async_trait::async_trait] +impl BusReplicator for NoopReplicator { + fn on_local_publish(&self, _topic: &Topic, _event: &RealtimeEvent) { + // Intentionally empty. Local fan-out already happened in the bus. + } + + async fn run(self: Arc, shutdown: Arc) -> Result<(), DomainError> { + // Park until shutdown so the DI-managed handle stays alive with the + // same lifecycle as a future real replicator. + shutdown.notified().await; + Ok(()) + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// Tests +// ════════════════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn folder_topic_roundtrip() { + let id = Uuid::new_v4(); + let t = Topic::Folder(id); + let wire = t.to_wire_key(); + assert_eq!(wire, format!("folder:{id}")); + assert_eq!(Topic::parse(&wire).unwrap(), t); + } + + #[test] + fn user_authz_topic_roundtrip() { + let id = Uuid::new_v4(); + let t = Topic::UserAuthz(id); + let wire = t.to_wire_key(); + assert_eq!(wire, format!("user:{id}:authz")); + assert_eq!(Topic::parse(&wire).unwrap(), t); + } + + #[test] + fn parse_rejects_bad_uuid() { + assert_eq!( + Topic::parse("folder:not-a-uuid"), + Err(ParseTopicErr::BadUuid) + ); + } + + #[test] + fn parse_rejects_unknown_shape() { + assert_eq!(Topic::parse(""), Err(ParseTopicErr::Unknown)); + assert_eq!(Topic::parse("unknown:x"), Err(ParseTopicErr::Unknown)); + assert_eq!( + Topic::parse(&format!("user:{}", Uuid::new_v4())), + Err(ParseTopicErr::Unknown), + "user: without :authz suffix is not a known topic in MVP" + ); + } + + #[test] + fn required_perm_folder_is_resource_read() { + let id = Uuid::new_v4(); + assert_eq!( + Topic::Folder(id).required_perm(), + AuthzCheck::ResourceRead { + resource: BusResource::Folder(id) + } + ); + } + + #[test] + fn required_perm_user_authz_is_identity_match() { + let id = Uuid::new_v4(); + assert_eq!( + Topic::UserAuthz(id).required_perm(), + AuthzCheck::IdentityMatch { user_id: id } + ); + } + + #[test] + fn event_serializes_with_snake_case_discriminator() { + // The `#[serde(tag = "event")]` shape is the WS wire contract for + // the `rt.event` JSON-RPC notification's `params.event` field. Pin + // it with a snapshot so accidental rename of the enum variant + // fails the test instead of silently breaking clients. + let ev = RealtimeEvent::FileCreated { + file_id: Uuid::nil(), + name: "notes.md".into(), + parent_id: Uuid::nil(), + actor: Uuid::nil(), + }; + let json = serde_json::to_value(&ev).unwrap(); + assert_eq!(json["event"], "file_created"); + assert_eq!(json["name"], "notes.md"); + + let ev = RealtimeEvent::FolderCreated { + folder_id: Uuid::nil(), + name: "docs".into(), + parent_id: Uuid::nil(), + actor: Uuid::nil(), + }; + let json = serde_json::to_value(&ev).unwrap(); + assert_eq!(json["event"], "folder_created"); + } + + #[test] + fn event_roundtrip() { + let file_id = Uuid::new_v4(); + let parent_id = Uuid::new_v4(); + let actor = Uuid::new_v4(); + let original = RealtimeEvent::FileCreated { + file_id, + name: "a.txt".into(), + parent_id, + actor, + }; + let json = serde_json::to_string(&original).unwrap(); + let decoded: RealtimeEvent = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn error_codes_stay_in_the_defined_ranges() { + // Application-defined codes live in the JSON-RPC "server-defined" + // range -32000..=-32099. Standard envelope codes live in + // -32700..=-32600. A refactor that moves a value out of its range + // is a wire-break — pin it here. + for code in [ + error_code::NO_READ, + error_code::NO_SHARE, + error_code::NO_COMMENT, + error_code::TOPIC_FORBIDDEN, + error_code::SUB_LIMIT, + error_code::RATE_LIMITED, + error_code::NO_EDIT, + ] { + assert!( + (-32099..=-32000).contains(&code), + "app-defined code {code} outside -32099..=-32000" + ); + } + for code in [ + error_code::INTERNAL_ERROR, + error_code::INVALID_REQUEST, + error_code::METHOD_NOT_FOUND, + error_code::INVALID_PARAMS, + ] { + assert!( + (-32700..=-32600).contains(&code), + "standard code {code} outside -32700..=-32600" + ); + } + } + + #[tokio::test] + async fn noop_replicator_parks_until_notified() { + let repl = Arc::new(NoopReplicator); + let shutdown = Arc::new(Notify::new()); + let handle = tokio::spawn({ + let repl = Arc::clone(&repl); + let shutdown = Arc::clone(&shutdown); + async move { BusReplicator::run(repl, shutdown).await } + }); + // on_local_publish is a no-op that should not panic or spawn work. + repl.on_local_publish( + &Topic::Folder(Uuid::nil()), + &RealtimeEvent::FileCreated { + file_id: Uuid::nil(), + name: "x".into(), + parent_id: Uuid::nil(), + actor: Uuid::nil(), + }, + ); + // `notify_one` (not `notify_waiters`) so the signal survives if the + // spawned task hasn't yet reached `.notified().await` — permit + // queues instead of being dropped. See BusReplicator docs. + shutdown.notify_one(); + handle.await.unwrap().unwrap(); + } +} diff --git a/src/application/services/file_upload_service.rs b/src/application/services/file_upload_service.rs index 9b26acb7..1a10258d 100644 --- a/src/application/services/file_upload_service.rs +++ b/src/application/services/file_upload_service.rs @@ -56,6 +56,13 @@ pub struct FileUploadService { /// (`create_file_from_owned_blob_with_perms`); `None` in minimal test /// wiring. instant_upload: Option, + /// Realtime message bus. When wired, `upload_file_streaming` + /// publishes a `FileCreated` event on `Topic::Folder(parent_id)` + /// after the DB commit — subscribers see the new file appear in + /// their live folder view. Optional so stub / test factories can + /// build the service without a bus; a `None` bus is a silent no-op + /// on the publish path. + bus: Option>, } /// Everything the instant-upload path needs beyond the upload service's own @@ -78,6 +85,7 @@ impl FileUploadService { resource_access_hook: None, authorization: None, instant_upload: None, + bus: None, } } @@ -95,6 +103,7 @@ impl FileUploadService { resource_access_hook: None, authorization: None, instant_upload: None, + bus: None, } } @@ -108,6 +117,18 @@ impl FileUploadService { self } + /// Wire the realtime message bus. Enables live folder-view updates: + /// after `upload_file_streaming` commits, a `FileCreated` event + /// fires on `Topic::Folder(parent_id)` — subscribers see the new + /// file appear without polling. + pub fn with_realtime_bus( + mut self, + bus: Arc, + ) -> Self { + self.bus = Some(bus); + self + } + /// Wires the authorization engine, dedup index and quota service that /// power the instant-upload path. /// @@ -454,6 +475,27 @@ impl FileUploadUseCase for FileUploadService { // The caller just created this file — surface it in Recent so the // "I just uploaded X" UX matches the pre-SvelteKit behaviour. self.notify_file_accessed(caller_id, &dto.id); + + // Realtime fan-out AFTER commit — subscribers to the parent + // folder's topic see the new file appear live. Silent no-op if + // the bus isn't wired (stubs / tests) or the file landed at + // drive-root (no folder id → nothing to publish on). + if let (Some(bus), Some(parent_folder_id)) = (&self.bus, dto.folder_id.as_deref()) + && let (Ok(parent_uuid), Ok(file_uuid)) = + (Uuid::parse_str(parent_folder_id), Uuid::parse_str(&dto.id)) + { + use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; + bus.publish( + &Topic::Folder(parent_uuid), + RealtimeEvent::FileCreated { + file_id: file_uuid, + name: dto.name.clone(), + parent_id: parent_uuid, + actor: caller_id, + }, + ); + } + Ok(dto) } diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index ac2ed941..34917be4 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -49,6 +49,13 @@ pub struct FolderService { /// on cross-drive MOVE. Silently skipped when unwired (stubs). storage_usage: Option>, + /// Realtime message bus. When wired, `create_folder_with_perms` + /// publishes a `FolderCreated` event on `Topic::Folder(parent_id)` + /// after the DB commit — subscribers see the new folder appear in + /// their live folder view. Optional so stub / test factories can + /// build the service without a bus; a `None` bus is a silent no-op + /// on the publish path (no fan-out, no audit). + bus: Option>, } impl FolderService { @@ -66,9 +73,21 @@ impl FolderService { file_lifecycle, drive_repo: None, storage_usage: None, + bus: None, } } + /// Wire the realtime message bus. Enables live folder-view updates: + /// after `create_folder_with_perms` commits, a `FolderCreated` event + /// fires on `Topic::Folder(parent_id)`. Off in stubs / tests. + pub fn with_realtime_bus( + mut self, + bus: Arc, + ) -> Self { + self.bus = Some(bus); + self + } + /// Borrow the external-mount classifier (handlers branch on this before /// treating an id as a native UUID). pub fn mount_router(&self) -> &MountRouter { @@ -366,10 +385,39 @@ impl FolderUseCase for FolderService { ) .await?; + // Snapshot the parent UUID before the move so the post-commit + // publish can address `Topic::Folder(parent_uuid)` without + // re-borrowing `dto.parent_id` (which is moved into + // `create_folder`). + let parent_uuid_for_publish = Uuid::parse_str(parent_id).ok(); + let folder = self .folder_storage .create_folder(dto.name, dto.parent_id, caller_id) .await?; + + // Publish AFTER commit — never before, never inside the write. + // Silent no-op if the bus isn't wired (stubs/tests) or the + // parent uuid didn't parse (won't happen — AuthZ above already + // parsed it — but the None-fallthrough keeps the publish path + // infallible). + if let (Some(bus), Some(parent_uuid), Ok(folder_uuid)) = ( + &self.bus, + parent_uuid_for_publish, + Uuid::parse_str(folder.id()), + ) { + use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; + bus.publish( + &Topic::Folder(parent_uuid), + RealtimeEvent::FolderCreated { + folder_id: folder_uuid, + name: folder.name().to_owned(), + parent_id: parent_uuid, + actor: caller_id, + }, + ); + } + Ok(FolderDto::from(folder)) } diff --git a/src/common/di.rs b/src/common/di.rs index d65377de..b6768800 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -703,7 +703,14 @@ impl AppServiceFactory { resource_access_hook: Option< Arc, >, + bus: &Arc, ) -> ApplicationServices { + // Upcast the concrete bus once — service builders take the + // trait object so the wire remains stable across future bus + // impls. + let bus_trait: Arc = + bus.clone(); + // Main services let folder_service = Arc::new( FolderService::new( @@ -724,7 +731,10 @@ impl AppServiceFactory { // MOVE. Reuses the `check_drive_quota` the upload path // already runs. Without this, a Move that would push the // destination past its cap succeeds silently. - .with_storage_usage(storage_usage.clone()), + .with_storage_usage(storage_usage.clone()) + // Realtime fan-out on `create_folder_with_perms` — the + // parent-folder subscribers see new sub-folders live. + .with_realtime_bus(bus_trait.clone()), ); // Built before the upload/management services so the plugin lifecycle @@ -771,7 +781,11 @@ impl AppServiceFactory { authz.clone(), core.dedup_service.clone(), storage_usage.clone(), - ); + ) + // Realtime fan-out — every successful `upload_file_streaming` + // publishes a `FileCreated` event on the parent folder's + // topic so open folder views refresh live. + .with_realtime_bus(bus_trait.clone()); if let Some(hook) = resource_access_hook.clone() { svc = svc.with_resource_access_hook(hook); } @@ -1775,6 +1789,17 @@ impl AppServiceFactory { crate::application::services::external_mount_router::MountRouter::new(mount_registry), ); + // Realtime bus: single instance for the app lifetime, wired + // with a no-op replicator (multi-instance broker is a follow-up + // per `docs/plan/message-bus.md § Roadmap`). Constructed here + // so `create_application_services` can hand it to services that + // publish after their DB commits (`FolderService`, + // `FileUploadService`, …). Spawns its own GC task in + // `with_replicator` — no supervisor setup required. + let bus = crate::infrastructure::services::in_process_realtime_bus::InProcessRealtimeBus::with_replicator( + Arc::new(crate::application::ports::realtime_ports::NoopReplicator), + ); + let mut apps = self.create_application_services( &core, &repos, @@ -1786,6 +1811,7 @@ impl AppServiceFactory { plugin_dispatch.clone(), mount_router.clone(), Some(resource_access_hook.clone()), + &bus, ); // 5. Share service @@ -2284,6 +2310,7 @@ impl AppServiceFactory { db_pool: Some(pool.clone()), maintenance_pool: Some(maintenance_pool), mount_router, + bus, auth_service: auth_services, opaque_service, opaque_repo, @@ -3177,6 +3204,20 @@ pub struct AppState { /// method (which still owns the authorization check). pub mount_router: Arc, + /// Realtime message bus. Always present — an empty bus (no + /// subscribers, no publishes) costs a single `DashMap` allocation. + /// The WS handler reads `subscribe`; service publish hooks + /// (`FolderService::create_folder_with_perms`, + /// `FileManagementService`'s file-create commit) call `publish` + /// AFTER their DB transaction commits. + /// + /// Stored as the concrete type (not `Arc`) so the + /// GC task's `Weak` lifecycle is legible from di.rs. Consumers + /// that only need the trait obtain it via + /// `Arc::clone(&state.bus) as Arc`. + pub bus: Arc< + crate::infrastructure::services::in_process_realtime_bus::InProcessRealtimeBus, + >, pub auth_service: Option, /// OPAQUE aPAKE substrate (RFC 9807). Populated only when /// [`OpaqueConfig::effective_mode`] is not `Off` — that method diff --git a/src/infrastructure/services/in_process_realtime_bus.rs b/src/infrastructure/services/in_process_realtime_bus.rs new file mode 100644 index 00000000..afd0f7cb --- /dev/null +++ b/src/infrastructure/services/in_process_realtime_bus.rs @@ -0,0 +1,347 @@ +//! In-process `RealtimeBus` — one `broadcast::Sender` per active topic, +//! held in a [`DashMap`] keyed by [`Topic`]. Publish is fire-and-forget, +//! subscribe returns a `Stream` backed by [`BroadcastStream`]. +//! +//! # Slow-subscriber policy +//! +//! `tokio::sync::broadcast` drops the oldest queued message when a +//! subscriber can't keep up (ring is bounded to [`BROADCAST_RING_CAPACITY`]). +//! When a subscriber's stream sees a `Lagged` marker, the WS handler kills +//! that session with a JSON-RPC `rt.revoked` notification (reason +//! `slow_consumer`) and lets the client reconnect + refetch. That policy +//! lives in the handler; this module just surfaces the `Lagged` variant. +//! +//! # Topic GC +//! +//! When the last subscriber of a topic drops, `broadcast::Sender::receiver_count` +//! falls to zero. New publishes on that topic still succeed (they hit the +//! now-orphaned sender), but the entry stays in the map. A background GC +//! task periodically sweeps entries whose `receiver_count == 0`. Kept +//! simple: no ref-count tracking, no watchdog — a sweep every +//! [`GC_INTERVAL`] is enough for our fan-out volume. + +use std::sync::Arc; +use std::time::Duration; + +use dashmap::DashMap; +use futures::StreamExt; +use tokio::sync::broadcast; +use tokio_stream::wrappers::BroadcastStream; + +use crate::application::ports::realtime_ports::{ + BusReplicator, BusStream, RealtimeBus, RealtimeEvent, Topic, +}; + +/// Per-topic ring-buffer size for slow subscribers. When a subscriber lags +/// past this, the broadcast channel starts dropping the oldest messages and +/// signals `Lagged`. Sized generously — fan-out volume per topic is low +/// (folder mutations, job step ticks) so pressure comes from a genuinely +/// dead consumer, not from a normal traffic spike. +pub const BROADCAST_RING_CAPACITY: usize = 256; + +/// How often the GC task sweeps empty topics. Short enough that a burst of +/// short-lived subs (folder navigations) doesn't grow the map indefinitely, +/// long enough that GC overhead stays trivial. +pub const GC_INTERVAL: Duration = Duration::from_secs(60); + +/// The in-process implementation of [`RealtimeBus`]. +/// +/// Callers hold `Arc` (or `Arc`). +/// The struct owns its topic map and — when constructed via +/// [`InProcessRealtimeBus::with_replicator`] — an [`Arc`] +/// that gets fed every local publish for outbound broker forwarding. +pub struct InProcessRealtimeBus { + topics: DashMap>, + replicator: Arc, +} + +impl InProcessRealtimeBus { + /// Construct with a replicator. In v1 that's a + /// [`crate::application::ports::realtime_ports::NoopReplicator`]; when + /// multi-instance ships, it becomes the pg-NOTIFY or broker impl. + /// + /// The GC task holds a [`Weak`] handle so it exits naturally when the + /// last outer `Arc` drops — matches OxiCloud's DI convention that + /// background tasks are dropped on runtime shutdown, no explicit + /// signal needed. + pub fn with_replicator(replicator: Arc) -> Arc { + let bus = Arc::new(Self { + topics: DashMap::new(), + replicator, + }); + bus.spawn_gc(); + bus + } + + /// Spawn the periodic GC task. Holds `Weak` so it does not keep + /// the bus alive past the last outer `Arc` drop; the next + /// upgrade-and-sweep call after that returns `None` and the loop + /// exits. + fn spawn_gc(self: &Arc) { + let weak = Arc::downgrade(self); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(GC_INTERVAL); + // First tick fires immediately; skip it so we don't sweep an + // empty map on startup. + ticker.tick().await; + loop { + ticker.tick().await; + match weak.upgrade() { + Some(bus) => bus.gc_empty_topics(), + None => break, + } + } + }); + } + + /// Remove topics whose broadcast sender has no live receivers. Called + /// on the GC ticker. + fn gc_empty_topics(&self) { + self.topics + .retain(|_topic, sender| sender.receiver_count() > 0); + } + + /// For tests + observability: how many topics currently have a + /// broadcast sender in the map. + pub fn active_topic_count(&self) -> usize { + self.topics.len() + } + + /// Get-or-insert the broadcast sender for `topic`, returning a fresh + /// receiver. Used by both `publish` (for the sender) and `subscribe` + /// (for the receiver) — one code path for the map insert avoids a race + /// where publish creates a sender concurrent subscribers miss. + fn sender_for(&self, topic: &Topic) -> broadcast::Sender { + self.topics + .entry(*topic) + .or_insert_with(|| broadcast::channel(BROADCAST_RING_CAPACITY).0) + .clone() + } +} + +impl RealtimeBus for InProcessRealtimeBus { + fn publish(&self, topic: &Topic, event: RealtimeEvent) { + // Feed the replicator FIRST — if it were called after local fan-out, + // an unwind on a broken subscriber could skip broker forwarding. + // `on_local_publish` is a sync fire-and-forget contract; slow + // replicators must background their I/O themselves. + self.replicator.on_local_publish(topic, &event); + + // If nobody is subscribed, don't allocate a sender just to drop + // its message. `broadcast::Sender::send` returns Err when there + // are no receivers — cheaper still to short-circuit here. + if let Some(sender) = self.topics.get(topic) { + // `send` never blocks; it drops the oldest when the ring is + // full, signalling `Lagged` on that subscriber's next recv. + let _ = sender.send(event); + } + // Else: no active subs. Event is lost by design (see + // `docs/plan/message-bus.md § Failure modes`). + } + + fn subscribe(&self, topic: &Topic) -> BusStream { + let receiver = self.sender_for(topic).subscribe(); + // `BroadcastStream` yields `Result`; + // filter out the `Lagged` variant here and terminate the stream on + // it so the WS handler sees a clean "the stream ended" signal + // rather than having to match on the error. The handler is + // responsible for emitting the `rt.revoked` notification with + // `slow_consumer` reason on such a termination. + let stream = BroadcastStream::new(receiver).take_while(|item| { + let keep = item.is_ok(); + async move { keep } + }); + Box::pin(stream.filter_map(|item| async move { item.ok() })) + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// Tests +// ════════════════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + use crate::application::ports::realtime_ports::NoopReplicator; + use futures::StreamExt; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + use tokio::sync::Notify; + use uuid::Uuid; + + fn make_bus() -> Arc { + InProcessRealtimeBus::with_replicator(Arc::new(NoopReplicator)) + } + + fn folder_topic() -> Topic { + Topic::Folder(Uuid::new_v4()) + } + + fn file_created(parent_id: Uuid) -> RealtimeEvent { + RealtimeEvent::FileCreated { + file_id: Uuid::new_v4(), + name: "a.txt".into(), + parent_id, + actor: Uuid::new_v4(), + } + } + + /// Positive fan-out: a subscriber to a topic receives an event + /// published on that same topic. + #[tokio::test] + async fn subscriber_receives_publish_on_same_topic() { + let bus = make_bus(); + let topic = folder_topic(); + let mut stream = bus.subscribe(&topic); + + // Give the subscriber a moment to install (broadcast::Sender::send + // silently fails against a not-yet-installed receiver; the + // subscribe() call above is synchronous but the receiver still + // needs to be registered on the sender's side before publish). + let parent = match topic { + Topic::Folder(id) => id, + _ => unreachable!(), + }; + let event = file_created(parent); + bus.publish(&topic, event.clone()); + + let received = tokio::time::timeout(Duration::from_millis(200), stream.next()) + .await + .expect("event should arrive within 200ms") + .expect("stream must yield at least once"); + assert_eq!(received, event); + } + + /// Topic isolation: a subscriber to folder A does not receive events + /// published on folder B. This is the invariant the smoke test's + /// Scenario 2 asserts end-to-end; verifying it in-unit here catches + /// bugs early. + #[tokio::test] + async fn subscriber_does_not_receive_other_topic() { + let bus = make_bus(); + let topic_a = folder_topic(); + let topic_b = folder_topic(); + assert_ne!(topic_a, topic_b); + + let mut stream_a = bus.subscribe(&topic_a); + + let parent_b = match topic_b { + Topic::Folder(id) => id, + _ => unreachable!(), + }; + let event_b = file_created(parent_b); + bus.publish(&topic_b, event_b); + + // The A subscriber must NOT see B's event. Poll with a short + // timeout — if the isolation is broken we'll see the event; if + // it holds we'll time out. + let result = tokio::time::timeout(Duration::from_millis(100), stream_a.next()).await; + assert!( + result.is_err(), + "subscriber to topic A must not observe events published on topic B \ + (got {:?})", + result.ok().flatten() + ); + } + + /// Multiple subscribers to the same topic all see each publish. + #[tokio::test] + async fn multi_subscriber_fanout() { + let bus = make_bus(); + let topic = folder_topic(); + let mut s1 = bus.subscribe(&topic); + let mut s2 = bus.subscribe(&topic); + + let parent = match topic { + Topic::Folder(id) => id, + _ => unreachable!(), + }; + let event = file_created(parent); + bus.publish(&topic, event.clone()); + + let r1 = tokio::time::timeout(Duration::from_millis(200), s1.next()) + .await + .unwrap() + .unwrap(); + let r2 = tokio::time::timeout(Duration::from_millis(200), s2.next()) + .await + .unwrap() + .unwrap(); + assert_eq!(r1, event); + assert_eq!(r2, event); + } + + /// Publishing with no subscribers is a no-op (does not panic, does not + /// grow the map into an orphan-sender state we later have to sweep). + #[tokio::test] + async fn publish_with_no_subscribers_is_noop() { + let bus = make_bus(); + let topic = folder_topic(); + let parent = match topic { + Topic::Folder(id) => id, + _ => unreachable!(), + }; + bus.publish(&topic, file_created(parent)); + assert_eq!( + bus.active_topic_count(), + 0, + "publish without any subscribe must not insert into topics map" + ); + } + + /// The replicator sees every publish, exactly once per publish. + /// Locks in the "feed replicator FIRST" contract without asserting on + /// broker semantics we don't control from unit-tests. + #[tokio::test] + async fn replicator_is_notified_on_publish() { + struct CountingReplicator { + count: AtomicUsize, + } + #[async_trait::async_trait] + impl BusReplicator for CountingReplicator { + fn on_local_publish(&self, _topic: &Topic, _event: &RealtimeEvent) { + self.count.fetch_add(1, Ordering::SeqCst); + } + async fn run( + self: Arc, + shutdown: Arc, + ) -> Result<(), crate::common::errors::DomainError> { + shutdown.notified().await; + Ok(()) + } + } + + let counter = Arc::new(CountingReplicator { + count: AtomicUsize::new(0), + }); + let bus = InProcessRealtimeBus::with_replicator(Arc::clone(&counter) as Arc<_>); + let topic = folder_topic(); + let _sub = bus.subscribe(&topic); + let parent = match topic { + Topic::Folder(id) => id, + _ => unreachable!(), + }; + bus.publish(&topic, file_created(parent)); + bus.publish(&topic, file_created(parent)); + bus.publish(&topic, file_created(parent)); + assert_eq!(counter.count.load(Ordering::SeqCst), 3); + } + + /// Dropping the last subscriber leaves the topic sender orphaned until + /// the GC sweeps it. We don't wait for the timer here (that would make + /// the test slow); instead we call `gc_empty_topics` directly to + /// verify the sweep does what it promises. + #[tokio::test] + async fn gc_removes_empty_topics() { + let bus = make_bus(); + let topic = folder_topic(); + { + let _sub = bus.subscribe(&topic); + assert_eq!(bus.active_topic_count(), 1); + } + // Subscriber dropped. Sender is still in the map, but receiver + // count is zero — the sweep should reclaim it. + bus.gc_empty_topics(); + assert_eq!(bus.active_topic_count(), 0); + } +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 1522429e..1fb189c3 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -27,6 +27,7 @@ pub mod files_consistency_service; pub mod folders_consistency_service; pub mod grant_cleanup_service; pub mod image_transcode_service; +pub mod in_process_realtime_bus; pub mod jwt_service; pub mod last_seen_tracker; pub mod local_blob_backend; diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index a6a945cf..8177ee35 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -23,6 +23,7 @@ pub mod opaque_auth_handler; pub mod people_handler; pub mod photos_handler; pub mod recent_handler; +pub mod rt_ws; pub mod search_handler; pub mod share_handler; pub mod subject_group_handler; diff --git a/src/interfaces/api/handlers/rt_ws.rs b/src/interfaces/api/handlers/rt_ws.rs new file mode 100644 index 00000000..839ff66b --- /dev/null +++ b/src/interfaces/api/handlers/rt_ws.rs @@ -0,0 +1,552 @@ +//! Realtime bus WebSocket handler — the endpoint every WS session +//! multiplexes over. See `docs/plan/message-bus.md § Wire protocol`. +//! +//! # Wire +//! +//! JSON-RPC 2.0 for control + events (text frames). Binary frames are +//! reserved for the Yjs sync protocol (collab editor, Phase A follow-up) +//! and are IGNORED in MVP. +//! +//! Methods accepted in MVP: +//! - `rt.subscribe { topic }` → `{ subscribed: "" }` or JSON-RPC error. +//! - `rt.unsubscribe { topic }` → `{ unsubscribed: "" }`. +//! - `rt.ping` → `{ pong: true }`. +//! +//! Server-initiated notifications: +//! - `rt.event { topic, event, data, actor, ts }` — an event published to +//! a topic the caller is subscribed to. +//! +//! # Auth +//! +//! Route sits under `protected_api` (see `src/interfaces/api/routes.rs`) +//! so `auth_middleware` runs first. Cookie AND `Authorization: Bearer` +//! paths both produce a `CurrentUserId` extension the handler extracts. +//! Browser-side subprotocol bearer (`Sec-WebSocket-Protocol: +//! authorization.bearer.`) is a Phase-A follow-up — the MVP relies +//! on the Authorization header, which programmatic clients (the +//! `rt-hurl-helper` smoke test) set directly. +//! +//! # Limits +//! +//! Per-connection outbound `mpsc::Sender` bounded to 512 — full → close. +//! Per-connection subscription cap 128. Frame-size cap not enforced in +//! MVP; the underlying tokio-tungstenite default is 64 MiB which is more +//! than adequate for JSON-RPC control traffic. + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::extract::State; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::response::Response; +use futures::StreamExt; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use uuid::Uuid; + +use crate::application::ports::authorization_ports::AuthorizationEngine; +use crate::application::ports::realtime_ports::{ + AuthzCheck, BusResource, ParseTopicErr, RealtimeBus, RealtimeEvent, Topic, error_code, +}; +use crate::common::di::AppState; +use crate::domain::services::authorization::{Permission, Resource, Subject}; +use crate::interfaces::middleware::auth::CurrentUserId; + +/// Max simultaneous subscriptions on a single WS session. Beyond this the +/// server responds `-32005 sub_limit` and the client is expected to +/// unsubscribe before subscribing to another topic. +const MAX_SUBSCRIPTIONS_PER_CONNECTION: usize = 128; + +/// Outbound mpsc capacity per session. When full → we close the WS +/// (client reconnects, refetches). Sized so a subscriber blocked on the +/// socket layer doesn't back-pressure into the bus's broadcast ring. +const OUTBOUND_CHANNEL_CAPACITY: usize = 512; + +// ════════════════════════════════════════════════════════════════════════════ +// JSON-RPC 2.0 envelope types +// ════════════════════════════════════════════════════════════════════════════ + +/// Marker constant for the `jsonrpc` field. +const JSONRPC_V2: &str = "2.0"; + +/// Inbound JSON-RPC envelope — deserialize-tolerant so a client can +/// send `rt.ping` without `params`, or a notification without an `id`. +/// +/// Response/notification serialization uses the more strongly-typed +/// [`RpcResponse`] and [`RpcNotification`] types below. +#[derive(Debug, Deserialize)] +struct RpcRequest { + #[serde(rename = "jsonrpc")] + _jsonrpc: Option, + /// `id` is `None` for notifications (which the client-side of MVP + /// never sends). We accept it in the shape but do not treat missing + /// `id` as a permitted request — every `rt.*` method requires + /// `id`-correlated responses in MVP. + id: Option, + method: Option, + #[serde(default)] + params: Value, +} + +/// Server → client response for a request (both success and error use +/// this shape; exactly one of `result`/`error` is populated per spec). +#[derive(Debug, Serialize)] +struct RpcResponse<'a> { + jsonrpc: &'static str, + id: Value, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option>, +} + +#[derive(Debug, Serialize)] +struct RpcError<'a> { + code: i32, + message: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, +} + +/// Server → client notification (id-less). Emitted for `rt.event` (bus +/// fan-out) and `rt.revoked` (subscription eviction). +#[derive(Debug, Serialize)] +struct RpcNotification<'a> { + jsonrpc: &'static str, + method: &'a str, + params: Value, +} + +// ════════════════════════════════════════════════════════════════════════════ +// Handler entrypoint +// ════════════════════════════════════════════════════════════════════════════ + +/// `GET /api/rt/ws` — WS upgrade handler. Sits under `protected_api` so +/// [`CurrentUserId`] resolves against a valid session before we reach +/// `on_upgrade`. +/// +/// Returns whatever `WebSocketUpgrade::on_upgrade` produces (an HTTP 101 +/// Switching Protocols with the WebSocket handshake headers). +pub async fn rt_ws_handler( + ws: WebSocketUpgrade, + CurrentUserId(caller_id): CurrentUserId, + State(state): State>, +) -> Response { + ws.on_upgrade(move |socket| handle_session(socket, caller_id, state)) +} + +// ════════════════════════════════════════════════════════════════════════════ +// Session loop +// ════════════════════════════════════════════════════════════════════════════ + +/// A per-topic subscription: the join handle for the reader task that +/// drains the bus stream into `out_tx`. Dropping does NOT abort a spawned +/// tokio task — we must call `.abort()` explicitly on unsubscribe. +struct Sub { + reader: JoinHandle<()>, +} + +impl Drop for Sub { + fn drop(&mut self) { + // Belt-and-braces: if `remove()` bypasses `.abort()` for some + // future call path, dropping still stops the reader. + self.reader.abort(); + } +} + +async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc) { + // Outbound queue — every path that produces a text frame for the + // client enqueues here; the writer half of the select drains. + let (out_tx, mut out_rx) = mpsc::channel::(OUTBOUND_CHANNEL_CAPACITY); + + // Active subscriptions on this session. Keyed by the wire-form topic + // string so an incoming `rt.unsubscribe` with the same string is + // recognised without re-parsing. + let mut subs: HashMap = HashMap::new(); + + loop { + tokio::select! { + // biased: process outbound before inbound so an event burst + // doesn't get overtaken by a control-frame handshake. + biased; + + outbound = out_rx.recv() => { + match outbound { + Some(text) => { + if socket.send(Message::Text(text.into())).await.is_err() { + break; + } + } + None => break, // out_tx dropped — unreachable but safe + } + } + + incoming = socket.recv() => { + match incoming { + Some(Ok(Message::Text(txt))) => { + if let Some(reply) = + handle_text_frame(&txt, caller_id, &state, &mut subs, &out_tx).await + && socket.send(Message::Text(reply.into())).await.is_err() { + break; + } + } + Some(Ok(Message::Binary(_))) => { + // Reserved for Yjs sync protocol frames (collab + // editor, Phase A follow-up). Silently ignored in + // MVP so a future client that speaks binary + // frames on the same connection isn't rejected. + } + Some(Ok(Message::Ping(_) | Message::Pong(_))) => { + // Handled by axum's WebSocket state machine. + } + Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break, + } + } + } + } + + // Session cleanup: abort every subscription reader task. + subs.clear(); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Frame handling +// ════════════════════════════════════════════════════════════════════════════ + +/// Parse one inbound text frame as a JSON-RPC 2.0 request and dispatch +/// it. Returns the response string to send back (empty option if the +/// dispatch already enqueued via `out_tx`). +async fn handle_text_frame( + text: &str, + caller_id: Uuid, + state: &Arc, + subs: &mut HashMap, + out_tx: &mpsc::Sender, +) -> Option { + // Parse envelope. On malformed JSON: reply with an id-less error per + // JSON-RPC 2.0 (id = null when the request couldn't be parsed). + let req: RpcRequest = match serde_json::from_str(text) { + Ok(r) => r, + Err(_) => { + return Some(error_response( + Value::Null, + error_code::INVALID_REQUEST, + "invalid_request", + None, + )); + } + }; + + let id = req.id.unwrap_or(Value::Null); + let Some(method) = req.method else { + return Some(error_response( + id, + error_code::INVALID_REQUEST, + "invalid_request", + None, + )); + }; + + match method.as_str() { + "rt.subscribe" => { + Some(handle_subscribe(id, req.params, caller_id, state, subs, out_tx).await) + } + "rt.unsubscribe" => Some(handle_unsubscribe(id, req.params, subs)), + "rt.ping" => Some(success_response(id, serde_json::json!({ "pong": true }))), + _ => Some(error_response( + id, + error_code::METHOD_NOT_FOUND, + "method_not_found", + Some(serde_json::json!({ "method": method })), + )), + } +} + +async fn handle_subscribe( + id: Value, + params: Value, + caller_id: Uuid, + state: &Arc, + subs: &mut HashMap, + out_tx: &mpsc::Sender, +) -> String { + // Extract topic. + let topic_str = match params.get("topic").and_then(Value::as_str) { + Some(s) => s.to_owned(), + None => { + return error_response( + id, + error_code::INVALID_PARAMS, + "invalid_params", + Some(serde_json::json!({ "missing": "topic" })), + ); + } + }; + + // Guard against runaway subscribers pinning server memory. + if subs.len() >= MAX_SUBSCRIPTIONS_PER_CONNECTION && !subs.contains_key(&topic_str) { + audit_denied(caller_id, &topic_str, "sub_limit"); + return error_response( + id, + error_code::SUB_LIMIT, + "sub_limit", + Some(serde_json::json!({ "topic": topic_str })), + ); + } + + // Idempotent: re-subscribing to an already-active topic acks with + // no side effects. Client reconnect logic can replay its topic set + // without dedup. + if subs.contains_key(&topic_str) { + return success_response(id, serde_json::json!({ "subscribed": topic_str })); + } + + // Parse topic. + let topic = match Topic::parse(&topic_str) { + Ok(t) => t, + Err(ParseTopicErr::BadUuid) | Err(ParseTopicErr::Unknown) => { + // Both parse failures collapse to `topic_forbidden` on the + // wire — the caller cannot distinguish "unknown shape" from + // "shape known but resource doesn't exist" without hinting + // an enumeration oracle. + audit_denied(caller_id, &topic_str, "unknown_topic"); + return error_response( + id, + error_code::TOPIC_FORBIDDEN, + "topic_forbidden", + Some(serde_json::json!({ "topic": topic_str })), + ); + } + }; + + // AuthZ dispatch — one match arm per gate class. Adding a new topic + // variant with a new gate shape is a compile error here. + match topic.required_perm() { + AuthzCheck::ResourceRead { resource } => { + let domain_resource = match resource { + BusResource::Folder(uuid) => Resource::Folder(uuid), + }; + if state + .authorization + .require(Subject::User(caller_id), Permission::Read, domain_resource) + .await + .is_err() + { + // Anti-enum: "no such resource" and "no read" collapse + // to the same wire code. Audit records the truth. + audit_denied(caller_id, &topic_str, "no_read"); + return error_response( + id, + error_code::NO_READ, + "no_read", + Some(serde_json::json!({ "topic": topic_str })), + ); + } + } + AuthzCheck::IdentityMatch { user_id } => { + if user_id != caller_id { + audit_denied(caller_id, &topic_str, "identity_mismatch"); + return error_response( + id, + error_code::TOPIC_FORBIDDEN, + "topic_forbidden", + Some(serde_json::json!({ "topic": topic_str })), + ); + } + } + } + + // AuthZ passed — install the subscription and spawn a reader task + // that forwards bus events to the outbound channel as `rt.event` + // notifications. + let stream = RealtimeBus::subscribe(state.bus.as_ref(), &topic); + let topic_wire = topic_str.clone(); + let out_tx_task = out_tx.clone(); + let reader = tokio::spawn(async move { + let mut stream = stream; + while let Some(event) = stream.next().await { + let notification = event_notification(&topic_wire, &event); + if out_tx_task.send(notification).await.is_err() { + // Session's outbound channel closed — receiver dropped. + break; + } + } + }); + subs.insert(topic_str.clone(), Sub { reader }); + + success_response(id, serde_json::json!({ "subscribed": topic_str })) +} + +fn handle_unsubscribe(id: Value, params: Value, subs: &mut HashMap) -> String { + let Some(topic_str) = params.get("topic").and_then(Value::as_str) else { + return error_response( + id, + error_code::INVALID_PARAMS, + "invalid_params", + Some(serde_json::json!({ "missing": "topic" })), + ); + }; + // Idempotent: removing a topic the session isn't subscribed to is + // still a success ack, per plan. + subs.remove(topic_str); + success_response(id, serde_json::json!({ "unsubscribed": topic_str })) +} + +// ════════════════════════════════════════════════════════════════════════════ +// Envelope helpers +// ════════════════════════════════════════════════════════════════════════════ + +fn success_response(id: Value, result: Value) -> String { + serde_json::to_string(&RpcResponse { + jsonrpc: JSONRPC_V2, + id, + result: Some(result), + error: None, + }) + .expect("RpcResponse always serializes") +} + +fn error_response(id: Value, code: i32, message: &str, data: Option) -> String { + serde_json::to_string(&RpcResponse { + jsonrpc: JSONRPC_V2, + id, + result: None, + error: Some(RpcError { + code, + message, + data, + }), + }) + .expect("RpcResponse always serializes") +} + +/// Build an `rt.event` JSON-RPC notification for a bus event. +/// +/// Payload discipline (see plan): thin facts only. The `RealtimeEvent`'s +/// own `#[serde(tag = "event")]` shape provides `event` + variant fields +/// under one flat object; we lift them into `params.data` alongside a +/// `topic` selector for the client. +fn event_notification(topic_wire: &str, event: &RealtimeEvent) -> String { + // Serialize the event to extract `event` (discriminator) and the + // remaining fields as `data`. Two-step to avoid re-inventing the + // enum's discriminator string here. + let event_json = serde_json::to_value(event).expect("RealtimeEvent always serializes"); + let (event_name, data) = split_event_discriminator(event_json); + + let params = serde_json::json!({ + "topic": topic_wire, + "event": event_name, + "data": data, + }); + + serde_json::to_string(&RpcNotification { + jsonrpc: JSONRPC_V2, + method: "rt.event", + params, + }) + .expect("RpcNotification always serializes") +} + +/// Given a `RealtimeEvent` serialised as `{ "event": "file_created", ...rest }`, +/// split into `(event_name, rest)`. Falls back to `("unknown", full)` if +/// the shape doesn't match (defensive — shouldn't happen given the enum +/// derive, but a future untagged variant would land here). +fn split_event_discriminator(mut event_json: Value) -> (String, Value) { + if let Some(obj) = event_json.as_object_mut() + && let Some(Value::String(name)) = obj.remove("event") + { + return (name, Value::Object(obj.clone())); + } + ("unknown".to_owned(), event_json) +} + +// ════════════════════════════════════════════════════════════════════════════ +// Audit +// ════════════════════════════════════════════════════════════════════════════ + +fn audit_denied(caller_id: Uuid, topic: &str, reason: &'static str) { + tracing::info!( + target: "audit", + event = "realtime.subscribe_denied", + reason = reason, + caller_id = %caller_id, + topic = %topic, + "👮🏻‍♂️ realtime subscribe rejected", + ); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Tests +// ════════════════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn success_response_shape() { + let s = success_response( + Value::Number(42.into()), + serde_json::json!({ "subscribed": "folder:x" }), + ); + let v: Value = serde_json::from_str(&s).unwrap(); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], 42); + assert_eq!(v["result"]["subscribed"], "folder:x"); + assert!(v.get("error").is_none()); + } + + #[test] + fn error_response_shape() { + let s = error_response( + Value::Number(7.into()), + error_code::NO_READ, + "no_read", + Some(serde_json::json!({ "topic": "folder:x" })), + ); + let v: Value = serde_json::from_str(&s).unwrap(); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], 7); + assert_eq!(v["error"]["code"], error_code::NO_READ); + assert_eq!(v["error"]["message"], "no_read"); + assert_eq!(v["error"]["data"]["topic"], "folder:x"); + assert!(v.get("result").is_none()); + } + + #[test] + fn event_notification_shape() { + let event = RealtimeEvent::FileCreated { + file_id: Uuid::nil(), + name: "notes.md".into(), + parent_id: Uuid::nil(), + actor: Uuid::nil(), + }; + let s = event_notification("folder:abc", &event); + let v: Value = serde_json::from_str(&s).unwrap(); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["method"], "rt.event"); + assert_eq!(v["params"]["topic"], "folder:abc"); + assert_eq!(v["params"]["event"], "file_created"); + assert_eq!(v["params"]["data"]["name"], "notes.md"); + // The discriminator field must have been lifted OUT of `data` — + // otherwise the client sees `data.event` alongside the + // top-level `event`, which is confusing and violates the plan's + // wire shape. + assert!(v["params"]["data"].get("event").is_none()); + } + + #[test] + fn split_event_discriminator_extracts_and_removes() { + let input = serde_json::json!({ + "event": "file_created", + "file_id": "00000000-0000-0000-0000-000000000000", + "name": "x", + }); + let (name, rest) = split_event_discriminator(input); + assert_eq!(name, "file_created"); + assert!(rest.get("event").is_none()); + assert_eq!(rest["name"], "x"); + } +} diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 10ad9281..2f0e11a2 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -674,6 +674,16 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { .with_state(app_state.clone()); router = router.nest("/users", users_router); + // Realtime bus WebSocket. Auth (session cookie or bearer JWT) via + // the same `auth_middleware` the rest of `/api/*` gets; the handler + // extracts `CurrentUserId` from the extension the middleware + // installs. See `docs/plan/message-bus.md` and the module doc on + // `rt_ws` for the JSON-RPC 2.0 wire. + router = router.route( + "/rt/ws", + get(crate::interfaces::api::handlers::rt_ws::rt_ws_handler).with_state(app_state.clone()), + ); + // Collector for any unknown `/api/*` path. Without this, an // unmatched API URL falls through Axum's matcher to the // ServeDir fallback and is logged under `http::web` — wrong From 4a4c83b53a9494486f9bb68aa81c861c31399941 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 10 Sep 2026 01:04:01 +0200 Subject: [PATCH 02/21] doc(msg-bus): add asyncapi doc generator you can test generated doc resources/gen/asyncapi.json into https://studio.asyncapi.com/ --- src/bin/generate-asyncapi.rs | 356 +++++++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 src/bin/generate-asyncapi.rs diff --git a/src/bin/generate-asyncapi.rs b/src/bin/generate-asyncapi.rs new file mode 100644 index 00000000..9cc25056 --- /dev/null +++ b/src/bin/generate-asyncapi.rs @@ -0,0 +1,356 @@ +//! AsyncAPI 3.0 spec generator for the realtime message bus. +//! +//! Mirrors `generate-openapi.rs`: constructs the spec from the same +//! Rust enums the server uses (`Topic`, `RealtimeEvent`, JSON-RPC +//! error codes) and writes `resources/gen/asyncapi.json`. +//! +//! This is the first-PR MVP surface — the two topics and two events +//! that Phase A ships (see `docs/plan/message-bus.md § First PR`). +//! Adding a topic/event later is a match arm + a new schema block in +//! this file; the CI dirty-tree check (same as OpenAPI's) prevents +//! spec/code drift. +//! +//! Format: JSON, not YAML — matches `openapi.json`. AsyncAPI's own +//! tooling reads either; JSON also keeps us dep-free. +//! +//! Invocation: +//! +//! ```bash +//! cargo run --bin generate-asyncapi +//! # or +//! just asyncapi +//! ``` + +use std::fs; +use std::path::PathBuf; + +use oxicloud::application::ports::realtime_ports::error_code; +use serde_json::{Value, json}; + +fn main() { + let doc = build_asyncapi(); + let json = + serde_json::to_string_pretty(&doc).expect("Failed to serialize AsyncAPI spec to JSON"); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let resources_gen_dir = manifest_dir.join("resources").join("gen"); + fs::create_dir_all(&resources_gen_dir).expect("Failed to create resources/gen directory"); + let output_path = resources_gen_dir.join("asyncapi.json"); + fs::write(&output_path, json).expect("Failed to write AsyncAPI spec to file"); + + println!( + "AsyncAPI spec generated successfully at: {}", + output_path.display() + ); +} + +fn build_asyncapi() -> Value { + json!({ + "asyncapi": "3.0.0", + "info": { + "title": "OxiCloud realtime message bus", + "version": env!("CARGO_PKG_VERSION"), + "description": r#" +JSON-RPC 2.0 over WebSocket for control + events, Yjs sync protocol for +CRDT binary frames. The wire is described here for the first-PR MVP +surface (folder-live updates); Phase B (comments, presence) and +Phase C (sync-client push, album live) extend the same channels — see +`docs/plan/message-bus.md`. +"#.trim(), + "license": { "name": "AGPL-3.0-or-later" }, + }, + "servers": { + "default": { + "host": "{host}", + "pathname": "/api/rt/ws", + "protocol": "wss", + "description": "OxiCloud realtime bus WebSocket endpoint", + "variables": { + "host": { + "description": "Server host — replace with the deployment domain", + "default": "cloud.example.com", + }, + }, + "protocolVersion": "13", + // Subprotocol advertised in the WS handshake. Handler + // accepts `oxi.rt.v1` and the optional bearer element + // `authorization.bearer.` alongside it. + "bindings": { + "ws": { "subProtocol": "oxi.rt.v1" } + }, + } + }, + "channels": channels(), + "operations": operations(), + "components": components(), + }) +} + +fn channels() -> Value { + json!({ + "Folder": { + "address": "folder:{folderId}", + "description": "A folder's mutation stream — file/subfolder created events fire here. AuthZ: caller must hold `Read` on the folder.", + "parameters": { + "folderId": { "description": "Folder UUID" } + }, + "messages": { + "SubscribeRequest": { "$ref": "#/components/messages/RtSubscribeRequest" }, + "UnsubscribeRequest": { "$ref": "#/components/messages/RtUnsubscribeRequest" }, + "PingRequest": { "$ref": "#/components/messages/RtPingRequest" }, + "SubscribedResponse": { "$ref": "#/components/messages/RtSubscribedResponse" }, + "ErrorResponse": { "$ref": "#/components/messages/RtErrorResponse" }, + "FolderEvent": { "$ref": "#/components/messages/RtFolderEventNotification" }, + } + }, + "UserAuthz": { + "address": "user:{userId}:authz", + "description": "A user's private AuthZ-change channel. Identity-scoped: caller_id must equal userId (no admin bypass).", + "parameters": { + "userId": { "description": "User UUID — must match the authenticated caller" } + }, + "messages": { + "SubscribeRequest": { "$ref": "#/components/messages/RtSubscribeRequest" }, + "UnsubscribeRequest": { "$ref": "#/components/messages/RtUnsubscribeRequest" }, + } + } + }) +} + +fn operations() -> Value { + json!({ + "subscribeFolder": { + "action": "send", + "channel": { "$ref": "#/channels/Folder" }, + "summary": "Subscribe to a folder's mutation stream", + "messages": [ + { "$ref": "#/channels/Folder/messages/SubscribeRequest" } + ], + "reply": { + "channel": { "$ref": "#/channels/Folder" }, + "messages": [ + { "$ref": "#/channels/Folder/messages/SubscribedResponse" }, + { "$ref": "#/channels/Folder/messages/ErrorResponse" }, + ] + } + }, + "unsubscribeFolder": { + "action": "send", + "channel": { "$ref": "#/channels/Folder" }, + "summary": "Unsubscribe from a folder's mutation stream", + "messages": [ + { "$ref": "#/channels/Folder/messages/UnsubscribeRequest" } + ] + }, + "receiveFolderEvent": { + "action": "receive", + "channel": { "$ref": "#/channels/Folder" }, + "summary": "Server-pushed `rt.event` notification for a folder mutation", + "messages": [ + { "$ref": "#/channels/Folder/messages/FolderEvent" } + ] + } + }) +} + +fn components() -> Value { + json!({ + "messages": { + // ── Requests ──────────────────────────────────────────── + "RtSubscribeRequest": { + "name": "rt.subscribe", + "title": "Subscribe to a topic", + "contentType": "application/json", + "payload": { "$ref": "#/components/schemas/RtSubscribeRequestBody" }, + }, + "RtUnsubscribeRequest": { + "name": "rt.unsubscribe", + "title": "Unsubscribe from a topic", + "contentType": "application/json", + "payload": { "$ref": "#/components/schemas/RtUnsubscribeRequestBody" }, + }, + "RtPingRequest": { + "name": "rt.ping", + "title": "Keepalive ping", + "contentType": "application/json", + "payload": { "$ref": "#/components/schemas/RtPingRequestBody" }, + }, + // ── Responses ─────────────────────────────────────────── + "RtSubscribedResponse": { + "name": "rt.subscribed", + "title": "Subscribe / unsubscribe ack", + "contentType": "application/json", + "payload": { "$ref": "#/components/schemas/RtSuccessResponseBody" }, + }, + "RtErrorResponse": { + "name": "rt.error", + "title": "JSON-RPC error object", + "contentType": "application/json", + "payload": { "$ref": "#/components/schemas/RtErrorResponseBody" }, + }, + // ── Notifications (server → client) ───────────────────── + "RtFolderEventNotification": { + "name": "rt.event", + "title": "Folder mutation event", + "contentType": "application/json", + "payload": { "$ref": "#/components/schemas/RtFolderEventBody" }, + } + }, + "schemas": { + "RtSubscribeRequestBody": rpc_request_schema("rt.subscribe", topic_params_schema()), + "RtUnsubscribeRequestBody": rpc_request_schema("rt.unsubscribe", topic_params_schema()), + "RtPingRequestBody": rpc_request_schema("rt.ping", json!({ "type": "null" })), + "RtSuccessResponseBody": rpc_success_response_schema(), + "RtErrorResponseBody": rpc_error_response_schema(), + "RtFolderEventBody": folder_event_notification_schema(), + "FileCreatedData": file_created_schema(), + "FolderCreatedData": folder_created_schema(), + } + }) +} + +// ─── Schema builders ──────────────────────────────────────────────────────── + +fn rpc_request_schema(method: &str, params_schema: Value) -> Value { + json!({ + "type": "object", + "required": ["jsonrpc", "id", "method"], + "properties": { + "jsonrpc": { "type": "string", "const": "2.0" }, + "id": { "type": ["integer", "string", "null"] }, + "method": { "type": "string", "const": method }, + "params": params_schema, + } + }) +} + +fn topic_params_schema() -> Value { + json!({ + "type": "object", + "required": ["topic"], + "properties": { + "topic": { + "type": "string", + "description": "Wire form: `folder:` or `user::authz`", + "examples": ["folder:00000000-0000-0000-0000-000000000000"], + } + } + }) +} + +fn rpc_success_response_schema() -> Value { + json!({ + "type": "object", + "required": ["jsonrpc", "id", "result"], + "properties": { + "jsonrpc": { "type": "string", "const": "2.0" }, + "id": { "type": ["integer", "string", "null"] }, + "result": { "type": "object" }, + } + }) +} + +fn rpc_error_response_schema() -> Value { + // The `code`/`message` catalog is the stable public vocabulary — + // any change here IS a wire break. Every entry mirrors + // `application/ports/realtime_ports.rs::error_code`. + json!({ + "type": "object", + "required": ["jsonrpc", "id", "error"], + "properties": { + "jsonrpc": { "type": "string", "const": "2.0" }, + "id": { "type": ["integer", "string", "null"] }, + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "integer", + "enum": [ + error_code::NO_READ, + error_code::NO_SHARE, + error_code::NO_COMMENT, + error_code::TOPIC_FORBIDDEN, + error_code::SUB_LIMIT, + error_code::RATE_LIMITED, + error_code::NO_EDIT, + error_code::INTERNAL_ERROR, + error_code::INVALID_REQUEST, + error_code::METHOD_NOT_FOUND, + error_code::INVALID_PARAMS, + ], + }, + "message": { + "type": "string", + "description": "Stable wire vocabulary; matches the `code`.", + "enum": [ + "no_read", "no_share", "no_comment", "topic_forbidden", + "sub_limit", "rate_limited", "no_edit", + "internal_error", "invalid_request", + "method_not_found", "invalid_params", + ], + }, + "data": { + "type": "object", + "description": "Optional caller-facing context (e.g. offending topic).", + } + } + } + } + }) +} + +fn folder_event_notification_schema() -> Value { + json!({ + "type": "object", + "description": "JSON-RPC notification (no `id`). `method = \"rt.event\"`.", + "required": ["jsonrpc", "method", "params"], + "properties": { + "jsonrpc": { "type": "string", "const": "2.0" }, + "method": { "type": "string", "const": "rt.event" }, + "params": { + "type": "object", + "required": ["topic", "event", "data"], + "properties": { + "topic": { "type": "string" }, + "event": { + "type": "string", + "enum": ["file_created", "folder_created"], + }, + "data": { + "oneOf": [ + { "$ref": "#/components/schemas/FileCreatedData" }, + { "$ref": "#/components/schemas/FolderCreatedData" }, + ] + } + } + } + } + }) +} + +fn file_created_schema() -> Value { + json!({ + "type": "object", + "required": ["file_id", "name", "parent_id", "actor"], + "properties": { + "file_id": { "type": "string", "format": "uuid" }, + "name": { "type": "string" }, + "parent_id": { "type": "string", "format": "uuid" }, + "actor": { "type": "string", "format": "uuid" }, + } + }) +} + +fn folder_created_schema() -> Value { + json!({ + "type": "object", + "required": ["folder_id", "name", "parent_id", "actor"], + "properties": { + "folder_id": { "type": "string", "format": "uuid" }, + "name": { "type": "string" }, + "parent_id": { "type": "string", "format": "uuid" }, + "actor": { "type": "string", "format": "uuid" }, + } + }) +} From a2d27a61fea2485960fe603957299516bb3c98d0 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 10 Sep 2026 00:25:39 +0200 Subject: [PATCH 03/21] test(message-bus): test basic scenario use a helper to run scenario in hurl like style --- Cargo.lock | 1 + Cargo.toml | 31 ++- justfile | 7 + src/bin/opaque-hurl-helper.rs | 73 +++++- src/bin/rt-hurl-helper.rs | 426 ++++++++++++++++++++++++++++++++++ tests/api/rt_bus_check.sh | 219 +++++++++++++++++ tests/api/run.sh | 16 +- 7 files changed, 769 insertions(+), 4 deletions(-) create mode 100644 src/bin/rt-hurl-helper.rs create mode 100755 tests/api/rt_bus_check.sh diff --git a/Cargo.lock b/Cargo.lock index 870a7493..7590cfee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4721,6 +4721,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", + "tokio-tungstenite", "tokio-util", "toml 1.1.2+spec-1.1.0", "tower", diff --git a/Cargo.toml b/Cargo.toml index 250ec8a5..049f814e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,10 @@ axum = { version = "0.8.8", features = ["multipart", "http1", "http2", "tokio", tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "io-util", "net", "time", "sync", "fs", "process", "signal"] } tokio-util = { version = "0.7.18", features = ["io", "codec", "compat"] } tokio-stream = { version = "0.1.18", features = ["fs", "sync"] } +# Only pulled in by the `test_utils` feature (rt-hurl-helper bin). Not +# shipped in release; pinned to the same 0.28 axum 0.8.8 already brings +# transitively so we don't duplicate the crate graph. +tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect", "handshake"], optional = true } bytes = "1.11.1" tempfile = "3.27.0" tower = "0.5.3" @@ -190,7 +194,7 @@ metrics-exporter-prometheus = { version = "0.18", default-features = false } [features] default = [] -test_utils = ["mockall"] +test_utils = ["mockall", "dep:tokio-tungstenite"] integration_tests = [] # WASM plugin runtime (Extism). Opt-in: bundles wasmtime, a large engine most # deployments won't use. Activation also requires OXICLOUD_ENABLE_PLUGINS=true. @@ -262,6 +266,16 @@ path = "src/bin/generate-openapi.rs" # Invoked by `just openapi`, which passes `--features dev_tools`. required-features = ["dev_tools"] +[[bin]] +name = "generate-asyncapi" +path = "src/bin/generate-asyncapi.rs" +# Dev-only: regenerates `resources/gen/asyncapi.json` — the WS surface's +# analogue of openapi.json. Constructed from the same `error_code` +# constants + `Topic`/`RealtimeEvent` shapes the server uses, so the +# spec stays in sync with the implementation by construction. Same +# gating as `generate-openapi`. Invoked by `just asyncapi`. +required-features = ["dev_tools"] + [[bin]] name = "opaque-hurl-helper" path = "src/bin/opaque-hurl-helper.rs" @@ -292,6 +306,21 @@ path = "src/bin/load-seed.rs" # and load-nightly.yml build it explicitly with --features load_seed_bin. required-features = ["load_seed_bin"] +[[bin]] +name = "rt-hurl-helper" +path = "src/bin/rt-hurl-helper.rs" +# Test-suite WebSocket client for the realtime message bus. Hurl is +# HTTP-only and cannot drive a WS handshake or read frames; this bin +# supplies the two modes the smoke test needs — `subscribe-and-collect` +# (background subscriber that captures events to JSON) and +# `expect-denied` (synchronous check that a subscribe attempt is +# rejected with a specific JSON-RPC error code). Invoked from +# tests/api/rt_bus_check.sh after the main hurl block. +# +# Not shipped in release: gated behind `test_utils` alongside the +# opaque/dpop helpers. +required-features = ["test_utils"] + # Phase 0 perf harness — Task 0.2 (criterion latency + output-size bench). [[bench]] name = "thumbnails" diff --git a/justfile b/justfile index fe306d35..39157f38 100644 --- a/justfile +++ b/justfile @@ -194,6 +194,13 @@ audit: openapi: cargo run --features dev_tools --bin generate-openapi +# Regenerate `resources/gen/asyncapi.json` — the WS surface's spec, +# analogue of openapi.json. Built from the `Topic`, `RealtimeEvent`, +# and `error_code` constants in `application/ports/realtime_ports.rs` +# so the spec stays in sync with the wire by construction. +asyncapi: + cargo run --features dev_tools --bin generate-asyncapi + db: docker compose up -d postgres diff --git a/src/bin/opaque-hurl-helper.rs b/src/bin/opaque-hurl-helper.rs index b7f2d974..ed8325f4 100644 --- a/src/bin/opaque-hurl-helper.rs +++ b/src/bin/opaque-hurl-helper.rs @@ -402,8 +402,79 @@ async fn main() -> ExitCode { Err(e) => return fail(format!("/api/admin/sessions network: {e}")), } + // ── OPAQUE-minted JWT works against the realtime WS ───────────── + // + // Regression guard: `auth_middleware` doesn't inspect how a JWT + // was minted, so an OPAQUE-issued access_token must Just Work on + // `/api/rt/ws` the same way a legacy-password one does. If a + // future refactor makes WS auth diverge from the general + // request-auth path, this smoke fails and the divergence gets + // caught here rather than only surfacing in the collab editor. + // + // The check itself is trivial: connect with the OPAQUE JWT, send + // one `rt.ping`, expect `result.pong == true`. + if let Err(msg) = opaque_jwt_ws_smoke(base, &auth.access_token).await { + return fail(format!("OPAQUE JWT + WS: {msg}")); + } + eprintln!( - "opaque-hurl-helper: OK — register + login + /me + admin sessions origin=opaque for '{username}'" + "opaque-hurl-helper: OK — register + login + /me + admin sessions origin=opaque + rt.ping over WS for '{username}'" ); ExitCode::from(EXIT_OK) } + +async fn opaque_jwt_ws_smoke(base: &str, access_token: &str) -> Result<(), String> { + use futures::{SinkExt, StreamExt}; + use tokio_tungstenite::tungstenite::Message; + use tokio_tungstenite::tungstenite::client::IntoClientRequest; + use tokio_tungstenite::tungstenite::http::HeaderValue; + + let ws_url = match base.strip_prefix("http://") { + Some(rest) => format!("ws://{rest}/api/rt/ws"), + None => match base.strip_prefix("https://") { + Some(rest) => format!("wss://{rest}/api/rt/ws"), + None => return Err(format!("unexpected base scheme: {base}")), + }, + }; + + let mut req = ws_url + .into_client_request() + .map_err(|e| format!("bad url: {e}"))?; + req.headers_mut().insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {access_token}")) + .map_err(|e| format!("bad bearer header: {e}"))?, + ); + let (mut ws, _resp) = tokio_tungstenite::connect_async(req) + .await + .map_err(|e| format!("connect failed: {e}"))?; + + let ping = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "rt.ping", + }) + .to_string(); + ws.send(Message::Text(ping.into())) + .await + .map_err(|e| format!("send: {e}"))?; + + // Bounded wait — the server should reply immediately. A hung reply + // means the WS handler didn't recognise the JWT (misgated + // middleware) or panicked; we treat either as a hard failure. + let msg = tokio::time::timeout(std::time::Duration::from_secs(2), ws.next()) + .await + .map_err(|_| "rt.ping response timed out".to_string())? + .ok_or_else(|| "socket closed before response".to_string())? + .map_err(|e| format!("recv: {e}"))?; + + let Message::Text(text) = msg else { + return Err(format!("expected text frame, got {msg:?}")); + }; + let v: serde_json::Value = + serde_json::from_str(&text).map_err(|e| format!("bad json: {e}: {text}"))?; + if v["result"]["pong"] != true { + return Err(format!("expected pong=true, got: {v}")); + } + Ok(()) +} diff --git a/src/bin/rt-hurl-helper.rs b/src/bin/rt-hurl-helper.rs new file mode 100644 index 00000000..62cf43d4 --- /dev/null +++ b/src/bin/rt-hurl-helper.rs @@ -0,0 +1,426 @@ +//! WebSocket-side smoke-test helper for the realtime message bus. +//! +//! Hurl is HTTP-only — it can't do a WS upgrade, let alone read frames +//! for later assertion. This binary is the WS half of the smoke test: +//! opens `/api/rt/ws`, speaks JSON-RPC 2.0, and either collects events +//! into a JSON file for shell assertions (`subscribe-and-collect`) or +//! validates that an authz-denied subscribe returns the expected wire +//! error code (`expect-denied`). +//! +//! Invocation (from `tests/api/rt_bus_check.sh`): +//! +//! ```bash +//! rt-hurl-helper subscribe-and-collect \ +//! --url ws://127.0.0.1:$PORT/api/rt/ws \ +//! --token $USER_JWT \ +//! --subscribe folder:$FOLDER_A \ +//! --expect-events 1 \ +//! --timeout 3s \ +//! --output /tmp/rt_s1.json & +//! +//! rt-hurl-helper expect-denied \ +//! --url ws://127.0.0.1:$PORT/api/rt/ws \ +//! --token $USER2_JWT \ +//! --subscribe folder:$FOLDER_A \ +//! --reason no_read \ +//! --timeout 2s +//! ``` +//! +//! Exit codes: +//! * 0 — expectation met. +//! * 1 — expectation failed (wrong event, unexpected event, timeout +//! without hitting the target, denied when expecting event, +//! event when expecting denied). +//! * 2 — protocol / connect error the shell can distinguish from a +//! real assertion failure. +//! +//! JSON output shape for `subscribe-and-collect` (written to `--output`): +//! +//! ```jsonc +//! { +//! "subscribed": ["folder:..."], +//! "events": [ { "topic": "folder:...", "event": "file_created", +//! "data": { ... } } ], +//! "timed_out": false +//! } +//! ``` + +use std::process::ExitCode; +use std::time::Duration; + +use futures::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tokio::time::timeout; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; + +// ════════════════════════════════════════════════════════════════════════════ +// CLI parsing (minimal, dependency-free) +// ════════════════════════════════════════════════════════════════════════════ + +struct Args { + mode: Mode, + url: String, + token: String, + subscribe: Vec, + expect_events: Option, + reason: Option, + timeout: Duration, + output: Option, +} + +enum Mode { + SubscribeAndCollect, + ExpectDenied, +} + +fn parse_duration(s: &str) -> Result { + // Accept `s`, `ms`, or a bare integer (interpreted as + // seconds). Kept small — hurl and shell are the only callers. + let s = s.trim(); + if let Some(num) = s.strip_suffix("ms") { + num.parse::() + .map(Duration::from_millis) + .map_err(|_| format!("bad duration: {s}")) + } else if let Some(num) = s.strip_suffix('s') { + num.parse::() + .map(Duration::from_secs) + .map_err(|_| format!("bad duration: {s}")) + } else { + s.parse::() + .map(Duration::from_secs) + .map_err(|_| format!("bad duration: {s}")) + } +} + +fn parse_args() -> Result { + let mut it = std::env::args().skip(1); + let mode = match it.next().as_deref() { + Some("subscribe-and-collect") => Mode::SubscribeAndCollect, + Some("expect-denied") => Mode::ExpectDenied, + Some(other) => return Err(format!("unknown mode: {other}")), + None => return Err("mode is required".into()), + }; + + let mut url = None; + let mut token = None; + let mut subscribe = Vec::new(); + let mut expect_events = None; + let mut reason = None; + let mut timeout = Duration::from_secs(3); + let mut output = None; + + while let Some(flag) = it.next() { + let value = it + .next() + .ok_or_else(|| format!("flag {flag} requires a value"))?; + match flag.as_str() { + "--url" => url = Some(value), + "--token" => token = Some(value), + "--subscribe" => subscribe.push(value), + "--expect-events" => { + expect_events = Some( + value + .parse::() + .map_err(|_| format!("--expect-events not a number: {value}"))?, + ); + } + "--reason" => reason = Some(value), + "--timeout" => timeout = parse_duration(&value)?, + "--output" => output = Some(value), + other => return Err(format!("unknown flag: {other}")), + } + } + + Ok(Args { + mode, + url: url.ok_or("--url required")?, + token: token.ok_or("--token required")?, + subscribe, + expect_events, + reason, + timeout, + output, + }) +} + +// ════════════════════════════════════════════════════════════════════════════ +// Main +// ════════════════════════════════════════════════════════════════════════════ + +#[tokio::main(flavor = "current_thread")] +async fn main() -> ExitCode { + let args = match parse_args() { + Ok(a) => a, + Err(e) => { + eprintln!("rt-hurl-helper: {e}"); + return ExitCode::from(2); + } + }; + + let result = match args.mode { + Mode::SubscribeAndCollect => subscribe_and_collect(args).await, + Mode::ExpectDenied => expect_denied(args).await, + }; + + match result { + Ok(()) => ExitCode::SUCCESS, + Err(HelperError::Expectation(msg)) => { + eprintln!("rt-hurl-helper: expectation failed: {msg}"); + ExitCode::from(1) + } + Err(HelperError::Protocol(msg)) => { + eprintln!("rt-hurl-helper: protocol error: {msg}"); + ExitCode::from(2) + } + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// Errors +// ════════════════════════════════════════════════════════════════════════════ + +enum HelperError { + /// The wire behaved OK but didn't match what the test expected — + /// e.g. a `subscribed` ack when we expected `denied`, or fewer + /// events than requested before timeout. Exit 1: test failure. + Expectation(String), + /// Something is broken at the transport/JSON layer — connect + /// refused, malformed frame, TLS handshake failed. Exit 2: + /// infrastructure problem, not a test result. + Protocol(String), +} + +impl From for HelperError { + fn from(e: E) -> Self { + HelperError::Protocol(e.to_string()) + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// WS connection +// ════════════════════════════════════════════════════════════════════════════ + +/// Open a WS connection to `url` with the given bearer token attached +/// via `Authorization: Bearer `. Programmatic client — this is the +/// path native clients (this helper, future sync-client integrations) +/// take. Browser clients that can't set the header will use the +/// `Sec-WebSocket-Protocol` subprotocol fallback (Phase A follow-up). +async fn connect_ws( + url: &str, + token: &str, +) -> Result< + tokio_tungstenite::WebSocketStream>, + HelperError, +> { + let mut req = url + .into_client_request() + .map_err(|e| HelperError::Protocol(format!("bad url: {e}")))?; + let bearer = format!("Bearer {token}"); + req.headers_mut().insert( + "Authorization", + HeaderValue::from_str(&bearer) + .map_err(|e| HelperError::Protocol(format!("bad token: {e}")))?, + ); + let (ws, _resp) = tokio_tungstenite::connect_async(req) + .await + .map_err(|e| HelperError::Protocol(format!("connect failed: {e}")))?; + Ok(ws) +} + +// ════════════════════════════════════════════════════════════════════════════ +// Mode: subscribe-and-collect +// ════════════════════════════════════════════════════════════════════════════ + +async fn subscribe_and_collect(args: Args) -> Result<(), HelperError> { + if args.subscribe.is_empty() { + return Err(HelperError::Protocol( + "--subscribe required for subscribe-and-collect".into(), + )); + } + let expect_events = args.expect_events.unwrap_or(0); + + let mut ws = connect_ws(&args.url, &args.token).await?; + + // Subscribe to every requested topic; track pending request ids so + // we know when all acks have arrived before we start counting + // events. + let mut subscribed: Vec = Vec::new(); + let mut pending_subs: std::collections::HashMap = std::collections::HashMap::new(); + for (i, topic) in args.subscribe.iter().enumerate() { + let req_id = (i as u64) + 1; + let frame = json!({ + "jsonrpc": "2.0", + "id": req_id, + "method": "rt.subscribe", + "params": { "topic": topic }, + }); + ws.send(Message::Text(frame.to_string().into())).await?; + pending_subs.insert(req_id, topic.clone()); + } + + let mut events: Vec = Vec::new(); + let mut timed_out = false; + + let deadline = tokio::time::Instant::now() + args.timeout; + + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + timed_out = true; + break; + } + // Exit early: all acks received AND enough events collected. + if pending_subs.is_empty() && events.len() >= expect_events { + break; + } + + let msg = match timeout(remaining, ws.next()).await { + Ok(Some(Ok(m))) => m, + Ok(Some(Err(e))) => { + return Err(HelperError::Protocol(format!("ws error: {e}"))); + } + Ok(None) => { + return Err(HelperError::Protocol("connection closed by peer".into())); + } + Err(_) => { + timed_out = true; + break; + } + }; + + let Message::Text(text) = msg else { + // Ignore ping/pong/binary; server may send close later. + continue; + }; + let value: Value = serde_json::from_str(&text) + .map_err(|e| HelperError::Protocol(format!("bad frame: {e}: {text}")))?; + + // Response to a subscribe request? + if let Some(id_num) = value.get("id").and_then(|v| v.as_u64()) { + let topic = pending_subs.remove(&id_num); + if let Some(err) = value.get("error") { + return Err(HelperError::Expectation(format!( + "subscribe to {} denied: {}", + topic.as_deref().unwrap_or(""), + err, + ))); + } + if let Some(topic) = topic { + subscribed.push(topic); + } + continue; + } + + // Notification (id-less)? + let method = value.get("method").and_then(|v| v.as_str()).unwrap_or(""); + if method == "rt.event" + && let Some(params) = value.get("params") + { + events.push(params.clone()); + } + // Other notifications (`rt.revoked`, `rt.pong`) — ignored for + // subscribe-and-collect. They can be added to the output + // schema when scenarios need them. + } + + // Assertion: at least `expect_events` collected before timeout. + let met = events.len() >= expect_events; + + // Always write output (even on failure) so the shell can diff. + if let Some(path) = args.output.as_ref() { + let summary = json!({ + "subscribed": subscribed, + "events": events, + "timed_out": timed_out, + }); + std::fs::write(path, serde_json::to_vec_pretty(&summary).unwrap()) + .map_err(|e| HelperError::Protocol(format!("write output: {e}")))?; + } + + if !met { + return Err(HelperError::Expectation(format!( + "expected {} events, got {} ({}timeout)", + expect_events, + events.len(), + if timed_out { "with " } else { "no " } + ))); + } + Ok(()) +} + +// ════════════════════════════════════════════════════════════════════════════ +// Mode: expect-denied +// ════════════════════════════════════════════════════════════════════════════ + +async fn expect_denied(args: Args) -> Result<(), HelperError> { + let topic = args + .subscribe + .first() + .ok_or_else(|| HelperError::Protocol("--subscribe required for expect-denied".into()))? + .clone(); + + let mut ws = connect_ws(&args.url, &args.token).await?; + + let req_id: u64 = 1; + let frame = json!({ + "jsonrpc": "2.0", + "id": req_id, + "method": "rt.subscribe", + "params": { "topic": topic }, + }); + ws.send(Message::Text(frame.to_string().into())).await?; + + // Wait for the id-matched response with an `error` object. Any + // notification arriving before the response is skipped — the + // server should not fan out to a subscription that hasn't been + // acked yet, but the check is robust to that ordering anyway. + let deadline = tokio::time::Instant::now() + args.timeout; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(HelperError::Expectation( + "timeout without a subscribe response".into(), + )); + } + + let msg = match timeout(remaining, ws.next()).await { + Ok(Some(Ok(m))) => m, + Ok(Some(Err(e))) => return Err(HelperError::Protocol(format!("ws error: {e}"))), + Ok(None) => return Err(HelperError::Protocol("connection closed by peer".into())), + Err(_) => { + return Err(HelperError::Expectation( + "timeout without a subscribe response".into(), + )); + } + }; + let Message::Text(text) = msg else { continue }; + let value: Value = serde_json::from_str(&text) + .map_err(|e| HelperError::Protocol(format!("bad frame: {e}: {text}")))?; + + // Match by id. + let Some(id_num) = value.get("id").and_then(|v| v.as_u64()) else { + continue; + }; + if id_num != req_id { + continue; + } + + // Expect: error object present. + let Some(err) = value.get("error") else { + return Err(HelperError::Expectation(format!( + "expected `error` object, got: {value}" + ))); + }; + let message = err.get("message").and_then(|v| v.as_str()).unwrap_or(""); + if let Some(want) = args.reason.as_ref() + && message != want + { + return Err(HelperError::Expectation(format!( + "expected reason `{want}`, got `{message}` (full error: {err})" + ))); + } + return Ok(()); + } +} diff --git a/tests/api/rt_bus_check.sh b/tests/api/rt_bus_check.sh new file mode 100755 index 00000000..75a660c6 --- /dev/null +++ b/tests/api/rt_bus_check.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# Realtime bus smoke test — the parts Hurl can't drive. +# +# Hurl is HTTP-only and cannot open a WebSocket, so the WS half of the test +# runs through `rt-hurl-helper` (a small Rust bin gated on `test_utils`). +# This script orchestrates it against a live oxicloud server: bootstraps +# state with curl, exercises the bus, asserts on the helper's JSON output. +# +# Four scenarios: +# S1 Positive delivery — subscribe to folder A, upload into A, see event. +# S2 Topic isolation — subscribe to folder A only, upload into B and +# then A; must see A's event only. +# S3 AuthZ denial — user2 subscribes to folder A owned by user1 +# without a grant; expect wire reason `no_read`. +# S4 Anti-enumeration — subscribe to a folder that does not exist; +# must return the SAME wire reason (`no_read`) +# as S3, per the plan's anti-enum invariant. +# +# Exit non-zero on any failure — run.sh treats that as a suite failure. +# ───────────────────────────────────────────────────────────────────────────── + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +BUILD_TARGET="${BUILD_TARGET:-debug}" +HELPER_BIN="$REPO_ROOT/target/$BUILD_TARGET/rt-hurl-helper" + +# ── Env from test.env (base_url, admin username/password) ──────────────────── +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/test.env" +: "${base_url:?}" "${username:?}" "${password:?}" + +# WS URL derived from base_url (test.env uses http://); tolerate https for +# future deployments even though the test suite runs plain HTTP. +case "$base_url" in + http://*) ws_url="ws://${base_url#http://}/api/rt/ws" ;; + https://*) ws_url="wss://${base_url#https://}/api/rt/ws" ;; + *) echo "rt_bus_check: unexpected base_url scheme: $base_url" >&2; exit 2 ;; +esac + +log() { printf '\033[1;36m[rt_bus_check]\033[0m %s\n' "$*"; } +die() { printf '\033[1;31m[rt_bus_check FAIL]\033[0m %s\n' "$*" >&2; exit 1; } + +# ── Build the helper on demand (matches opaque/dpop helper convention) ────── +if [[ ! -x "$HELPER_BIN" ]]; then + log "Building rt-hurl-helper ($BUILD_TARGET)..." + case "$BUILD_TARGET" in + debug) (cd "$REPO_ROOT" && cargo build --features test_utils --bin rt-hurl-helper 2>&1 | tail -n 20) || die "rt-hurl-helper build failed" ;; + release) (cd "$REPO_ROOT" && cargo build --release --features test_utils --bin rt-hurl-helper 2>&1 | tail -n 20) || die "rt-hurl-helper build failed" ;; + esac +fi + +# ── curl wrappers ─────────────────────────────────────────────────────────── +c_post() { + local url="$1" auth="$2" body="$3" + curl -sS -X POST -H "Content-Type: application/json" \ + ${auth:+-H "Authorization: Bearer $auth"} \ + -d "$body" "$url" +} + +c_get() { + local url="$1" auth="$2" + curl -sS -H "Accept: application/json" \ + ${auth:+-H "Authorization: Bearer $auth"} \ + "$url" +} + +# ── Setup: register fresh users; the test.env admin may be OPAQUE- +# migrated and the legacy password-login path refuses those accounts, +# so we don't use it at all — same pattern as `dedup_admin_gate.hurl` +# and the other hurl scenarios that need a self-contained principal. +# The scenarios only need "a user with their own folders", not admin +# rights. +suffix="$(date +%s)_$$" +user1_name="rtbus_u1_$suffix" +user1_pass="RtBusU1Pass1!" +user2_name="rtbus_u2_$suffix" +user2_pass="RtBusU2Pass1!" + +log "Register user1 ($user1_name) and log in..." +c_post "$base_url/api/auth/register" "" \ + "$(printf '{"username":"%s","email":"%s@example.com","password":"%s"}' \ + "$user1_name" "$user1_name" "$user1_pass")" > /dev/null +u1_login=$(c_post "$base_url/api/auth/login" "" \ + "$(printf '{"username":"%s","password":"%s"}' "$user1_name" "$user1_pass")") +user1_token=$(printf '%s' "$u1_login" | jq -r '.access_token') +[[ -n "$user1_token" && "$user1_token" != "null" ]] || die "no user1 token: $u1_login" + +log "Discover user1 root folder..." +folders=$(c_get "$base_url/api/folders" "$user1_token") +root_id=$(printf '%s' "$folders" | jq -r '.[0].id') +[[ -n "$root_id" && "$root_id" != "null" ]] || die "no root folder for user1: $folders" + +log "Create folder A (rt_bus_A_$suffix) and folder B (rt_bus_B_$suffix)..." +folder_a=$(c_post "$base_url/api/folders" "$user1_token" \ + "$(printf '{"name":"rt_bus_A_%s","parent_id":"%s"}' "$suffix" "$root_id")" | jq -r '.id') +folder_b=$(c_post "$base_url/api/folders" "$user1_token" \ + "$(printf '{"name":"rt_bus_B_%s","parent_id":"%s"}' "$suffix" "$root_id")" | jq -r '.id') +[[ -n "$folder_a" && "$folder_a" != "null" ]] || die "folder A creation failed" +[[ -n "$folder_b" && "$folder_b" != "null" ]] || die "folder B creation failed" + +# Register a second user for the AuthZ-denial scenario (S3). No grant on +# folder A → subscribe attempt must be denied. +log "Register user2 ($user2_name) and log in..." +c_post "$base_url/api/auth/register" "" \ + "$(printf '{"username":"%s","email":"%s@example.com","password":"%s"}' \ + "$user2_name" "$user2_name" "$user2_pass")" > /dev/null +user2_login=$(c_post "$base_url/api/auth/login" "" \ + "$(printf '{"username":"%s","password":"%s"}' "$user2_name" "$user2_pass")") +user2_token=$(printf '%s' "$user2_login" | jq -r '.access_token') +[[ -n "$user2_token" && "$user2_token" != "null" ]] || die "no user2 token: $user2_login" + +# ── Helper: create a small file inside a folder via the byte-upload path. +# Not delta / instant-upload; keeps the wire simple and hits the same +# `upload_file_streaming` publish hook. +mkfile_in() { + local folder_id="$1" name="$2" token="$3" + local tmpfile + tmpfile="$(mktemp -t rtbus_body.XXXXXX)" + printf 'rt-bus-test-payload' > "$tmpfile" + # Multipart-upload path used by the frontend for byte uploads. + # NOTE: `folder_id` MUST come BEFORE the `file` part — file_handler.rs + # streams the parts in order and the fail-fast folder-required check + # fires the moment it sees the file bytes; a folder_id sent after the + # file arrives too late (returns 400 "folder_id is required"). + curl -sS -X POST \ + -H "Authorization: Bearer $token" \ + -F "folder_id=$folder_id" \ + -F "file=@$tmpfile;filename=$name" \ + "$base_url/api/files/upload" > /dev/null + rm -f "$tmpfile" +} + +# ── Scenario 1 — Positive delivery ────────────────────────────────────────── +log "S1: subscribe to folder A, upload into A, expect one file_created event." +out_s1="$(mktemp -t rtbus_s1.XXXXXX)" +"$HELPER_BIN" subscribe-and-collect \ + --url "$ws_url" \ + --token "$user1_token" \ + --subscribe "folder:$folder_a" \ + --expect-events 1 \ + --timeout 5s \ + --output "$out_s1" & +helper_pid=$! +# Give the ack a moment to install so the upload's post-commit publish +# lands on a live receiver, not an orphaned map entry. +sleep 0.4 +mkfile_in "$folder_a" "s1.txt" "$user1_token" +if ! wait "$helper_pid"; then + cat "$out_s1" >&2 || true + die "S1: helper did not observe the expected event" +fi +# jq assertions — one event, correct parent_id, correct discriminator. +[[ "$(jq -r '.events | length' "$out_s1")" == "1" ]] \ + || { cat "$out_s1"; die "S1: expected 1 event, got $(jq -r '.events | length' "$out_s1")"; } +[[ "$(jq -r '.events[0].event' "$out_s1")" == "file_created" ]] \ + || die "S1: wrong event discriminator: $(jq -r '.events[0].event' "$out_s1")" +[[ "$(jq -r '.events[0].data.parent_id' "$out_s1")" == "$folder_a" ]] \ + || die "S1: parent_id mismatch" +log "S1 OK" + +# ── Scenario 2 — Topic isolation ──────────────────────────────────────────── +log "S2: subscribe to folder A, upload into B (must be silent) and A (triggers exit)." +out_s2="$(mktemp -t rtbus_s2.XXXXXX)" +"$HELPER_BIN" subscribe-and-collect \ + --url "$ws_url" \ + --token "$user1_token" \ + --subscribe "folder:$folder_a" \ + --expect-events 1 \ + --timeout 5s \ + --output "$out_s2" & +helper_pid=$! +sleep 0.4 +# B first — should be dropped for the A subscriber. +mkfile_in "$folder_b" "s2_in_B.txt" "$user1_token" +# Small settle so if isolation is BROKEN, the B event has time to arrive +# before A's; the assertion below then catches it as a wrong parent_id. +sleep 0.2 +mkfile_in "$folder_a" "s2_in_A.txt" "$user1_token" +if ! wait "$helper_pid"; then + cat "$out_s2" >&2 || true + die "S2: helper did not observe the expected A event" +fi +# Exactly one event, and it MUST be from folder A. If isolation were +# broken, we'd either see 2 events or a B-parented event first. +[[ "$(jq -r '.events | length' "$out_s2")" == "1" ]] \ + || { cat "$out_s2"; die "S2: expected 1 event, got $(jq -r '.events | length' "$out_s2") (isolation broken?)"; } +[[ "$(jq -r '.events[0].data.parent_id' "$out_s2")" == "$folder_a" ]] \ + || die "S2: parent_id was $(jq -r '.events[0].data.parent_id' "$out_s2"), expected $folder_a" +log "S2 OK" + +# ── Scenario 3 — AuthZ denial ─────────────────────────────────────────────── +log "S3: user2 subscribes to folder A (no grant); expect no_read denial." +if ! "$HELPER_BIN" expect-denied \ + --url "$ws_url" \ + --token "$user2_token" \ + --subscribe "folder:$folder_a" \ + --reason no_read \ + --timeout 3s; then + die "S3: user2 was NOT denied on folder A (AuthZ gate broken?)" +fi +log "S3 OK" + +# ── Scenario 4 — Anti-enumeration parity ──────────────────────────────────── +log "S4: subscribe to a nonexistent folder; wire reason must equal S3." +fake_folder="00000000-0000-0000-0000-000000000000" +if ! "$HELPER_BIN" expect-denied \ + --url "$ws_url" \ + --token "$user1_token" \ + --subscribe "folder:$fake_folder" \ + --reason no_read \ + --timeout 3s; then + die "S4: nonexistent folder did not collapse to no_read (anti-enum invariant broken)" +fi +log "S4 OK" + +log "All four realtime-bus scenarios passed." diff --git a/tests/api/run.sh b/tests/api/run.sh index 7aa66375..5c4e073d 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -267,7 +267,19 @@ bash "$API_DIR/thumb_import_check.sh" bash "$API_DIR/storage_cleanup_check.sh" -# ── 5. OPAQUE crypto handshake — the parts Hurl can't drive ───────────── +# ── 5. Realtime message bus — WebSocket smoke test ────────────────────── +# Runs BEFORE the OPAQUE helper so its user registration + login uses +# the legacy password path (opaque_substrate.hurl migrates the admin +# account, but by running first this check is unaffected by whatever +# order later scenarios touch the auth substrate). Four scenarios: +# positive delivery, topic isolation, AuthZ denial on subscribe, +# anti-enumeration parity. See `tests/api/rt_bus_check.sh` and +# `docs/plan/message-bus.md`. +log "Running realtime-bus smoke test..." +BUILD_TARGET="$BUILD_TARGET" bash "$REPO_ROOT/tests/api/rt_bus_check.sh" \ + || die "realtime-bus smoke test failed" + +# ── 6. OPAQUE crypto handshake — the parts Hurl can't drive ───────────── # Full OPAQUE register + login handshake against the running server, # using the real ciphersuite client-side. Closes the gap left by # `opaque_substrate.hurl` (which covers only wire shape, not OPRF- @@ -289,7 +301,7 @@ OPAQUE_HELPER_USERNAME="$username" \ OPAQUE_HELPER_PASSWORD="$password" \ "$OPAQUE_HELPER_BIN" || die "OPAQUE crypto handshake failed" -# ── 6. DPoP wire protocol — the parts Hurl can't drive ────────────────── +# ── 7. DPoP wire protocol — the parts Hurl can't drive ────────────────── # Each proof carries a fresh jti, current iat, htm/htu matching the # exact request, an ES256 signature, and a threaded nonce — none of # which a declarative .hurl template can compute. See From d20c79205610b563bad15fd586ce6ec03824f833 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 10 Sep 2026 01:33:37 +0200 Subject: [PATCH 04/21] feat(message-bus): add ping/keepalive on WS + root declaraiton on AsyncAPI - plan also eviction in case of permison revoked --- docs/config/env.md | 8 +++ docs/plan/message-bus.md | 44 ++++++++++++++-- example.env | 21 ++++++++ src/bin/generate-asyncapi.rs | 75 +++++++++++++++++++++++++- src/bin/rt-hurl-helper.rs | 19 +++++-- src/interfaces/api/handlers/rt_ws.rs | 78 +++++++++++++++++++++++++++- tests/api/rt_bus_check.sh | 51 +++++++++++++++++- tests/common/server.env | 7 +++ 8 files changed, 293 insertions(+), 10 deletions(-) diff --git a/docs/config/env.md b/docs/config/env.md index e261767e..7ca660da 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -355,6 +355,14 @@ Today's shipped locales: `ar, de, en, es, fa, fr, hi, it, ja, ko, nl, pl, pt, ru Example: `OXICLOUD_TRUST_PROXY_CIDR=127.0.0.1/32,10.0.0.0/8,172.16.0.0/12` +## Realtime WebSocket + +| Variable | Default | Description | +|---|---|---| +| `OXICLOUD_RT_WS_KEEPALIVE_SECONDS` | `30` | Server-initiated protocol Ping interval on `/api/rt/ws`. Prevents intermediate proxies (Traefik, nginx, Cloudflare) and NAT boxes from reaping the TCP session as idle. Read at each WS connect — a change takes effect on new connections, no restart needed. Set `0` or any non-positive value to fall back to the default. | + +Tuning: 30 s is comfortably under nginx's 60 s `proxy_read_timeout` default and Cloudflare's 100 s hard limit. Behind Traefik with `respondingTimeouts.idleTimeout` bumped to `3600s` (as documented in the reverse-proxy setup), you can leave this at 30 s or raise it — the interval should sit at most half the smallest hop's idle timeout so a single missed Ping doesn't reap the connection. + ## Allocator Tuning These variables are read directly by **mimalloc**, not by OxiCloud's config parser. diff --git a/docs/plan/message-bus.md b/docs/plan/message-bus.md index 56ed4845..56df8f13 100644 --- a/docs/plan/message-bus.md +++ b/docs/plan/message-bus.md @@ -559,12 +559,50 @@ Follows the same shape as `generate-openapi`: - Generator produces spec covering the Phase-A-MVP surface only (`rt.subscribe` / `rt.unsubscribe` / `rt.ping` methods, - `rt.event` / `rt.revoked` notifications, `Folder(id)` and - `UserAuthz(u)` topics, `FileCreated` / `FolderCreated` events, - the error-code table). + `rt.event` notification, `Folder(id)` and `UserAuthz(u)` topics, + `FileCreated` / `FolderCreated` events, the error-code table, + `defaultContentType`, `securitySchemes.bearerAuth`, and a `ping` + operation with the `rt.pong` reply shape). - Adding a new topic/event/method later is an enum variant + serde derive → regenerate → commit. Same discipline as OpenAPI. +### AsyncAPI follow-ups (deferred) + +Land with their producer PRs; each is a small addition to +`generate-asyncapi.rs` alongside the code that emits it. + +- **`rt.revoked` notification** on the Folder + File channels — the + server-initiated eviction frame fired when a grant is revoked + mid-session ([[project-message-bus]] AuthZ eviction section). Ships + with the `AuthzChanged` publish hook in `ShareService::revoke`. + Wire shape is already fixed by the plan; the AsyncAPI additions are + a `RtRevokedNotification` message + a `receive`-action operation on + every resource-scoped channel that supports eviction. +- **Yjs binary frames — prose section** at the doc level: AsyncAPI + schemas can't fully describe the `[kind][doc_id][payload]` framing + (it's out-of-band from the JSON envelope), so a plain-English + section on the `Collab` channel description referring to + `docs/plan/markdown-collab.md § Wire protocol` is the pragmatic + documentation. Ships with the Collab channel definition when the + editor PR lands. +- **Server variable expansion** — add a `port` variable so local dev + URLs (`ws://localhost:8086/api/rt/ws`) can be expressed in tooling + that reads the AsyncAPI URL template. Trivial addition; not + blocking. +- **`defaultMessages` per channel** — AsyncAPI convention for + reducing per-operation `$ref` boilerplate as the message count + grows. Worth introducing once we hit ~10 messages per channel; MVP + has 6 on Folder, still legible. +- **Bindings on messages** — declare `bindings.ws.headers` on the + subscribe messages so tools can render the auth header shape (the + spec knows about it via `securitySchemes`, but per-message bindings + make it explicit at the point of use). +- **Reply message discrimination** — the `receiveFolderEvent` + operation could split into per-event-kind messages + (`RtFileCreatedEvent`, `RtFolderCreatedEvent`) instead of one + polymorphic `RtFolderEventNotification` with `oneOf`. Better + codegen for typed clients. Refactor when we generate an FE SDK. + ## AuthZ model (audit rules per AGENTS.md) ### The subscribe gate diff --git a/example.env b/example.env index 649443e5..d67ecc9f 100644 --- a/example.env +++ b/example.env @@ -1093,6 +1093,27 @@ OXICLOUD_WOPI_ENABLED=false # DEPRECATED — use OXICLOUD_TRUST_PROXY_CIDR instead #OXICLOUD_TRUST_PROXY_HEADERS= +# ----------------------------------------------------------------------------- +# REALTIME MESSAGE BUS (/api/rt/ws) +# ----------------------------------------------------------------------------- +# WebSocket endpoint for folder-live updates, notifications, and the +# collaborative editor. See docs/plan/message-bus.md for the JSON-RPC 2.0 +# wire protocol. + +# Server-initiated protocol Ping interval (seconds). Prevents intermediate +# proxies (Traefik, nginx, Cloudflare) and NAT boxes from reaping the TCP +# session as idle. Read at each WS connect — a change takes effect on new +# connections without restart. Set 0 (or any non-positive value) to fall +# back to the default. +# +# Tuning: the interval should sit at most half the smallest hop's idle +# timeout, so a single missed Ping doesn't kill the connection. Common +# floors: +# * nginx `proxy_read_timeout` default 60s → ping ≤ 30s +# * Cloudflare hard limit 100s → ping ≤ 45s +# * Traefik with idleTimeout bumped to 3600s → 30s is safely under +#OXICLOUD_RT_WS_KEEPALIVE_SECONDS=30 + # ----------------------------------------------------------------------------- # MEMORY ALLOCATOR TUNING (IMPORTANT FOR RAM USAGE) # ----------------------------------------------------------------------------- diff --git a/src/bin/generate-asyncapi.rs b/src/bin/generate-asyncapi.rs index 9cc25056..93e93bbd 100644 --- a/src/bin/generate-asyncapi.rs +++ b/src/bin/generate-asyncapi.rs @@ -59,12 +59,16 @@ Phase C (sync-client push, album live) extend the same channels — see "#.trim(), "license": { "name": "AGPL-3.0-or-later" }, }, + // Applied to every message that doesn't set its own — the JSON-RPC + // control frames are all `application/json`. Binary Yjs frames + // stay out of AsyncAPI (see the Server description for pointers). + "defaultContentType": "application/json", "servers": { "default": { "host": "{host}", "pathname": "/api/rt/ws", "protocol": "wss", - "description": "OxiCloud realtime bus WebSocket endpoint", + "description": "OxiCloud realtime bus WebSocket endpoint. Text frames are JSON-RPC 2.0. Binary frames (out of AsyncAPI scope) are Yjs sync protocol for the collab editor — see `docs/plan/markdown-collab.md`.", "variables": { "host": { "description": "Server host — replace with the deployment domain", @@ -78,6 +82,16 @@ Phase C (sync-client push, album live) extend the same channels — see "bindings": { "ws": { "subProtocol": "oxi.rt.v1" } }, + // Every request MUST be authenticated. Programmatic + // clients set `Authorization: Bearer ` on the WS + // upgrade (same header the REST API uses); browser + // clients — which can't set headers on `new WebSocket()` + // — will use the deferred ticket flow (a plain HTTP + // POST issues a short-lived one-shot ticket bound to + // the WS URL, see the plan's DPoP-gap section). + "security": [ + { "$ref": "#/components/securitySchemes/bearerAuth" } + ], } }, "channels": channels(), @@ -98,6 +112,7 @@ fn channels() -> Value { "SubscribeRequest": { "$ref": "#/components/messages/RtSubscribeRequest" }, "UnsubscribeRequest": { "$ref": "#/components/messages/RtUnsubscribeRequest" }, "PingRequest": { "$ref": "#/components/messages/RtPingRequest" }, + "PongResponse": { "$ref": "#/components/messages/RtPongResponse" }, "SubscribedResponse": { "$ref": "#/components/messages/RtSubscribedResponse" }, "ErrorResponse": { "$ref": "#/components/messages/RtErrorResponse" }, "FolderEvent": { "$ref": "#/components/messages/RtFolderEventNotification" }, @@ -149,6 +164,26 @@ fn operations() -> Value { "messages": [ { "$ref": "#/channels/Folder/messages/FolderEvent" } ] + }, + // Application-layer keepalive. Separate from the RFC 6455 Ping + // control frame the server sends on `OXICLOUD_RT_WS_KEEPALIVE_SECONDS` + // (which is transport-level and not modelled in AsyncAPI). This + // operation lets a client actively confirm the socket is + // end-to-end alive when transport-level Pings alone can't rule + // out a proxy black-hole. + "ping": { + "action": "send", + "channel": { "$ref": "#/channels/Folder" }, + "summary": "Application-level keepalive; `rt.pong` reply confirms end-to-end liveness", + "messages": [ + { "$ref": "#/channels/Folder/messages/PingRequest" } + ], + "reply": { + "channel": { "$ref": "#/channels/Folder" }, + "messages": [ + { "$ref": "#/channels/Folder/messages/PongResponse" } + ] + } } }) } @@ -188,6 +223,12 @@ fn components() -> Value { "contentType": "application/json", "payload": { "$ref": "#/components/schemas/RtErrorResponseBody" }, }, + "RtPongResponse": { + "name": "rt.pong", + "title": "Reply to rt.ping — `result.pong == true`", + "contentType": "application/json", + "payload": { "$ref": "#/components/schemas/RtPongResponseBody" }, + }, // ── Notifications (server → client) ───────────────────── "RtFolderEventNotification": { "name": "rt.event", @@ -201,10 +242,22 @@ fn components() -> Value { "RtUnsubscribeRequestBody": rpc_request_schema("rt.unsubscribe", topic_params_schema()), "RtPingRequestBody": rpc_request_schema("rt.ping", json!({ "type": "null" })), "RtSuccessResponseBody": rpc_success_response_schema(), + "RtPongResponseBody": rpc_pong_response_schema(), "RtErrorResponseBody": rpc_error_response_schema(), "RtFolderEventBody": folder_event_notification_schema(), "FileCreatedData": file_created_schema(), "FolderCreatedData": folder_created_schema(), + }, + // How the client authenticates. Handler side is `auth_middleware` + // — the same middleware every `/api/*` request goes through, so + // any JWT valid for REST is valid for WS. + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "OxiCloud JWT — same access_token minted by `POST /api/auth/login` (or the OPAQUE handshake). Programmatic clients set `Authorization: Bearer ` on the WS upgrade request. Browsers, which cannot set headers on `new WebSocket()`, will use the deferred ticket flow (`POST /api/rt/ticket` → short-lived one-shot ticket in the WS URL); see the plan's DPoP-gap section.", + } } }) } @@ -250,6 +303,26 @@ fn rpc_success_response_schema() -> Value { }) } +/// Reply to `rt.ping` — the shape pins `result.pong == true` so +/// contract tests can assert on it directly. +fn rpc_pong_response_schema() -> Value { + json!({ + "type": "object", + "required": ["jsonrpc", "id", "result"], + "properties": { + "jsonrpc": { "type": "string", "const": "2.0" }, + "id": { "type": ["integer", "string", "null"] }, + "result": { + "type": "object", + "required": ["pong"], + "properties": { + "pong": { "type": "boolean", "const": true } + } + }, + } + }) +} + fn rpc_error_response_schema() -> Value { // The `code`/`message` catalog is the stable public vocabulary — // any change here IS a wire break. Every entry mirrors diff --git a/src/bin/rt-hurl-helper.rs b/src/bin/rt-hurl-helper.rs index 62cf43d4..5eac6e96 100644 --- a/src/bin/rt-hurl-helper.rs +++ b/src/bin/rt-hurl-helper.rs @@ -261,6 +261,11 @@ async fn subscribe_and_collect(args: Args) -> Result<(), HelperError> { } let mut events: Vec = Vec::new(); + // Count server-initiated protocol Pings so scenarios can assert the + // keepalive fires. tokio-tungstenite queues an auto-Pong on the next + // write path, so we don't need to send one ourselves; we just observe + // the frame. + let mut pings_received: usize = 0; let mut timed_out = false; let deadline = tokio::time::Instant::now() + args.timeout; @@ -290,9 +295,16 @@ async fn subscribe_and_collect(args: Args) -> Result<(), HelperError> { } }; - let Message::Text(text) = msg else { - // Ignore ping/pong/binary; server may send close later. - continue; + let text = match msg { + Message::Text(t) => t, + Message::Ping(_) => { + // Server-initiated keepalive — observable proof that the + // interval is firing. tokio-tungstenite queues an + // auto-Pong on the next flush; nothing to do here. + pings_received += 1; + continue; + } + _ => continue, // pong/binary/close — not asserted on }; let value: Value = serde_json::from_str(&text) .map_err(|e| HelperError::Protocol(format!("bad frame: {e}: {text}")))?; @@ -333,6 +345,7 @@ async fn subscribe_and_collect(args: Args) -> Result<(), HelperError> { let summary = json!({ "subscribed": subscribed, "events": events, + "pings_received": pings_received, "timed_out": timed_out, }); std::fs::write(path, serde_json::to_vec_pretty(&summary).unwrap()) diff --git a/src/interfaces/api/handlers/rt_ws.rs b/src/interfaces/api/handlers/rt_ws.rs index 839ff66b..510bf88f 100644 --- a/src/interfaces/api/handlers/rt_ws.rs +++ b/src/interfaces/api/handlers/rt_ws.rs @@ -35,7 +35,9 @@ use std::collections::HashMap; use std::sync::Arc; +use std::time::Duration; +use axum::body::Bytes; use axum::extract::State; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::response::Response; @@ -44,6 +46,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use tokio::sync::mpsc; use tokio::task::JoinHandle; +use tokio::time::MissedTickBehavior; use uuid::Uuid; use crate::application::ports::authorization_ports::AuthorizationEngine; @@ -64,6 +67,31 @@ const MAX_SUBSCRIPTIONS_PER_CONNECTION: usize = 128; /// socket layer doesn't back-pressure into the bus's broadcast ring. const OUTBOUND_CHANNEL_CAPACITY: usize = 512; +/// Default server-initiated protocol Ping interval. Keeps intermediate +/// proxies (Traefik, nginx, Cloudflare) and NAT boxes from reaping the +/// TCP session as idle. 30 s sits comfortably under nginx's 60 s +/// default and Cloudflare's 100 s hard limit; behind Traefik we +/// document a much longer `idleTimeout` anyway. +/// +/// Overridable at server start via `OXICLOUD_RT_WS_KEEPALIVE_SECONDS` +/// — test suites drop it to a low value to exercise the keepalive path +/// within a bounded wall-clock. +const DEFAULT_KEEPALIVE_SECONDS: u64 = 30; + +/// Read the keepalive interval from env at connection time. Kept as a +/// function rather than a `LazyLock` so a running server with the env +/// var flipped picks it up on the NEXT connection without a restart — +/// useful for smoke tests that toggle the value on the fly. +fn keepalive_interval() -> Duration { + Duration::from_secs( + std::env::var("OXICLOUD_RT_WS_KEEPALIVE_SECONDS") + .ok() + .and_then(|s| s.parse().ok()) + .filter(|&n: &u64| n > 0) + .unwrap_or(DEFAULT_KEEPALIVE_SECONDS), + ) +} + // ════════════════════════════════════════════════════════════════════════════ // JSON-RPC 2.0 envelope types // ════════════════════════════════════════════════════════════════════════════ @@ -166,6 +194,42 @@ async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc = HashMap::new(); + // Server-initiated protocol Ping ticker — prevents intermediate + // proxies (Traefik, nginx, Cloudflare) and NAT boxes from reaping + // the TCP session as idle. Browsers can't send Ping control frames + // (the JS `WebSocket` API doesn't expose them), so the server owns + // this responsibility; the client's WS layer auto-Pongs. A truly + // dead peer surfaces on the next `socket.send` and breaks out of + // the loop the same way any WS error does — no pong-timeout + // tracking needed for MVP. + // + // ─────────────────────── Scaling note ──────────────────────────── + // This is a `tokio::time::interval` PER connection — not a thread. + // The tokio timer wheel handles arbitrary N intervals in O(1) and + // each Sleep future is ~150 bytes of state. Per-session task + // memory dominates at any interesting N (~1 KB stack), which is + // still trivial: 10 000 clients ≈ 12 MB total + ~333 Pings/sec + // spread across the worker pool. + // + // If a deployment ever hits 100 000+ concurrent WS AND the + // per-connection interval becomes a measurable cost, the swap is: + // 1. one global `tokio::spawn(async { interval.tick().await; ... })` + // task that scans a `DashMap>` + // registry and pings each session's mailbox on tick, + // 2. session tasks receive the mailbox signal in their `select!` + // and send `Message::Ping` from there (still per-session, so + // one slow socket doesn't block the whole fleet). + // Neither pattern change would touch the wire; both are same-file + // refactors. Don't do this until N genuinely warrants it — until + // then, per-connection is the standard tokio idiom for a reason. + let mut keepalive = tokio::time::interval(keepalive_interval()); + // Coalesce backlog if the runtime pauses (e.g. under heavy load) + // rather than firing a burst of Pings when it recovers. + keepalive.set_missed_tick_behavior(MissedTickBehavior::Delay); + // Discard the immediate first tick — the socket just opened; a + // client sending its opening `rt.subscribe` shouldn't race a Ping. + keepalive.tick().await; + loop { tokio::select! { // biased: process outbound before inbound so an event burst @@ -183,6 +247,15 @@ async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc { + // RFC 6455 Ping control frame. 0-byte payload is + // spec-legal and the smallest wire footprint. Client + // auto-Pongs; nothing to observe here on that. + if socket.send(Message::Ping(Bytes::new())).await.is_err() { + break; + } + } + incoming = socket.recv() => { match incoming { Some(Ok(Message::Text(txt))) => { @@ -199,7 +272,10 @@ async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc { - // Handled by axum's WebSocket state machine. + // Client Ping → axum auto-Pongs. Client Pong is + // the response to OUR keepalive Ping — nothing + // to do at the app layer; TCP + WS keep the + // pipe warm regardless. } Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break, } diff --git a/tests/api/rt_bus_check.sh b/tests/api/rt_bus_check.sh index 75a660c6..6a1a1a30 100755 --- a/tests/api/rt_bus_check.sh +++ b/tests/api/rt_bus_check.sh @@ -7,7 +7,7 @@ # This script orchestrates it against a live oxicloud server: bootstraps # state with curl, exercises the bus, asserts on the helper's JSON output. # -# Four scenarios: +# Five scenarios: # S1 Positive delivery — subscribe to folder A, upload into A, see event. # S2 Topic isolation — subscribe to folder A only, upload into B and # then A; must see A's event only. @@ -16,6 +16,10 @@ # S4 Anti-enumeration — subscribe to a folder that does not exist; # must return the SAME wire reason (`no_read`) # as S3, per the plan's anti-enum invariant. +# S5 Server keepalive — 3 s of idle surfaces multiple RFC 6455 Ping +# frames from the server (proves the interval +# fires), and the session still delivers an +# event on the same subscription afterwards. # # Exit non-zero on any failure — run.sh treats that as a suite failure. # ───────────────────────────────────────────────────────────────────────────── @@ -216,4 +220,47 @@ if ! "$HELPER_BIN" expect-denied \ fi log "S4 OK" -log "All four realtime-bus scenarios passed." +# ── Scenario 5 — Server-initiated keepalive ───────────────────────────────── +# Verifies the WS handler sends RFC 6455 Ping control frames on the +# `OXICLOUD_RT_WS_KEEPALIVE_SECONDS` cadence (1 s in tests/common/server.env). +# Two invariants: +# (a) idling on a live subscription surfaces multiple Ping frames — the +# keepalive interval genuinely fires, not just at connect and never again. +# (b) after 3 s of app-layer idle + keepalive traffic, the session is +# still healthy: an upload's event still delivers cleanly. +# If the keepalive impl were broken (missed-tick burst, dead select! arm, +# stalled write on the socket), either (a) trips (0-1 pings observed) or +# (b) trips (event never arrives after idle). +log "S5: server-initiated keepalive fires on idle; session still delivers." +out_s5="$(mktemp -t rtbus_s5.XXXXXX)" +"$HELPER_BIN" subscribe-and-collect \ + --url "$ws_url" \ + --token "$user1_token" \ + --subscribe "folder:$folder_a" \ + --expect-events 1 \ + --timeout 6s \ + --output "$out_s5" & +helper_pid=$! +# 3 s of pure idle — with 1 s keepalive on the server, that's ~3 Pings. +sleep 3 +mkfile_in "$folder_a" "s5.txt" "$user1_token" +if ! wait "$helper_pid"; then + cat "$out_s5" >&2 || true + die "S5: helper did not observe the expected event after idle" +fi +# (a) At least 2 Pings during the 3 s idle. Tolerant floor: with 1 s +# interval and a first-tick discard, 2 is the minimum credible observation +# before flakiness (missed tick, timer coalesce) becomes a concern. +pings=$(jq -r '.pings_received' "$out_s5") +if [[ "$pings" -lt 2 ]]; then + cat "$out_s5" >&2 || true + die "S5: expected >=2 keepalive pings during 3 s idle, got $pings" +fi +# (b) Exactly one event on the folder A subscription, from the post-idle upload. +[[ "$(jq -r '.events | length' "$out_s5")" == "1" ]] \ + || { cat "$out_s5"; die "S5: expected 1 event after idle, got $(jq -r '.events | length' "$out_s5")"; } +[[ "$(jq -r '.events[0].data.parent_id' "$out_s5")" == "$folder_a" ]] \ + || die "S5: parent_id mismatch after idle" +log "S5 OK ($pings pings observed)" + +log "All five realtime-bus scenarios passed." diff --git a/tests/common/server.env b/tests/common/server.env index 77c6760d..3adc50ac 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -35,6 +35,13 @@ OXICLOUD_OIDC_ENABLED=false OXICLOUD_NEXTCLOUD_ENABLED=true +# Realtime WS keepalive interval (server-initiated protocol Ping). +# Production default is 30 s; tests drop it to 1 s so the S5 scenario +# in `tests/api/rt_bus_check.sh` can observe multiple keepalive frames +# arriving within a bounded (few-seconds) wall-clock. Only observed +# by `rt_ws_handler`, which reads it at each WS connect time. +OXICLOUD_RT_WS_KEEPALIVE_SECONDS=1 + # Multi-entry storage config — see docs/plan/storage-multi-entry.md. # `local_main` is FIRST so the boot fallback picks it when no active # pointer is set in the DB yet (fresh test DB). Its root_dir falls From d850e9c1003eb5193789677812a1df76aca8fca1 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 10 Sep 2026 07:15:09 +0200 Subject: [PATCH 05/21] feat(msg-bus): add file and folder mutation notoficaton + tests --- docs/plan/message-bus.md | 25 +++ src/application/ports/realtime_ports.rs | 165 +++++++++++++++--- .../services/file_management_service.rs | 163 ++++++++++++++++- src/application/services/folder_service.rs | 83 +++++++++ src/bin/generate-asyncapi.rs | 101 ++++++++++- src/common/di.rs | 7 +- tests/api/rt_bus_check.sh | 112 +++++++++++- 7 files changed, 626 insertions(+), 30 deletions(-) diff --git a/docs/plan/message-bus.md b/docs/plan/message-bus.md index 56df8f13..bd730303 100644 --- a/docs/plan/message-bus.md +++ b/docs/plan/message-bus.md @@ -1049,6 +1049,31 @@ workspace. - **Reactions**: 👍❤️🎉 on comments and on files themselves; live fan-out on the same `file:{id}:comments` topic. - **Comment resolutions**: Google-Docs-style thread markers. +- **NotificationService consumes bus events** — up to Phase A the + bus's publish calls sit inline in each mutation site + (`FolderService::create_folder_with_perms`, + `FileUploadService::upload_file_streaming`, and — once folder-live + rounds out — the delete / rename / move sites for both files and + folders). That is the right shape and stays: the bus is + location-keyed (`Topic::Folder(id)`, subscriber-scoped) and + belongs at the mutation site. + When Phase B ships, notifications sit on the **same axis** (also + location + actor + subscriber-driven) — not the FileLifecycleHook + axis (which is server-internal, content-keyed, fan-out-to-all). + So `NotificationService` becomes an in-process subscriber to the + bus itself: it registers a `bus.subscribe(...)` on the topics it + cares about (`folder:{id}`, `file:{id}`, share-grant events), + translates relevant events into `notif.notifications` rows, and + re-publishes on `user:{u}:notifications`. No new dispatcher, no + new hook trait, no changes to existing mutation sites — the bus IS + the mutation-event pipeline for anything subscriber-driven. + Contrast with `FileLifecycleHook` (`src/application/ports/file_lifecycle.rs`): + that stays focused on content transitions (blob_hash, content_type) + and fires unconditionally to server-side workers (thumbnails, + audio metadata, plugins). Bus and lifecycle-hook are complementary + — same triggering moment, orthogonal fan-out shape and payload + discipline. Do NOT try to unify them; the two axes are genuinely + different (all-vs-subscribed × content-vs-location). Deliverables sized ~3 weeks after Phase A. diff --git a/src/application/ports/realtime_ports.rs b/src/application/ports/realtime_ports.rs index a3bd1165..85a10019 100644 --- a/src/application/ports/realtime_ports.rs +++ b/src/application/ports/realtime_ports.rs @@ -17,7 +17,10 @@ //! to folders the caller can't `Read`: //! //! - Topics: [`Topic::Folder`] and [`Topic::UserAuthz`] -//! - Events: [`RealtimeEvent::FileCreated`], [`RealtimeEvent::FolderCreated`] +//! - Events: [`RealtimeEvent::FileCreated`], [`RealtimeEvent::FileRenamed`], +//! [`RealtimeEvent::FileMoved`], [`RealtimeEvent::FileDeleted`], +//! [`RealtimeEvent::FolderCreated`], [`RealtimeEvent::FolderRenamed`], +//! [`RealtimeEvent::FolderMoved`], [`RealtimeEvent::FolderDeleted`] //! //! Adding a variant is a one-line change plus a match arm in `to_wire_key` / //! `parse` / `required_perm`. Other topics (`file:{id}`, `job:{id}`, @@ -181,6 +184,36 @@ pub enum RealtimeEvent { parent_id: Uuid, actor: Uuid, }, + /// A file was renamed. `parent_id` unchanged — same folder. + FileRenamed { + file_id: Uuid, + old_name: String, + new_name: String, + parent_id: Uuid, + actor: Uuid, + }, + /// A file was moved between folders. Fanned out on BOTH the source + /// and destination folder topics — subscribers to either see the + /// event once. `from` / `to` are the folder UUIDs; a move + /// involving a drive root would be `Option` in a future + /// variant, but MVP mutations all address a real folder. + FileMoved { + file_id: Uuid, + name: String, + from: Uuid, + to: Uuid, + actor: Uuid, + }, + /// A file was deleted (trashed OR permanently removed — the wire + /// doesn't distinguish, and clients treat both as "disappears from + /// the folder view"). `parent_id` is the folder the file used to + /// live in — snapshotted before the delete since the row may be + /// gone by publish time. + FileDeleted { + file_id: Uuid, + parent_id: Uuid, + actor: Uuid, + }, /// A sub-folder was created inside `parent_id`. FolderCreated { folder_id: Uuid, @@ -188,6 +221,30 @@ pub enum RealtimeEvent { parent_id: Uuid, actor: Uuid, }, + /// A folder was renamed. `parent_id` unchanged. + FolderRenamed { + folder_id: Uuid, + old_name: String, + new_name: String, + parent_id: Uuid, + actor: Uuid, + }, + /// A folder was moved between parents. Fanned out on BOTH source + /// and destination folder topics. + FolderMoved { + folder_id: Uuid, + name: String, + from: Uuid, + to: Uuid, + actor: Uuid, + }, + /// A folder was deleted (trashed or permanent — see `FileDeleted` + /// for the same wire-collapse rationale). + FolderDeleted { + folder_id: Uuid, + parent_id: Uuid, + actor: Uuid, + }, } // ════════════════════════════════════════════════════════════════════════════ @@ -394,26 +451,92 @@ mod tests { fn event_serializes_with_snake_case_discriminator() { // The `#[serde(tag = "event")]` shape is the WS wire contract for // the `rt.event` JSON-RPC notification's `params.event` field. Pin - // it with a snapshot so accidental rename of the enum variant - // fails the test instead of silently breaking clients. - let ev = RealtimeEvent::FileCreated { - file_id: Uuid::nil(), - name: "notes.md".into(), - parent_id: Uuid::nil(), - actor: Uuid::nil(), - }; - let json = serde_json::to_value(&ev).unwrap(); - assert_eq!(json["event"], "file_created"); - assert_eq!(json["name"], "notes.md"); - - let ev = RealtimeEvent::FolderCreated { - folder_id: Uuid::nil(), - name: "docs".into(), - parent_id: Uuid::nil(), - actor: Uuid::nil(), - }; - let json = serde_json::to_value(&ev).unwrap(); - assert_eq!(json["event"], "folder_created"); + // every variant's discriminator with a snapshot so an accidental + // rename fails the test instead of silently breaking clients — + // the AsyncAPI spec's `event` enum mirrors these exact strings. + let cases: &[(RealtimeEvent, &str)] = &[ + ( + RealtimeEvent::FileCreated { + file_id: Uuid::nil(), + name: "notes.md".into(), + parent_id: Uuid::nil(), + actor: Uuid::nil(), + }, + "file_created", + ), + ( + RealtimeEvent::FileRenamed { + file_id: Uuid::nil(), + old_name: "a.md".into(), + new_name: "b.md".into(), + parent_id: Uuid::nil(), + actor: Uuid::nil(), + }, + "file_renamed", + ), + ( + RealtimeEvent::FileMoved { + file_id: Uuid::nil(), + name: "a.md".into(), + from: Uuid::nil(), + to: Uuid::nil(), + actor: Uuid::nil(), + }, + "file_moved", + ), + ( + RealtimeEvent::FileDeleted { + file_id: Uuid::nil(), + parent_id: Uuid::nil(), + actor: Uuid::nil(), + }, + "file_deleted", + ), + ( + RealtimeEvent::FolderCreated { + folder_id: Uuid::nil(), + name: "docs".into(), + parent_id: Uuid::nil(), + actor: Uuid::nil(), + }, + "folder_created", + ), + ( + RealtimeEvent::FolderRenamed { + folder_id: Uuid::nil(), + old_name: "old".into(), + new_name: "new".into(), + parent_id: Uuid::nil(), + actor: Uuid::nil(), + }, + "folder_renamed", + ), + ( + RealtimeEvent::FolderMoved { + folder_id: Uuid::nil(), + name: "docs".into(), + from: Uuid::nil(), + to: Uuid::nil(), + actor: Uuid::nil(), + }, + "folder_moved", + ), + ( + RealtimeEvent::FolderDeleted { + folder_id: Uuid::nil(), + parent_id: Uuid::nil(), + actor: Uuid::nil(), + }, + "folder_deleted", + ), + ]; + for (ev, expected) in cases { + let json = serde_json::to_value(ev).unwrap(); + assert_eq!( + json["event"], *expected, + "wire discriminator mismatch for {ev:?}" + ); + } } #[test] diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 95352b55..cdad81bf 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -5,7 +5,7 @@ use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::file_lifecycle::FileLifecycleHook; use crate::application::ports::file_ports::FileManagementUseCase; use crate::application::ports::resource_access_hook::ResourceAccessHook; -use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileWritePort}; +use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileReadPort, FileWritePort}; use crate::application::ports::trash_ports::TrashUseCase; use crate::application::services::external_mount_router::{MountRouter, ResolvedId}; use crate::application::services::mount_dto::{audit_mount_write, mount_file_dto, mount_parent_id}; @@ -57,6 +57,20 @@ pub struct FileManagementService { /// (stub/test builders); production DI wires it in. storage_usage: Option>, + /// Realtime message bus. When wired, delete / rename / move + /// mutations publish their corresponding `RealtimeEvent` on + /// `Topic::Folder(parent_id)` (both source AND destination for + /// move) after the DB commit. `None` silently no-ops the publish + /// path — same pattern as `bus` on FileUploadService. + bus: Option>, + /// Read repository — needed by the mutation publish path + /// (delete / rename / move) to snapshot the file's pre-mutation + /// parent folder BEFORE the write commits: delete removes the row, + /// move rewrites `folder_id`. Without it we couldn't publish on + /// the correct `Topic::Folder(parent)` (delete) or fan out on the + /// source-side folder (move). Optional so stubs stay minimal; when + /// unwired, the affected publishes silently no-op. + file_read: Option>, } impl FileManagementService { @@ -68,7 +82,7 @@ impl FileManagementService { pub fn with_trash( file_repository: Arc, trash_service: Option>, - _file_read: Option>, + file_read: Option>, _folder_repo: Option>, content_cache: Option>, authz: Arc, @@ -83,9 +97,21 @@ impl FileManagementService { resource_access_hook: None, drive_repo: None, storage_usage: None, + bus: None, + file_read, } } + /// Wire the realtime message bus. When set, delete / rename / move + /// mutations publish on the affected folder topics after commit. + pub fn with_realtime_bus( + mut self, + bus: Arc, + ) -> Self { + self.bus = Some(bus); + self + } + /// Sets the lifecycle hook dispatcher (thumbnails, audio metadata, …). pub fn with_file_lifecycle_hook(mut self, hook: Arc) -> Self { self.file_lifecycle_hook = Some(hook); @@ -181,6 +207,50 @@ impl FileManagementService { self } + /// Snapshot the (uuid, name, parent-folder-uuid) of a file BEFORE + /// a mutation, so the realtime publish path has a stable + /// `Topic::Folder(parent)` to address even after the write commits + /// (delete removes the row; move rewrites `folder_id`). + /// + /// Returns `None` when: + /// - `file_read` is unwired (stub / test builder), + /// - the file can't be read (already gone, permission failure — + /// the caller is responsible for AuthZ, this is only a + /// best-effort snapshot), + /// - the file is at drive-root (no parent folder, nothing to + /// publish on), + /// - the id can't be parsed as a `Uuid` (mount id or malformed). + /// + /// All `None` paths silently skip the publish — never fail the + /// mutation. The bus is best-effort. + async fn snapshot_for_publish(&self, file_id: &str) -> Option<(Uuid, String, Uuid)> { + let file_read = self.file_read.as_ref()?; + let file = file_read.get_file(file_id).await.ok()?; + let parts = file.into_parts(); + let file_uuid = Uuid::parse_str(&parts.id).ok()?; + let parent_uuid = Uuid::parse_str(parts.folder_id.as_deref()?).ok()?; + Some((file_uuid, parts.name, parent_uuid)) + } + + /// Publish `FileDeleted` on the file's parent folder topic. Called + /// by both the trash and permanent-delete paths so subscribers see + /// one event regardless of which happened. Silent no-op when the + /// bus isn't wired or the pre-mutation snapshot failed (drive-root + /// file, mount, unwired `file_read`). + fn publish_file_deleted(&self, caller_id: Uuid, snapshot: Option<(Uuid, String, Uuid)>) { + if let (Some(bus), Some((file_uuid, _name, parent_uuid))) = (&self.bus, snapshot) { + use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; + bus.publish( + &Topic::Folder(parent_uuid), + RealtimeEvent::FileDeleted { + file_id: file_uuid, + parent_id: parent_uuid, + actor: caller_id, + }, + ); + } + } + /// Engine check for a file resource. Parses the id into a `Uuid` and /// requires the specified permission. async fn require_file_perm( @@ -462,8 +532,41 @@ impl FileManagementUseCase for FileManagementService { } } + // Snapshot source parent BEFORE the write — after `move_file` + // the row's `folder_id` reflects the destination, so we'd lose + // the from-side for the fan-out. + let source_snapshot = self.snapshot_for_publish(file_id).await; + let dto = self.move_file(file_id, folder_id, caller_id).await?; + // Realtime fan-out on BOTH source and destination folder + // topics. Subscribers to the source see the file "gone" from + // their view; subscribers to the destination see it "appear". + // Silent no-op when the bus isn't wired, the source snapshot + // failed (drive-root file, mount), or the destination is + // drive-root (`dto.folder_id = None`). Any of those cases + // matches the "no interested subscribers" invariant so + // silently skipping is honest. + if let (Some(bus), Some((file_uuid, name, source_uuid)), Some(dest_str)) = + (&self.bus, source_snapshot, dto.folder_id.as_deref()) + && let Ok(dest_uuid) = Uuid::parse_str(dest_str) + && source_uuid != dest_uuid + { + use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; + let event = RealtimeEvent::FileMoved { + file_id: file_uuid, + name, + from: source_uuid, + to: dest_uuid, + actor: caller_id, + }; + // Publish twice — subscribers to either folder see the + // event exactly once because they're only subscribed to + // one of the two topics. + bus.publish(&Topic::Folder(source_uuid), event.clone()); + bus.publish(&Topic::Folder(dest_uuid), event); + } + // Cross-drive move invalidates the file's `owner_cache` entry // in the authz engine — the cache assumed drive_id stability // that no longer holds. Without this call the drive-role @@ -559,7 +662,39 @@ impl FileManagementUseCase for FileManagementService { } self.require_file_perm(file_id, Permission::Update, caller_id) .await?; - self.rename_file(file_id, new_name, caller_id).await + + // Snapshot old_name pre-rename so the publish carries both + // sides of the transition. `parent_id` is the same before and + // after (rename doesn't move) so we can safely reuse it from + // the post-mutation DTO. + let old_name = self + .snapshot_for_publish(file_id) + .await + .map(|(_, name, _)| name); + + let dto = self.rename_file(file_id, new_name, caller_id).await?; + + // Realtime publish AFTER commit. Silent no-op when the bus + // isn't wired, the pre-fetch failed (old_name = None), or the + // file has no folder (`dto.folder_id = None` — drive-root). + if let (Some(bus), Some(old_name), Some(parent_str)) = + (&self.bus, old_name, dto.folder_id.as_deref()) + && let (Ok(file_uuid), Ok(parent_uuid)) = + (Uuid::parse_str(&dto.id), Uuid::parse_str(parent_str)) + { + use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; + bus.publish( + &Topic::Folder(parent_uuid), + RealtimeEvent::FileRenamed { + file_id: file_uuid, + old_name, + new_name: dto.name.clone(), + parent_id: parent_uuid, + actor: caller_id, + }, + ); + } + Ok(dto) } async fn delete_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> { @@ -572,7 +707,15 @@ impl FileManagementUseCase for FileManagementService { } self.require_file_perm(id, Permission::Delete, caller_id) .await?; - self.delete_file(id).await + + // Snapshot the pre-delete parent so the publish path has a + // `Topic::Folder(parent)` to address — the row is gone by the + // time `delete_file` returns. + let snapshot = self.snapshot_for_publish(id).await; + + self.delete_file(id).await?; + self.publish_file_deleted(caller_id, snapshot); + Ok(()) } /// Smart delete: trash-first with dedup reference cleanup. @@ -597,6 +740,15 @@ impl FileManagementUseCase for FileManagementService { self.require_file_perm(id, Permission::Delete, caller_id) .await?; + + // Snapshot the pre-mutation parent so both the trash and the + // fallback permanent-delete path can publish `FileDeleted` on + // the right folder topic. Trash leaves the row in place but + // `is_trashed=TRUE` makes it disappear from folder listings — + // subscribers should see the same "gone from this folder" + // event either way. + let snapshot = self.snapshot_for_publish(id).await; + // Step 1: Try trash (soft delete — file row stays, blob stays referenced) if let Some(trash) = &self.trash_service { info!("Moving file to trash: {}", id); @@ -610,6 +762,7 @@ impl FileManagementUseCase for FileManagementService { // Do NOT decrement blob ref here — the file row still exists // (is_trashed = TRUE). The trigger will decrement when the // row is actually DELETEd during trash emptying. + self.publish_file_deleted(caller_id, snapshot); return Ok(true); // trashed } Err(err) => { @@ -625,7 +778,7 @@ impl FileManagementUseCase for FileManagementService { // Step 2: Permanent delete — trigger handles blob ref_count self.delete_file(id).await?; - + self.publish_file_deleted(caller_id, snapshot); Ok(false) // permanently deleted } diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 34917be4..e52723e8 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -813,6 +813,28 @@ impl FolderUseCase for FolderService { drive_repo.invalidate_default_drive_all(); } + // Realtime publish AFTER commit. Root folders (`parent_id() = None`) + // have no parent folder topic to publish on — the drive's + // display-name change is handled by the readable/default-drive + // cache invalidations above, not the bus. Silent no-op if the + // bus isn't wired. + if let (Some(bus), Some(parent_str)) = (&self.bus, folder.parent_id()) + && let (Ok(folder_uuid), Ok(parent_uuid)) = + (Uuid::parse_str(renamed.id()), Uuid::parse_str(parent_str)) + { + use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; + bus.publish( + &Topic::Folder(parent_uuid), + RealtimeEvent::FolderRenamed { + folder_id: folder_uuid, + old_name: folder.name().to_owned(), + new_name: renamed.name().to_owned(), + parent_id: parent_uuid, + actor: caller_id, + }, + ); + } + Ok(FolderDto::from(renamed)) } @@ -938,6 +960,18 @@ impl FolderUseCase for FolderService { } } + // Snapshot source parent BEFORE the move — the post-move + // `folder.parent_id()` is the destination. Best-effort: if the + // lookup fails or the folder has no parent (root — can't be + // moved anyway per drive_semantics), the publish path below + // silently skips. + let source_parent_uuid = self + .folder_storage + .get_folder(id) + .await + .ok() + .and_then(|f| f.parent_id().and_then(|p| Uuid::parse_str(p).ok())); + let parent_ref = dto.parent_id.as_deref(); let folder = self .folder_storage @@ -950,6 +984,29 @@ impl FolderUseCase for FolderService { ) })?; + // Realtime fan-out on BOTH source and destination folder + // topics. Same shape as `FileMoved` — subscribers to either + // see the event exactly once. Silent no-op when the bus isn't + // wired, the source snapshot failed, or the destination is + // drive-root (`folder.parent_id() = None`). + if let (Some(bus), Some(source_uuid), Some(dest_str)) = + (&self.bus, source_parent_uuid, folder.parent_id()) + && let (Ok(folder_uuid), Ok(dest_uuid)) = + (Uuid::parse_str(folder.id()), Uuid::parse_str(dest_str)) + && source_uuid != dest_uuid + { + use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; + let event = RealtimeEvent::FolderMoved { + folder_id: folder_uuid, + name: folder.name().to_owned(), + from: source_uuid, + to: dest_uuid, + actor: caller_id, + }; + bus.publish(&Topic::Folder(source_uuid), event.clone()); + bus.publish(&Topic::Folder(dest_uuid), event); + } + // Cross-drive move flushes the authz engine's `owner_cache` // — every descendant's cached `Resource → drive_id` mapping // just got stale via the cascade trigger, and we don't (yet) @@ -1028,6 +1085,16 @@ impl FolderUseCase for FolderService { .await .unwrap_or_default(); + // Pre-delete snapshot for the bus publish — post-DELETE the + // row is gone and we can't recover `parent_id`. Best-effort; + // failures fall through to a silent skip below. + let publish_snapshot: Option<(Uuid, Uuid)> = + self.folder_storage.get_folder(id).await.ok().and_then(|f| { + let folder_uuid = Uuid::parse_str(f.id()).ok()?; + let parent_uuid = Uuid::parse_str(f.parent_id()?).ok()?; + Some((folder_uuid, parent_uuid)) + }); + self.folder_storage.delete_folder(id).await.map_err(|e| { DomainError::internal_error( "FolderStorage", @@ -1039,6 +1106,22 @@ impl FolderUseCase for FolderService { self.file_lifecycle.on_file_deleted(file_id); } + // Realtime publish AFTER the DELETE commits. Root folders + // (no parent) can't be deleted through this endpoint per the + // mount / drive-root guards above, so `publish_snapshot` is + // effectively always Some for regular deletes. + if let (Some(bus), Some((folder_uuid, parent_uuid))) = (&self.bus, publish_snapshot) { + use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; + bus.publish( + &Topic::Folder(parent_uuid), + RealtimeEvent::FolderDeleted { + folder_id: folder_uuid, + parent_id: parent_uuid, + actor: caller_id, + }, + ); + } + Ok(()) } } diff --git a/src/bin/generate-asyncapi.rs b/src/bin/generate-asyncapi.rs index 93e93bbd..8da99838 100644 --- a/src/bin/generate-asyncapi.rs +++ b/src/bin/generate-asyncapi.rs @@ -246,7 +246,13 @@ fn components() -> Value { "RtErrorResponseBody": rpc_error_response_schema(), "RtFolderEventBody": folder_event_notification_schema(), "FileCreatedData": file_created_schema(), + "FileRenamedData": file_renamed_schema(), + "FileMovedData": file_moved_schema(), + "FileDeletedData": file_deleted_schema(), "FolderCreatedData": folder_created_schema(), + "FolderRenamedData": folder_renamed_schema(), + "FolderMovedData": folder_moved_schema(), + "FolderDeletedData": folder_deleted_schema(), }, // How the client authenticates. Handler side is `auth_middleware` // — the same middleware every `/api/*` request goes through, so @@ -388,12 +394,21 @@ fn folder_event_notification_schema() -> Value { "topic": { "type": "string" }, "event": { "type": "string", - "enum": ["file_created", "folder_created"], + "enum": [ + "file_created", "file_renamed", "file_moved", "file_deleted", + "folder_created", "folder_renamed", "folder_moved", "folder_deleted", + ], }, "data": { "oneOf": [ { "$ref": "#/components/schemas/FileCreatedData" }, + { "$ref": "#/components/schemas/FileRenamedData" }, + { "$ref": "#/components/schemas/FileMovedData" }, + { "$ref": "#/components/schemas/FileDeletedData" }, { "$ref": "#/components/schemas/FolderCreatedData" }, + { "$ref": "#/components/schemas/FolderRenamedData" }, + { "$ref": "#/components/schemas/FolderMovedData" }, + { "$ref": "#/components/schemas/FolderDeletedData" }, ] } } @@ -415,6 +430,48 @@ fn file_created_schema() -> Value { }) } +fn file_renamed_schema() -> Value { + json!({ + "type": "object", + "required": ["file_id", "old_name", "new_name", "parent_id", "actor"], + "properties": { + "file_id": { "type": "string", "format": "uuid" }, + "old_name": { "type": "string" }, + "new_name": { "type": "string" }, + "parent_id": { "type": "string", "format": "uuid" }, + "actor": { "type": "string", "format": "uuid" }, + } + }) +} + +fn file_moved_schema() -> Value { + json!({ + "type": "object", + "description": "Emitted on BOTH the source (`from`) and destination (`to`) folder topics. Subscribers to either see the event exactly once because they're subscribed to only one of the two.", + "required": ["file_id", "name", "from", "to", "actor"], + "properties": { + "file_id": { "type": "string", "format": "uuid" }, + "name": { "type": "string" }, + "from": { "type": "string", "format": "uuid" }, + "to": { "type": "string", "format": "uuid" }, + "actor": { "type": "string", "format": "uuid" }, + } + }) +} + +fn file_deleted_schema() -> Value { + json!({ + "type": "object", + "description": "The wire doesn't distinguish soft (trash) vs. permanent delete — clients treat both as \"disappears from the folder view\". `parent_id` is the folder the file used to live in.", + "required": ["file_id", "parent_id", "actor"], + "properties": { + "file_id": { "type": "string", "format": "uuid" }, + "parent_id": { "type": "string", "format": "uuid" }, + "actor": { "type": "string", "format": "uuid" }, + } + }) +} + fn folder_created_schema() -> Value { json!({ "type": "object", @@ -427,3 +484,45 @@ fn folder_created_schema() -> Value { } }) } + +fn folder_renamed_schema() -> Value { + json!({ + "type": "object", + "required": ["folder_id", "old_name", "new_name", "parent_id", "actor"], + "properties": { + "folder_id": { "type": "string", "format": "uuid" }, + "old_name": { "type": "string" }, + "new_name": { "type": "string" }, + "parent_id": { "type": "string", "format": "uuid" }, + "actor": { "type": "string", "format": "uuid" }, + } + }) +} + +fn folder_moved_schema() -> Value { + json!({ + "type": "object", + "description": "Emitted on BOTH the source (`from`) and destination (`to`) folder topics — same shape as `FileMoved`.", + "required": ["folder_id", "name", "from", "to", "actor"], + "properties": { + "folder_id": { "type": "string", "format": "uuid" }, + "name": { "type": "string" }, + "from": { "type": "string", "format": "uuid" }, + "to": { "type": "string", "format": "uuid" }, + "actor": { "type": "string", "format": "uuid" }, + } + }) +} + +fn folder_deleted_schema() -> Value { + json!({ + "type": "object", + "description": "Soft vs. permanent delete are indistinguishable on the wire.", + "required": ["folder_id", "parent_id", "actor"], + "properties": { + "folder_id": { "type": "string", "format": "uuid" }, + "parent_id": { "type": "string", "format": "uuid" }, + "actor": { "type": "string", "format": "uuid" }, + } + }) +} diff --git a/src/common/di.rs b/src/common/di.rs index b6768800..7377bdaa 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -825,7 +825,12 @@ impl AppServiceFactory { .with_drive_repo(drive_repo.clone()) // Destination-drive quota pre-check on cross-drive file // MOVE. Same rationale as the folder side above. - .with_storage_usage(storage_usage.clone()); + .with_storage_usage(storage_usage.clone()) + // Realtime fan-out on delete / rename / move — each hook + // publishes on the affected folder topic (move fans out on + // BOTH source and destination) so folder-view subscribers + // see the mutation live. + .with_realtime_bus(bus_trait.clone()); if let Some(hook) = resource_access_hook.clone() { svc = svc.with_resource_access_hook(hook); } diff --git a/tests/api/rt_bus_check.sh b/tests/api/rt_bus_check.sh index 6a1a1a30..153f2684 100755 --- a/tests/api/rt_bus_check.sh +++ b/tests/api/rt_bus_check.sh @@ -7,7 +7,7 @@ # This script orchestrates it against a live oxicloud server: bootstraps # state with curl, exercises the bus, asserts on the helper's JSON output. # -# Five scenarios: +# Seven scenarios: # S1 Positive delivery — subscribe to folder A, upload into A, see event. # S2 Topic isolation — subscribe to folder A only, upload into B and # then A; must see A's event only. @@ -20,6 +20,14 @@ # frames from the server (proves the interval # fires), and the session still delivers an # event on the same subscription afterwards. +# S6 Delete emits — DELETE a pre-uploaded file → subscriber sees +# one `file_deleted` event with correct +# `file_id` + `parent_id` (snapshotted +# pre-delete since the row is gone by then). +# S7 Move fan-out — MOVE A→B while subscribed to BOTH topics on +# one session → observe TWO `file_moved` +# events (one via the A topic, one via B). +# Same file_id/from/to on both. # # Exit non-zero on any failure — run.sh treats that as a suite failure. # ───────────────────────────────────────────────────────────────────────────── @@ -263,4 +271,104 @@ fi || die "S5: parent_id mismatch after idle" log "S5 OK ($pings pings observed)" -log "All five realtime-bus scenarios passed." +# ── Scenario 6 — File delete emits `file_deleted` ─────────────────────────── +# Pre-create a file in folder A, then subscribe to `folder:$folder_a`, then +# DELETE the file. The subscription must observe exactly one +# `file_deleted` event — proves the delete publish hook fires and carries +# the correct `parent_id` (snapshotted pre-delete, since the row is gone +# by publish time). +log "S6: DELETE a file → subscriber observes file_deleted." +# Pre-create the file BEFORE the subscriber goes up, so S6 asserts on the +# delete event alone (S1 already covered the create-side). +s6_upload=$(curl -sS -X POST \ + -H "Authorization: Bearer $user1_token" \ + -F "folder_id=$folder_a" \ + -F "file=@$(mktemp -t rtbus_s6_body.XXXXXX);filename=s6.txt" \ + "$base_url/api/files/upload") +s6_file_id=$(printf '%s' "$s6_upload" | jq -r '.id') +[[ -n "$s6_file_id" && "$s6_file_id" != "null" ]] \ + || die "S6: pre-upload failed: $s6_upload" + +out_s6="$(mktemp -t rtbus_s6.XXXXXX)" +"$HELPER_BIN" subscribe-and-collect \ + --url "$ws_url" \ + --token "$user1_token" \ + --subscribe "folder:$folder_a" \ + --expect-events 1 \ + --timeout 5s \ + --output "$out_s6" & +helper_pid=$! +sleep 0.4 +# `DELETE /api/files/{id}` routes to `delete_and_cleanup_with_perms` — +# the trash-first path. Publish fires on BOTH the trash and the +# permanent-delete branch, so this covers whichever the test hits. +curl -sS -X DELETE \ + -H "Authorization: Bearer $user1_token" \ + "$base_url/api/files/$s6_file_id" > /dev/null +if ! wait "$helper_pid"; then + cat "$out_s6" >&2 || true + die "S6: helper did not observe the expected file_deleted event" +fi +[[ "$(jq -r '.events | length' "$out_s6")" == "1" ]] \ + || { cat "$out_s6"; die "S6: expected 1 event, got $(jq -r '.events | length' "$out_s6")"; } +[[ "$(jq -r '.events[0].event' "$out_s6")" == "file_deleted" ]] \ + || die "S6: wrong event: $(jq -r '.events[0].event' "$out_s6")" +[[ "$(jq -r '.events[0].data.file_id' "$out_s6")" == "$s6_file_id" ]] \ + || die "S6: file_id mismatch" +[[ "$(jq -r '.events[0].data.parent_id' "$out_s6")" == "$folder_a" ]] \ + || die "S6: parent_id mismatch" +log "S6 OK" + +# ── Scenario 7 — Move fans out on BOTH source and destination ─────────────── +# Pre-create a file in folder A, subscribe to BOTH `folder:$folder_a` and +# `folder:$folder_b` on ONE session, then MOVE the file A → B. The single +# session must observe TWO `file_moved` events — one delivered on the A +# topic, one on the B topic. Same file_id in both. Same event contents +# (from=A, to=B). Proves the plan's "fan out on both source AND +# destination" invariant. +# Broken publish (source-only or dest-only) would surface as 1 event. +# Broken publish-after-commit would surface as 0 events. +log "S7: MOVE fans out on both source AND destination folder topics." +s7_upload=$(curl -sS -X POST \ + -H "Authorization: Bearer $user1_token" \ + -F "folder_id=$folder_a" \ + -F "file=@$(mktemp -t rtbus_s7_body.XXXXXX);filename=s7.txt" \ + "$base_url/api/files/upload") +s7_file_id=$(printf '%s' "$s7_upload" | jq -r '.id') +[[ -n "$s7_file_id" && "$s7_file_id" != "null" ]] \ + || die "S7: pre-upload failed: $s7_upload" + +out_s7="$(mktemp -t rtbus_s7.XXXXXX)" +"$HELPER_BIN" subscribe-and-collect \ + --url "$ws_url" \ + --token "$user1_token" \ + --subscribe "folder:$folder_a" \ + --subscribe "folder:$folder_b" \ + --expect-events 2 \ + --timeout 5s \ + --output "$out_s7" & +helper_pid=$! +sleep 0.4 +# `PUT /api/files/{id}/move` — MoveFilePayload = { folder_id: }. +curl -sS -X PUT \ + -H "Authorization: Bearer $user1_token" \ + -H "Content-Type: application/json" \ + -d "$(printf '{"folder_id":"%s"}' "$folder_b")" \ + "$base_url/api/files/$s7_file_id/move" > /dev/null +if ! wait "$helper_pid"; then + cat "$out_s7" >&2 || true + die "S7: helper did not observe 2 file_moved events" +fi +# Both events same shape, same file_id, from = A, to = B. +[[ "$(jq -r '.events | length' "$out_s7")" == "2" ]] \ + || { cat "$out_s7"; die "S7: expected 2 events (fan-out on A + B), got $(jq -r '.events | length' "$out_s7")"; } +# Every event has event=file_moved, correct file_id/from/to. +if ! jq -e --arg fid "$s7_file_id" --arg from "$folder_a" --arg to "$folder_b" \ + '.events | all(.event == "file_moved" and .data.file_id == $fid and .data.from == $from and .data.to == $to)' \ + "$out_s7" > /dev/null; then + cat "$out_s7" + die "S7: event contents mismatch (expected file_moved, from=$folder_a, to=$folder_b)" +fi +log "S7 OK" + +log "All seven realtime-bus scenarios passed." From c4b859c37f55d9eccee591ccaedac651c9488bfc Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 10 Sep 2026 08:36:49 +0200 Subject: [PATCH 06/21] feat(message-bus): add subscribtion eviction on grant revocation change also plan to implement frontend types generation from AsyncAPI --- docs/plan/message-bus.md | 40 +++++ src/application/ports/realtime_ports.rs | 18 ++ src/bin/generate-asyncapi.rs | 49 ++++++ src/bin/rt-hurl-helper.rs | 31 +++- src/interfaces/api/handlers/grant_handler.rs | 20 +++ src/interfaces/api/handlers/rt_ws.rs | 167 ++++++++++++++++--- tests/api/rt_bus_check.sh | 133 ++++++++++++++- 7 files changed, 427 insertions(+), 31 deletions(-) diff --git a/docs/plan/message-bus.md b/docs/plan/message-bus.md index bd730303..aab0be0f 100644 --- a/docs/plan/message-bus.md +++ b/docs/plan/message-bus.md @@ -603,6 +603,46 @@ Land with their producer PRs; each is a small addition to polymorphic `RtFolderEventNotification` with `oneOf`. Better codegen for typed clients. Refactor when we generate an FE SDK. +### TypeScript client codegen via `@asyncapi/modelina` (Phase-A polish) + +AsyncAPI has the same "spec → typed FE SDK" story OpenAPI has. Wire +it once, avoid hand-maintaining a growing catalog of message types. + +- **Tool:** `@asyncapi/modelina` — the AsyncAPI-native model + generator. Reads `resources/gen/asyncapi.json`, emits TypeScript + interfaces + tagged unions for every message and schema. Actively + maintained, produces idiomatic TS. +- **Not** `@asyncapi/generator`'s WebSocket TEMPLATE — that generates + a full client SDK on assumptions (fetch shape, subscription model) + that don't match our `useTopic` singleton store. Custom composable + stays; only the message DTOs come from codegen. +- **Wiring:** + - `frontend/package.json` dev-dep: `@asyncapi/modelina`. + - Script `frontend/scripts/gen-realtime-types.mjs` invokes Modelina, + writes to `frontend/src/lib/generated/realtime/`. + - `just asyncapi-ts` recipe alongside `just asyncapi`. + - CI dirty-tree check — regenerate on every build, fail if `git + diff` on the generated folder is non-empty. Same discipline as + OpenAPI's check. + - Generated files carry a `// AUTO-GENERATED — do not edit; run + `just asyncapi-ts` to regenerate` header. +- **What the FE gets:** + - `type RtEvent = FileCreatedData | FileRenamedData | ...` — a + tagged union keyed on the `event` discriminator, so the folder + view's `switch (evt.event)` is exhaustive at compile time. + - `RtSubscribeRequestBody`, `RtErrorResponseBody`, error-code enum, + `RtPongResponseBody.result.pong === true` narrowed by type. + - No divergence between wire spec and FE types — the CI check + catches drift. +- **Also worth:** if we ever want a typed WS client for other + languages (Rust sync client, Python integration), the AsyncAPI + spec is the source; Modelina supports 8+ target languages. +- **Timing:** the current spec covers 8 event variants + 6 message + envelopes. Marginal savings today; substantial as Phase B adds + ~15 more event variants (comments, mentions, presence, share + events). Set up now so the discipline is in place BEFORE the + surface grows. + ## AuthZ model (audit rules per AGENTS.md) ### The subscribe gate diff --git a/src/application/ports/realtime_ports.rs b/src/application/ports/realtime_ports.rs index 85a10019..3e0e2c39 100644 --- a/src/application/ports/realtime_ports.rs +++ b/src/application/ports/realtime_ports.rs @@ -245,6 +245,18 @@ pub enum RealtimeEvent { parent_id: Uuid, actor: Uuid, }, + /// A user's authorization changed — publishes on + /// [`Topic::UserAuthz`]. The WS handler auto-subscribes each + /// session to its own `user:{caller}:authz` topic; on receipt it + /// walks the session's active subscriptions and evicts any whose + /// resource is in `affected_folders`, emitting a `rt.revoked` + /// notification per evicted topic. + /// + /// MVP carries folder UUIDs only (the only resource-scoped topic + /// that ships in Phase A). When file/drive/calendar topics land, + /// the payload extends with additional resource classes — see the + /// plan's Phase-B roadmap. + AuthzChanged { affected_folders: Vec }, } // ════════════════════════════════════════════════════════════════════════════ @@ -529,6 +541,12 @@ mod tests { }, "folder_deleted", ), + ( + RealtimeEvent::AuthzChanged { + affected_folders: vec![Uuid::nil()], + }, + "authz_changed", + ), ]; for (ev, expected) in cases { let json = serde_json::to_value(ev).unwrap(); diff --git a/src/bin/generate-asyncapi.rs b/src/bin/generate-asyncapi.rs index 8da99838..8824e4ef 100644 --- a/src/bin/generate-asyncapi.rs +++ b/src/bin/generate-asyncapi.rs @@ -116,6 +116,7 @@ fn channels() -> Value { "SubscribedResponse": { "$ref": "#/components/messages/RtSubscribedResponse" }, "ErrorResponse": { "$ref": "#/components/messages/RtErrorResponse" }, "FolderEvent": { "$ref": "#/components/messages/RtFolderEventNotification" }, + "RevokedNotification": { "$ref": "#/components/messages/RtRevokedNotification" }, } }, "UserAuthz": { @@ -165,6 +166,14 @@ fn operations() -> Value { { "$ref": "#/channels/Folder/messages/FolderEvent" } ] }, + "receiveRevoked": { + "action": "receive", + "channel": { "$ref": "#/channels/Folder" }, + "summary": "Server-initiated eviction of a subscription (grant revoked, resource deleted, etc.). Client stops rendering the topic.", + "messages": [ + { "$ref": "#/channels/Folder/messages/RevokedNotification" } + ] + }, // Application-layer keepalive. Separate from the RFC 6455 Ping // control frame the server sends on `OXICLOUD_RT_WS_KEEPALIVE_SECONDS` // (which is transport-level and not modelled in AsyncAPI). This @@ -235,6 +244,12 @@ fn components() -> Value { "title": "Folder mutation event", "contentType": "application/json", "payload": { "$ref": "#/components/schemas/RtFolderEventBody" }, + }, + "RtRevokedNotification": { + "name": "rt.revoked", + "title": "Subscription evicted", + "contentType": "application/json", + "payload": { "$ref": "#/components/schemas/RtRevokedBody" }, } }, "schemas": { @@ -245,6 +260,7 @@ fn components() -> Value { "RtPongResponseBody": rpc_pong_response_schema(), "RtErrorResponseBody": rpc_error_response_schema(), "RtFolderEventBody": folder_event_notification_schema(), + "RtRevokedBody": revoked_notification_schema(), "FileCreatedData": file_created_schema(), "FileRenamedData": file_renamed_schema(), "FileMovedData": file_moved_schema(), @@ -526,3 +542,36 @@ fn folder_deleted_schema() -> Value { } }) } + +/// `rt.revoked` notification body — server tells the client that a +/// specific subscription has been evicted. `topic` is the wire-form +/// string the client originally subscribed to. `reason` is the stable +/// eviction vocabulary — never repurpose an existing value (matches +/// the AuthZ audit-line convention). +fn revoked_notification_schema() -> Value { + json!({ + "type": "object", + "description": "JSON-RPC notification (no `id`). `method = \"rt.revoked\"`.", + "required": ["jsonrpc", "method", "params"], + "properties": { + "jsonrpc": { "type": "string", "const": "2.0" }, + "method": { "type": "string", "const": "rt.revoked" }, + "params": { + "type": "object", + "required": ["topic", "reason"], + "properties": { + "topic": { "type": "string" }, + "reason": { + "type": "string", + "enum": [ + "grant_revoked", + "resource_deleted", + "group_membership_lost", + "admin_kick", + ] + } + } + } + } + }) +} diff --git a/src/bin/rt-hurl-helper.rs b/src/bin/rt-hurl-helper.rs index 5eac6e96..36bc446b 100644 --- a/src/bin/rt-hurl-helper.rs +++ b/src/bin/rt-hurl-helper.rs @@ -261,6 +261,11 @@ async fn subscribe_and_collect(args: Args) -> Result<(), HelperError> { } let mut events: Vec = Vec::new(); + // Server-initiated eviction notifications (`rt.revoked`) — captured + // separately from `rt.event` so scenarios can assert on eviction + // scoping (evicted topic vs. surviving topic) without conflating + // them with real content events. + let mut revoked: Vec = Vec::new(); // Count server-initiated protocol Pings so scenarios can assert the // keepalive fires. tokio-tungstenite queues an auto-Pong on the next // write path, so we don't need to send one ourselves; we just observe @@ -327,14 +332,25 @@ async fn subscribe_and_collect(args: Args) -> Result<(), HelperError> { // Notification (id-less)? let method = value.get("method").and_then(|v| v.as_str()).unwrap_or(""); - if method == "rt.event" - && let Some(params) = value.get("params") - { - events.push(params.clone()); + match method { + "rt.event" => { + if let Some(params) = value.get("params") { + events.push(params.clone()); + } + } + "rt.revoked" => { + // Server evicted one of our subscriptions. Record for + // the shell to assert on; do NOT increment `events` — + // eviction is orthogonal to content delivery. + if let Some(params) = value.get("params") { + revoked.push(params.clone()); + } + } + _ => { + // Unknown notification method — ignored. `rt.pong` and + // future server-pushed methods land here silently. + } } - // Other notifications (`rt.revoked`, `rt.pong`) — ignored for - // subscribe-and-collect. They can be added to the output - // schema when scenarios need them. } // Assertion: at least `expect_events` collected before timeout. @@ -345,6 +361,7 @@ async fn subscribe_and_collect(args: Args) -> Result<(), HelperError> { let summary = json!({ "subscribed": subscribed, "events": events, + "revoked": revoked, "pings_received": pings_received, "timed_out": timed_out, }); diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index feb6f17e..93dda8f1 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -504,6 +504,26 @@ pub async fn revoke_grant( self_revoke = (granter == caller_id), "🗑️ grant revoked", ); + + // Realtime eviction cascade — the revoke committed, so any WS + // session that had the affected user auto-subscribed to + // `user:{u}:authz` gets an AuthzChanged event and drops any live + // subscriptions to the affected resource. Silent no-op when the + // subject isn't a User (Group / Token subjects don't have live + // sessions to notify — group cascade is Phase-B once group + // membership expansion ships). Folder resources only for MVP; + // File/Drive topics don't exist yet. + if let (Subject::User(target_user), Resource::Folder(folder_id)) = (subject, resource) { + use crate::application::ports::realtime_ports::{RealtimeBus, RealtimeEvent, Topic}; + RealtimeBus::publish( + state.bus.as_ref(), + &Topic::UserAuthz(target_user), + RealtimeEvent::AuthzChanged { + affected_folders: vec![folder_id], + }, + ); + } + StatusCode::NO_CONTENT.into_response() } diff --git a/src/interfaces/api/handlers/rt_ws.rs b/src/interfaces/api/handlers/rt_ws.rs index 510bf88f..d96ee7ad 100644 --- a/src/interfaces/api/handlers/rt_ws.rs +++ b/src/interfaces/api/handlers/rt_ws.rs @@ -184,16 +184,45 @@ impl Drop for Sub { } } +/// Messages the per-topic reader tasks send to the session's main +/// loop. Two shapes: +/// +/// - `Frame` — a client-bound text frame (`rt.event` notification, +/// `rt.revoked` notification, whatever). Main loop writes it to +/// the socket. +/// - `EvictFolders` — internal control signal. The reader for the +/// session's auto-subscribed `user:{caller}:authz` topic translates +/// inbound [`RealtimeEvent::AuthzChanged`] events into this rather +/// than a client-visible frame. Main loop walks its subs, drops any +/// whose resource is in the list, and emits one `rt.revoked` frame +/// per evicted topic. +enum SessionOut { + Frame(String), + EvictFolders(Vec), +} + async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc) { - // Outbound queue — every path that produces a text frame for the - // client enqueues here; the writer half of the select drains. - let (out_tx, mut out_rx) = mpsc::channel::(OUTBOUND_CHANNEL_CAPACITY); + // Outbound queue — every path that produces a client-bound frame + // enqueues here; the writer half of the select drains. Also + // carries internal `EvictFolders` control signals from the + // authz reader — the main loop reacts to those without them + // hitting the socket. + let (out_tx, mut out_rx) = mpsc::channel::(OUTBOUND_CHANNEL_CAPACITY); // Active subscriptions on this session. Keyed by the wire-form topic // string so an incoming `rt.unsubscribe` with the same string is // recognised without re-parsing. let mut subs: HashMap = HashMap::new(); + // Auto-subscribe to the caller's private authz-change topic. + // No AuthZ check (identity-scoped: caller_id == user_id by + // construction), no client `rt.subscribe` frame. The reader for + // this topic translates `AuthzChanged` events into + // `SessionOut::EvictFolders` signals instead of pushing an + // `rt.event` notification the client can see — client-visible + // effect is the `rt.revoked` per evicted sub. + install_subscription(Topic::UserAuthz(caller_id), &mut subs, &out_tx, &state); + // Server-initiated protocol Ping ticker — prevents intermediate // proxies (Traefik, nginx, Cloudflare) and NAT boxes from reaping // the TCP session as idle. Browsers can't send Ping control frames @@ -238,11 +267,36 @@ async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc { match outbound { - Some(text) => { + Some(SessionOut::Frame(text)) => { if socket.send(Message::Text(text.into())).await.is_err() { break; } } + Some(SessionOut::EvictFolders(folders)) => { + // Grant-revocation cascade. Walk the sub set; + // drop any Folder(id) whose id is in the list; + // emit one `rt.revoked` frame per eviction so + // the client knows to stop rendering that + // resource. Idempotent: re-evicting an + // already-gone topic is a no-op. + for folder_uuid in folders { + let wire = Topic::Folder(folder_uuid).to_wire_key(); + if subs.remove(&wire).is_some() { + let frame = revoked_notification( + &wire, + "grant_revoked", + ); + if socket + .send(Message::Text(frame.into())) + .await + .is_err() + { + return; // session dead + } + audit_evicted(caller_id, &wire, "grant_revoked"); + } + } + } None => break, // out_tx dropped — unreachable but safe } } @@ -299,7 +353,7 @@ async fn handle_text_frame( caller_id: Uuid, state: &Arc, subs: &mut HashMap, - out_tx: &mpsc::Sender, + out_tx: &mpsc::Sender, ) -> Option { // Parse envelope. On malformed JSON: reply with an id-less error per // JSON-RPC 2.0 (id = null when the request couldn't be parsed). @@ -346,7 +400,7 @@ async fn handle_subscribe( caller_id: Uuid, state: &Arc, subs: &mut HashMap, - out_tx: &mpsc::Sender, + out_tx: &mpsc::Sender, ) -> String { // Extract topic. let topic_str = match params.get("topic").and_then(Value::as_str) { @@ -437,20 +491,7 @@ async fn handle_subscribe( // AuthZ passed — install the subscription and spawn a reader task // that forwards bus events to the outbound channel as `rt.event` // notifications. - let stream = RealtimeBus::subscribe(state.bus.as_ref(), &topic); - let topic_wire = topic_str.clone(); - let out_tx_task = out_tx.clone(); - let reader = tokio::spawn(async move { - let mut stream = stream; - while let Some(event) = stream.next().await { - let notification = event_notification(&topic_wire, &event); - if out_tx_task.send(notification).await.is_err() { - // Session's outbound channel closed — receiver dropped. - break; - } - } - }); - subs.insert(topic_str.clone(), Sub { reader }); + install_subscription(topic, subs, out_tx, state); success_response(id, serde_json::json!({ "subscribed": topic_str })) } @@ -470,6 +511,62 @@ fn handle_unsubscribe(id: Value, params: Value, subs: &mut HashMap) success_response(id, serde_json::json!({ "unsubscribed": topic_str })) } +// ════════════════════════════════════════════════════════════════════════════ +// Subscription installer +// ════════════════════════════════════════════════════════════════════════════ + +/// Spawn a reader task for `topic` and insert it into `subs`. No AuthZ +/// check — the caller is responsible for gating (either via +/// `handle_subscribe`'s explicit dispatch, or via identity-by- +/// construction for the auto-subscribed `Topic::UserAuthz(caller)`). +/// +/// The reader interprets bus events differently by topic class: +/// +/// - For `Topic::UserAuthz(_)`: an incoming `RealtimeEvent::AuthzChanged` +/// is translated to `SessionOut::EvictFolders(affected)` — the main +/// loop then walks the sub set and drops matching topics. Any other +/// event kind on this topic is ignored (defensive; shouldn't happen +/// in MVP). +/// - For every other topic: bus events are wrapped into a client- +/// visible `rt.event` notification and pushed as `SessionOut::Frame`. +fn install_subscription( + topic: Topic, + subs: &mut HashMap, + out_tx: &mpsc::Sender, + state: &Arc, +) { + let topic_wire = topic.to_wire_key(); + let mut stream = RealtimeBus::subscribe(state.bus.as_ref(), &topic); + let out_tx_task = out_tx.clone(); + let translate_authz = matches!(topic, Topic::UserAuthz(_)); + // Clone for the reader closure; keep the original to key `subs`. + let topic_wire_reader = topic_wire.clone(); + + let reader = tokio::spawn(async move { + while let Some(event) = stream.next().await { + let message = if translate_authz { + match event { + RealtimeEvent::AuthzChanged { affected_folders } => { + SessionOut::EvictFolders(affected_folders) + } + // The authz topic only carries AuthzChanged in + // MVP; other variants would be a producer bug — + // drop them silently so a mis-wired publish + // doesn't spam the client. + _ => continue, + } + } else { + SessionOut::Frame(event_notification(&topic_wire_reader, &event)) + }; + if out_tx_task.send(message).await.is_err() { + // Session's outbound channel closed — receiver dropped. + break; + } + } + }); + subs.insert(topic_wire, Sub { reader }); +} + // ════════════════════════════════════════════════════════════════════════════ // Envelope helpers // ════════════════════════════════════════════════════════════════════════════ @@ -538,6 +635,22 @@ fn split_event_discriminator(mut event_json: Value) -> (String, Value) { ("unknown".to_owned(), event_json) } +/// Build the server-initiated `rt.revoked` JSON-RPC notification. +/// Emitted when a subscription is evicted mid-session (grant revoked, +/// resource deleted, etc.). Not tied to a request id — client sees +/// this as a signal to stop rendering the topic. +fn revoked_notification(topic_wire: &str, reason: &'static str) -> String { + serde_json::to_string(&RpcNotification { + jsonrpc: JSONRPC_V2, + method: "rt.revoked", + params: serde_json::json!({ + "topic": topic_wire, + "reason": reason, + }), + }) + .expect("RpcNotification always serializes") +} + // ════════════════════════════════════════════════════════════════════════════ // Audit // ════════════════════════════════════════════════════════════════════════════ @@ -553,6 +666,20 @@ fn audit_denied(caller_id: Uuid, topic: &str, reason: &'static str) { ); } +/// Audit line for server-initiated eviction — every `rt.revoked` +/// frame we send should also have a durable trail. Stable `reason` +/// vocabulary matches the WS wire's `reason` field. +fn audit_evicted(caller_id: Uuid, topic: &str, reason: &'static str) { + tracing::info!( + target: "audit", + event = "realtime.subscription_evicted", + reason = reason, + caller_id = %caller_id, + topic = %topic, + "🚫 realtime subscription evicted", + ); +} + // ════════════════════════════════════════════════════════════════════════════ // Tests // ════════════════════════════════════════════════════════════════════════════ diff --git a/tests/api/rt_bus_check.sh b/tests/api/rt_bus_check.sh index 153f2684..350d6eb1 100755 --- a/tests/api/rt_bus_check.sh +++ b/tests/api/rt_bus_check.sh @@ -28,6 +28,13 @@ # one session → observe TWO `file_moved` # events (one via the A topic, one via B). # Same file_id/from/to on both. +# S8 Grant-revoke eviction — user2 subscribes to A + B (both granted), +# user1 revokes only A → user2 sees +# `rt.revoked` for folder:A AND an event +# on folder:B (upload after revoke). +# Locks in three invariants: eviction +# fires, scoping is per-topic, session +# survives. # # Exit non-zero on any failure — run.sh treats that as a suite failure. # ───────────────────────────────────────────────────────────────────────────── @@ -123,26 +130,42 @@ user2_login=$(c_post "$base_url/api/auth/login" "" \ "$(printf '{"username":"%s","password":"%s"}' "$user2_name" "$user2_pass")") user2_token=$(printf '%s' "$user2_login" | jq -r '.access_token') [[ -n "$user2_token" && "$user2_token" != "null" ]] || die "no user2 token: $user2_login" +# S8 needs user2's UUID to target them as the grant subject. +user2_id=$(printf '%s' "$user2_login" | jq -r '.user.full.user.id') +[[ -n "$user2_id" && "$user2_id" != "null" ]] || die "no user2 id: $user2_login" # ── Helper: create a small file inside a folder via the byte-upload path. # Not delta / instant-upload; keeps the wire simple and hits the same # `upload_file_streaming` publish hook. mkfile_in() { local folder_id="$1" name="$2" token="$3" - local tmpfile + local tmpfile respfile status tmpfile="$(mktemp -t rtbus_body.XXXXXX)" + respfile="$(mktemp -t rtbus_resp.XXXXXX)" printf 'rt-bus-test-payload' > "$tmpfile" # Multipart-upload path used by the frontend for byte uploads. # NOTE: `folder_id` MUST come BEFORE the `file` part — file_handler.rs # streams the parts in order and the fail-fast folder-required check # fires the moment it sees the file bytes; a folder_id sent after the # file arrives too late (returns 400 "folder_id is required"). - curl -sS -X POST \ + # + # Capture body + status so a silent 4xx doesn't look like a timing + # bug in the bus. A stale server binary that lost the publish hook, + # or a schema change that broke the endpoint, would otherwise + # present as "subscribe works, no event, timeout" — exactly the + # shape of a real regression but a completely different root cause. + status=$(curl -sS -o "$respfile" -w "%{http_code}" -X POST \ -H "Authorization: Bearer $token" \ -F "folder_id=$folder_id" \ -F "file=@$tmpfile;filename=$name" \ - "$base_url/api/files/upload" > /dev/null + "$base_url/api/files/upload") rm -f "$tmpfile" + if [[ "$status" -lt 200 || "$status" -ge 300 ]]; then + printf 'mkfile_in FAIL: HTTP %s\nbody: %s\n' "$status" "$(cat "$respfile")" >&2 + rm -f "$respfile" + return 1 + fi + rm -f "$respfile" } # ── Scenario 1 — Positive delivery ────────────────────────────────────────── @@ -371,4 +394,106 @@ if ! jq -e --arg fid "$s7_file_id" --arg from "$folder_a" --arg to "$folder_b" \ fi log "S7 OK" -log "All seven realtime-bus scenarios passed." +# ── Scenario 8 — Grant-revocation eviction (scoped, session survives) ─────── +# The strong version of "eviction fires": prove that revoking one grant +# affects ONLY the corresponding subscription — the session stays alive, +# unrelated subs keep delivering events, and only the revoked topic +# gets `rt.revoked`. +# +# Setup: +# - user1 grants user2 `viewer` on folders A AND B (two independent +# grants; user2 has no prior access). +# - user2 subscribes to BOTH folders on one WS session. +# Action: +# - user1 revokes the grant on folder A only. +# - user1 uploads a file to folder B (the surviving sub). +# Invariants: +# (a) helper output records exactly ONE `rt.revoked` for `folder:A` +# with reason `grant_revoked` — the eviction fired. +# (b) helper output records exactly ONE `file_created` event for +# `folder:B` — the unrelated sub is still delivering. Regression +# that mass-drops subs on any AuthzChanged would surface as 0 +# events. +# (c) `subscribed` contains BOTH folder:A and folder:B — both +# original subs were installed (regression that failed the initial +# subscribe under AuthZ would fail here). +# (d) `timed_out == false` — session actor kept running through the +# revoke + subsequent event. Regression that killed the whole +# session on AuthzChanged would surface as a timeout or the +# helper's `wait` failing. +log "S8: revoke user2's grant on folder A; unrelated sub on B still delivers." +# 8.1 Grant user2 viewer role on folder A + folder B. +grant_a=$(c_post "$base_url/api/grants" "$user1_token" \ + "$(printf '{"subject":{"type":"user","id":"%s"},"resource":{"type":"folder","id":"%s"},"role":"viewer"}' \ + "$user2_id" "$folder_a")") +grant_a_id=$(printf '%s' "$grant_a" | jq -r '.grants[0].id') +[[ -n "$grant_a_id" && "$grant_a_id" != "null" ]] \ + || die "S8: grant on folder A failed: $grant_a" + +grant_b=$(c_post "$base_url/api/grants" "$user1_token" \ + "$(printf '{"subject":{"type":"user","id":"%s"},"resource":{"type":"folder","id":"%s"},"role":"viewer"}' \ + "$user2_id" "$folder_b")") +grant_b_id=$(printf '%s' "$grant_b" | jq -r '.grants[0].id') +[[ -n "$grant_b_id" && "$grant_b_id" != "null" ]] \ + || die "S8: grant on folder B failed: $grant_b" + +# 8.2 user2 subscribes to BOTH folder topics; --expect-events 1 exits +# when the post-revoke upload lands on the SURVIVING sub. +out_s8="$(mktemp -t rtbus_s8.XXXXXX)" +"$HELPER_BIN" subscribe-and-collect \ + --url "$ws_url" \ + --token "$user2_token" \ + --subscribe "folder:$folder_a" \ + --subscribe "folder:$folder_b" \ + --expect-events 1 \ + --timeout 6s \ + --output "$out_s8" & +helper_pid=$! +sleep 0.4 # let both subscribes install + +# 8.3 user1 revokes only the folder-A grant. +curl -sS -X DELETE \ + -H "Authorization: Bearer $user1_token" \ + "$base_url/api/grants/$grant_a_id" > /dev/null +sleep 0.3 # let AuthzChanged propagate + +# 8.4 user1 uploads to folder B → triggers file_created on the +# surviving sub. +mkfile_in "$folder_b" "s8.txt" "$user1_token" + +if ! wait "$helper_pid"; then + cat "$out_s8" >&2 || true + die "S8: helper did not observe the post-revoke event on folder B" +fi + +# 8.5 Assert on the four invariants. +# (a) One rt.revoked for folder:A with reason grant_revoked. +revoked_count=$(jq -r '.revoked | length' "$out_s8") +[[ "$revoked_count" == "1" ]] \ + || { cat "$out_s8"; die "S8: expected 1 rt.revoked, got $revoked_count"; } +[[ "$(jq -r '.revoked[0].topic' "$out_s8")" == "folder:$folder_a" ]] \ + || die "S8: revoked wrong topic: $(jq -r '.revoked[0].topic' "$out_s8")" +[[ "$(jq -r '.revoked[0].reason' "$out_s8")" == "grant_revoked" ]] \ + || die "S8: revoked wrong reason: $(jq -r '.revoked[0].reason' "$out_s8")" + +# (b) One file_created for folder:B — surviving sub delivered. +event_count=$(jq -r '.events | length' "$out_s8") +[[ "$event_count" == "1" ]] \ + || { cat "$out_s8"; die "S8: expected 1 event on surviving sub, got $event_count (regression: mass eviction?)"; } +[[ "$(jq -r '.events[0].event' "$out_s8")" == "file_created" ]] \ + || die "S8: wrong event kind on surviving sub" +[[ "$(jq -r '.events[0].data.parent_id' "$out_s8")" == "$folder_b" ]] \ + || die "S8: wrong parent_id on surviving-sub event" + +# (c) Both original subs were installed. +sub_count=$(jq -r '.subscribed | length' "$out_s8") +[[ "$sub_count" == "2" ]] \ + || { cat "$out_s8"; die "S8: expected both subs installed, got $sub_count"; } + +# (d) Session did not time out — main loop kept running. +[[ "$(jq -r '.timed_out' "$out_s8")" == "false" ]] \ + || die "S8: session timed out (regression: session died on AuthzChanged?)" + +log "S8 OK" + +log "All eight realtime-bus scenarios passed." From 1d280c161cd824cb1d2c237c88858d708c5394b5 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 10 Sep 2026 21:33:24 +0200 Subject: [PATCH 07/21] feat(asyncapi): generate ts types according asyncapi --- .github/workflows/ci.yml | 64 + frontend/package-lock.json | 4113 ++++++++++++++++- frontend/package.json | 4 +- frontend/scripts/gen-realtime-types.mjs | 214 + .../lib/generated/realtime/FileCreatedData.ts | 9 + .../lib/generated/realtime/FileDeletedData.ts | 8 + .../lib/generated/realtime/FileMovedData.ts | 10 + .../lib/generated/realtime/FileRenamedData.ts | 10 + frontend/src/lib/generated/realtime/Folder.ts | 4 + .../generated/realtime/FolderCreatedData.ts | 9 + .../generated/realtime/FolderDeletedData.ts | 8 + .../lib/generated/realtime/FolderMovedData.ts | 10 + .../generated/realtime/FolderRenamedData.ts | 10 + .../src/lib/generated/realtime/RtErrorCode.ts | 14 + .../lib/generated/realtime/RtErrorMessage.ts | 14 + .../lib/generated/realtime/RtErrorObject.ts | 10 + .../generated/realtime/RtErrorResponseBody.ts | 9 + .../src/lib/generated/realtime/RtEventKind.ts | 11 + .../lib/generated/realtime/RtEventParams.ts | 25 + .../generated/realtime/RtFolderEventBody.ts | 9 + .../generated/realtime/RtPingRequestBody.ts | 8 + .../generated/realtime/RtPongResponseBody.ts | 9 + .../lib/generated/realtime/RtPongResult.ts | 6 + .../lib/generated/realtime/RtRevokedBody.ts | 9 + .../lib/generated/realtime/RtRevokedParams.ts | 8 + .../lib/generated/realtime/RtRevokedReason.ts | 7 + .../generated/realtime/RtSubscribeParams.ts | 6 + .../realtime/RtSubscribeRequestBody.ts | 10 + .../realtime/RtSuccessResponseBody.ts | 8 + .../generated/realtime/RtUnsubscribeParams.ts | 6 + .../realtime/RtUnsubscribeRequestBody.ts | 10 + justfile | 73 +- src/bin/generate-asyncapi.rs | 335 +- 33 files changed, 4944 insertions(+), 116 deletions(-) create mode 100644 frontend/scripts/gen-realtime-types.mjs create mode 100644 frontend/src/lib/generated/realtime/FileCreatedData.ts create mode 100644 frontend/src/lib/generated/realtime/FileDeletedData.ts create mode 100644 frontend/src/lib/generated/realtime/FileMovedData.ts create mode 100644 frontend/src/lib/generated/realtime/FileRenamedData.ts create mode 100644 frontend/src/lib/generated/realtime/Folder.ts create mode 100644 frontend/src/lib/generated/realtime/FolderCreatedData.ts create mode 100644 frontend/src/lib/generated/realtime/FolderDeletedData.ts create mode 100644 frontend/src/lib/generated/realtime/FolderMovedData.ts create mode 100644 frontend/src/lib/generated/realtime/FolderRenamedData.ts create mode 100644 frontend/src/lib/generated/realtime/RtErrorCode.ts create mode 100644 frontend/src/lib/generated/realtime/RtErrorMessage.ts create mode 100644 frontend/src/lib/generated/realtime/RtErrorObject.ts create mode 100644 frontend/src/lib/generated/realtime/RtErrorResponseBody.ts create mode 100644 frontend/src/lib/generated/realtime/RtEventKind.ts create mode 100644 frontend/src/lib/generated/realtime/RtEventParams.ts create mode 100644 frontend/src/lib/generated/realtime/RtFolderEventBody.ts create mode 100644 frontend/src/lib/generated/realtime/RtPingRequestBody.ts create mode 100644 frontend/src/lib/generated/realtime/RtPongResponseBody.ts create mode 100644 frontend/src/lib/generated/realtime/RtPongResult.ts create mode 100644 frontend/src/lib/generated/realtime/RtRevokedBody.ts create mode 100644 frontend/src/lib/generated/realtime/RtRevokedParams.ts create mode 100644 frontend/src/lib/generated/realtime/RtRevokedReason.ts create mode 100644 frontend/src/lib/generated/realtime/RtSubscribeParams.ts create mode 100644 frontend/src/lib/generated/realtime/RtSubscribeRequestBody.ts create mode 100644 frontend/src/lib/generated/realtime/RtSuccessResponseBody.ts create mode 100644 frontend/src/lib/generated/realtime/RtUnsubscribeParams.ts create mode 100644 frontend/src/lib/generated/realtime/RtUnsubscribeRequestBody.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a19b1ea..a925ef9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,7 @@ jobs: wasm: ${{ steps.filter.outputs.wasm }} plugins: ${{ steps.filter.outputs.plugins }} migrations: ${{ steps.filter.outputs.migrations }} + realtime_spec: ${{ steps.filter.outputs.realtime_spec }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 @@ -52,6 +53,13 @@ jobs: - 'src/application/adapters/plugin_user_lifecycle_hook.rs' migrations: - 'migrations/**' + realtime_spec: + - 'src/application/ports/realtime_ports.rs' + - 'src/bin/generate-asyncapi.rs' + - 'resources/gen/asyncapi.json' + - 'frontend/scripts/gen-realtime-types.mjs' + - 'frontend/src/lib/generated/realtime/**' + - 'frontend/package.json' frontend-check: name: Frontend — svelte-check, ESLint, Stylelint, Prettier @@ -80,6 +88,62 @@ jobs: - name: Unit tests run: npm run test:unit + # Regenerates the AsyncAPI spec and its TypeScript projection from + # scratch, then fails the PR if either output drifts from what was + # committed. Same discipline as the OpenAPI + wasm-fixture approach + # elsewhere in this file — the wire spec is a compile-time artefact + # of the Rust source (`realtime_ports.rs`), and the TS DTOs are a + # compile-time artefact of the spec, so both must be reproducible. + # + # Scoped by the `realtime_spec` path filter so a PR that doesn't + # touch the wire (or its generator scripts, or the Modelina version) + # skips this job entirely. Needs BOTH Rust and Node toolchains, so + # it's slightly heavier than a single-toolchain job — the filter + # keeps it off the hot path. + realtime-spec-drift: + name: Realtime spec — AsyncAPI + TypeScript DTO drift + needs: changes + if: needs.changes.outputs.realtime_spec == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # Rust for `just asyncapi` — the JSON spec is built by + # `cargo run --features dev_tools --bin generate-asyncapi`. + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + # Node for `just asyncapi-ts` — Modelina projects the spec into + # the FE `src/lib/generated/realtime/` folder. + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 26.3.0 + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Install frontend deps + working-directory: frontend + run: npm ci + - name: Regenerate AsyncAPI spec + # `just asyncapi` = `cargo run --features dev_tools --bin generate-asyncapi` + run: cargo run --features dev_tools --bin generate-asyncapi + - name: Regenerate TS DTOs (Modelina) + working-directory: frontend + run: npm run gen:realtime + - name: Fail if committed files drifted + # A non-empty diff means a contributor edited the Rust wire + # source (or Modelina config) without regenerating, or hand- + # edited the generated files. Either is a bug; the message + # below points at the fix. + run: | + if ! git diff --exit-code \ + resources/gen/asyncapi.json \ + frontend/src/lib/generated/realtime/; then + echo "" + echo "::error::Realtime spec drift: the committed files differ from what the" + echo "::error::generator produces from source. Run \`just asyncapi-ts\` locally" + echo "::error::and commit the result — that recipe re-runs both stages." + exit 1 + fi + # Fails the PR if a new sqlx migration file has a timestamp NOT strictly # greater than every migration already on the target branch. Guards # against the "two branches in flight, whoever merges second breaks diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 03b46a81..3eef6936 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "loglevel": "^1.9.2" }, "devDependencies": { + "@asyncapi/modelina": "^5.5.0", "@eslint/js": "^10.0.1", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.66.0", @@ -49,6 +50,117 @@ "dev": true, "license": "MIT" }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.1.1.tgz", + "integrity": "sha512-u/kozRnsPO/x8QtKYJOqoGtC4kH6yg1lfYkB9Au0WhYB0FNLpyFusttQtvhlwjtG3rOwiRz4D8DnnXa8iEpIKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "11.7.2", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.2" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.7.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.7.2.tgz", + "integrity": "sha512-4gY54eEGEstClvEkGnwVkTkrx0sqwemEFG5OSRRn3tD91XH0+Q8XIkYIfo7IwEWPpJZwILb9GUXeShtplRc/eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -100,6 +212,200 @@ "dev": true, "license": "MIT" }, + "node_modules/@asyncapi/avro-schema-parser": { + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/@asyncapi/avro-schema-parser/-/avro-schema-parser-3.0.24.tgz", + "integrity": "sha512-YMyr2S2heMrWHRyECknjHeejlZl5exUSv9nD1gTejAT13fSf0PqIRydZ9ZuoglCLBg55AeehypR2zLIBu/9kHQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@asyncapi/parser": "^3.1.0", + "@types/json-schema": "^7.0.11", + "avsc": "^5.7.6" + } + }, + "node_modules/@asyncapi/modelina": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/@asyncapi/modelina/-/modelina-5.10.1.tgz", + "integrity": "sha512-mvk77+ls2ia+w3uQftJ7s6/Yid4lO+1IgbTkJ94mGSV9Qqk1n+ln5dz2snccARI5ubdy3ofKb3QP2Dq/OGeH8A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.1.0", + "@apidevtools/swagger-parser": "^10.1.0", + "@asyncapi/multi-parser": "^2.2.0", + "@asyncapi/parser": "^3.4.0", + "alterschema": "^1.1.2", + "change-case": "^4.1.2", + "fast-xml-parser": "^5.3.0", + "js-yaml": "^4.1.0", + "openapi-types": "^12.1.3", + "typescript-json-schema": "^0.58.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@asyncapi/multi-parser": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@asyncapi/multi-parser/-/multi-parser-2.4.0.tgz", + "integrity": "sha512-odtze8N+nGDuzitYB4PlArsFBhEL601ahvR/NL0FVKXWOVz9FTO4IIwtLToJT0PuNaAZAwwVeE5mAmgERaXvOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@asyncapi/avro-schema-parser": "^3.0.3", + "@asyncapi/openapi-schema-parser": "^3.0.4", + "@asyncapi/parser": "*", + "@asyncapi/protobuf-schema-parser": "^3.8.2", + "parserapiv1": "npm:@asyncapi/parser@^2.1.0", + "parserapiv2": "npm:@asyncapi/parser@3.0.0-next-major-spec.8" + }, + "peerDependencies": { + "@asyncapi/raml-dt-schema-parser": "^4.0.4" + }, + "peerDependenciesMeta": { + "@asyncapi/raml-dt-schema-parser": { + "optional": true + } + } + }, + "node_modules/@asyncapi/openapi-schema-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@asyncapi/openapi-schema-parser/-/openapi-schema-parser-3.1.0.tgz", + "integrity": "sha512-YblYFErE6ixLTz+MNddzB/EW+EgRN5ubHM7LTZNGjUFYpyFOLWQVrV1ErrnCTZJ7Blr81GZmhpDpjpI8IfwiYQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@openapi-contrib/openapi-schema-to-json-schema": "~3.2.0", + "ajv": "^8.11.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1" + }, + "peerDependencies": { + "@asyncapi/parser": "^3.6.2" + } + }, + "node_modules/@asyncapi/openapi-schema-parser/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@asyncapi/openapi-schema-parser/node_modules/ajv-errors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", + "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.0.1" + } + }, + "node_modules/@asyncapi/openapi-schema-parser/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asyncapi/parser": { + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/@asyncapi/parser/-/parser-3.6.3.tgz", + "integrity": "sha512-MUC8xIUMcS2qNvqrqyx/ie0txu3d/OdIsrXs7UCzawdyR6P07gh35DpOqPz/z57s1UA3vERVpcheZYl3h8cVtw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@asyncapi/specs": "^6.11.1", + "@openapi-contrib/openapi-schema-to-json-schema": "~3.2.0", + "@stoplight/json": "3.21.0", + "@stoplight/json-ref-readers": "^1.2.2", + "@stoplight/json-ref-resolver": "^3.1.5", + "@stoplight/spectral-core": "^1.18.3", + "@stoplight/spectral-functions": "^1.7.2", + "@stoplight/spectral-parsers": "^1.0.2", + "@stoplight/spectral-ref-resolver": "^1.0.3", + "@stoplight/types": "^13.12.0", + "@types/json-schema": "^7.0.11", + "@types/urijs": "^1.19.19", + "ajv": "^8.18.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1", + "avsc": "^5.7.5", + "js-yaml": "^4.3.1", + "jsonpath-plus": "^10.0.7", + "node-fetch": "2.6.7" + } + }, + "node_modules/@asyncapi/parser/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@asyncapi/parser/node_modules/ajv-errors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", + "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.0.1" + } + }, + "node_modules/@asyncapi/parser/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asyncapi/protobuf-schema-parser": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@asyncapi/protobuf-schema-parser/-/protobuf-schema-parser-3.9.0.tgz", + "integrity": "sha512-S8HH2gCb8qysKEuxVOOR0X+UV49n2oefeRRB9D+imtrYocOT+M4UgC+PgmcxgPSarIclG4mAzWYuH+hlKejDcw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@asyncapi/parser": "^3.6.2", + "@types/protocol-buffers-schema": "^3.4.3", + "protobufjs": "^8.7.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@asyncapi/specs": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-6.11.1.tgz", + "integrity": "sha512-A3WBLqAKGoJ2+6FWFtpjBlCQ1oFCcs4GxF7zsIGvNqp/klGUHjlA3aAcZ9XMMpLGE8zPeYDz2x9FmO6DSuKraQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.11" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -454,6 +760,30 @@ "@keyv/serialize": "^1.1.1" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -927,6 +1257,97 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@hyperjump/json": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@hyperjump/json/-/json-0.1.0.tgz", + "integrity": "sha512-jWsAOHjweWhi0UEBCN57YZzyTt76Z6Fm/OJXOfNBJbEZt569AcTRsjv6Dqj5t4gQhW9td72oquiyaVp9oHbhBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hyperjump/json-pointer": "^0.9.2", + "moo": "^0.5.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + } + }, + "node_modules/@hyperjump/json-pointer": { + "version": "0.9.8", + "resolved": "https://registry.npmjs.org/@hyperjump/json-pointer/-/json-pointer-0.9.8.tgz", + "integrity": "sha512-6D6okhpH5VOS3oSYUtxu8nClsOcp59aC+sS06/tCxEta4T5Gk1yaycLiCkG8kE9eh+9AJUHsvQEJJrWBfLOjvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "just-curry-it": "^5.3.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + } + }, + "node_modules/@hyperjump/json-schema": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@hyperjump/json-schema/-/json-schema-0.23.5.tgz", + "integrity": "sha512-gb1jOT6+BlZBR9Nc/tMGDt757YM7rjS71Dml3+TBYebdGOZlSrTzTfVAUfGzOlsceB3gP4K9b7HzAwEGMWmexQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@hyperjump/json-schema-core": "^0.28.0", + "fastest-stable-stringify": "^2.0.2", + "just-curry-it": "^5.3.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + } + }, + "node_modules/@hyperjump/json-schema-core": { + "version": "0.28.5", + "resolved": "https://registry.npmjs.org/@hyperjump/json-schema-core/-/json-schema-core-0.28.5.tgz", + "integrity": "sha512-+f5P3oHYCQru3s+Ha+E10rIyEvyK0Hfa2oj3+cDoGaVMbT4Jg5TgCoIM7B5rl3t3KRA7EOmrLjKFGeLi5yd1pg==", + "deprecated": "This package was rolled into @hyperjump/json-schema as of v1.0.0", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@hyperjump/json": "^0.1.0", + "@hyperjump/json-pointer": "^0.9.4", + "@hyperjump/pact": "^0.2.3", + "content-type": "^1.0.4", + "node-fetch": "^2.6.5", + "pubsub-js": "^1.9.4", + "uri-js": "^4.4.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + } + }, + "node_modules/@hyperjump/pact": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@hyperjump/pact/-/pact-0.2.5.tgz", + "integrity": "sha512-93m7gLf40EI8svsKrdPc+KkLsngwX/2ld08xwc0PFioxJSxnfkx1BUHNJVjhG386UUYP6mNe+ZtmIiDXDJ4TQg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "just-curry-it": "^3.1.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + } + }, + "node_modules/@hyperjump/pact/node_modules/just-curry-it": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/just-curry-it/-/just-curry-it-3.2.1.tgz", + "integrity": "sha512-Q8206k8pTY7krW32cdmPsP+DqqLgWx/hYPSj9/+7SYqSqz7UuwPbfSe07lQtvuuaVyiSJveXk0E5RydOuWwsEg==", + "dev": true, + "license": "MIT" + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1094,6 +1515,52 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/ternary": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/ternary/-/ternary-1.1.4.tgz", + "integrity": "sha512-ck5wiqIbqdMX6WRQztBL7ASDty9YLgJ3sSAK5ZpBzXeySvFGCzIvM6UiAI4hTZ22fEcYQVV/zhUbNscggW+Ukg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, "node_modules/@keyv/serialize": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", @@ -1101,6 +1568,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1139,6 +1619,16 @@ "node": ">= 8" } }, + "node_modules/@openapi-contrib/openapi-schema-to-json-schema": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@openapi-contrib/openapi-schema-to-json-schema/-/openapi-schema-to-json-schema-3.2.0.tgz", + "integrity": "sha512-Gj6C0JwCr8arj0sYuslWXUBSP/KnUlEGnPW4qxlXvAl543oaNQgMgIgkQUA6vs5BCCvwTEiL8m/wdWzfl4UvSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1496,6 +1986,14 @@ "win32" ] }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@serenity-kit/opaque": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@serenity-kit/opaque/-/opaque-1.1.0.tgz", @@ -1525,6 +2023,535 @@ "dev": true, "license": "MIT" }, + "node_modules/@stoplight/json": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@stoplight/json/-/json-3.21.0.tgz", + "integrity": "sha512-5O0apqJ/t4sIevXCO3SBN9AHCEKKR/Zb4gaj7wYe5863jme9g02Q0n/GhM7ZCALkL+vGPTe4ZzTETP8TFtsw3g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/ordered-object-literal": "^1.0.3", + "@stoplight/path": "^1.3.2", + "@stoplight/types": "^13.6.0", + "jsonc-parser": "~2.2.1", + "lodash": "^4.17.21", + "safe-stable-stringify": "^1.1" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/@stoplight/json-ref-readers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@stoplight/json-ref-readers/-/json-ref-readers-1.2.2.tgz", + "integrity": "sha512-nty0tHUq2f1IKuFYsLM4CXLZGHdMn+X/IwEUIpeSOXt0QjMUbL0Em57iJUDzz+2MkWG83smIigNZ3fauGjqgdQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-fetch": "^2.6.0", + "tslib": "^1.14.1" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/@stoplight/json-ref-resolver": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@stoplight/json-ref-resolver/-/json-ref-resolver-3.1.6.tgz", + "integrity": "sha512-YNcWv3R3n3U6iQYBsFOiWSuRGE5su1tJSiX6pAPRVk7dP0L7lqCteXGzuVRQ0gMZqUl8v1P0+fAKxF6PLo9B5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "^3.21.0", + "@stoplight/path": "^1.3.2", + "@stoplight/types": "^12.3.0 || ^13.0.0", + "@types/urijs": "^1.19.19", + "dependency-graph": "~0.11.0", + "fast-memoize": "^2.5.2", + "immer": "^9.0.6", + "lodash": "^4.17.21", + "tslib": "^2.6.0", + "urijs": "^1.19.11" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/@stoplight/json-ref-resolver/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@stoplight/ordered-object-literal": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/ordered-object-literal/-/ordered-object-literal-1.0.5.tgz", + "integrity": "sha512-COTiuCU5bgMUtbIFBuyyh2/yVVzlr5Om0v5utQDgBCuQUOPgU1DwoffkTfg4UBQOvByi5foF4w4T+H9CoRe5wg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/@stoplight/path": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@stoplight/path/-/path-1.3.2.tgz", + "integrity": "sha512-lyIc6JUlUA8Ve5ELywPC8I2Sdnh1zc1zmbYgVarhXIp9YeAB0ReeqmGEOWNtlHkbP2DAA1AL65Wfn2ncjK/jtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/@stoplight/spectral-core": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-core/-/spectral-core-1.23.1.tgz", + "integrity": "sha512-VLC8OhpO/pMJKb6IHhurxJjXO1qB56Ng1unIb8b+hNxdw0+SEcASvmR+RpjfHYX/jv/DfSaA1x8QhFBJBmqBOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "^1.4.0", + "@stoplight/better-ajv-errors": "1.0.3", + "@stoplight/json": "~3.21.0", + "@stoplight/path": "1.3.2", + "@stoplight/spectral-parsers": "^1.0.0", + "@stoplight/spectral-ref-resolver": "^1.0.4", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "~13.6.0", + "@types/es-aggregate-error": "^1.0.2", + "@types/json-schema": "^7.0.11", + "ajv": "^8.18.0", + "ajv-errors": "~3.0.0", + "ajv-formats": "~2.1.1", + "es-aggregate-error": "^1.0.7", + "expr-eval-fork": "^3.0.1", + "jsonpath-plus": "^10.3.0", + "lodash": "^4.18.1", + "lodash.topath": "^4.5.2", + "minimatch": "^3.1.4", + "nimma": "0.2.3", + "pony-cause": "^1.1.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-core/node_modules/@stoplight/better-ajv-errors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@stoplight/better-ajv-errors/-/better-ajv-errors-1.0.3.tgz", + "integrity": "sha512-0p9uXkuB22qGdNfy3VeEhxkU5uwvp/KrBTAbrLBURv6ilxIVwanKwjMc41lQfIVgPGcOkmLbTolfFrSsueu7zA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jsonpointer": "^5.0.0", + "leven": "^3.1.0" + }, + "engines": { + "node": "^12.20 || >= 14.13" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, + "node_modules/@stoplight/spectral-core/node_modules/@stoplight/types": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.6.0.tgz", + "integrity": "sha512-dzyuzvUjv3m1wmhPfq82lCVYGcXG0xUYgqnWfCq3PCVR4BKFhjdkHrnJ+jIDoMKvXb05AZP/ObQF6+NpDo29IQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/spectral-core/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@stoplight/spectral-core/node_modules/ajv-errors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", + "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.0.1" + } + }, + "node_modules/@stoplight/spectral-core/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stoplight/spectral-core/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@stoplight/spectral-core/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stoplight/spectral-core/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@stoplight/spectral-core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@stoplight/spectral-formats": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-formats/-/spectral-formats-1.8.5.tgz", + "integrity": "sha512-xaC0rCH0p7/bzNJsz+JgLSj+Cp6uwYGWpePQxdLkF2G6a8Zyp3OyS7umkGYNiimEwKrOjvCNNTFJpeuiENZSBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "^1.4.0", + "@stoplight/json": "^3.17.0", + "@stoplight/spectral-core": "^1.23.0", + "@types/json-schema": "^7.0.7", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-formats/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@stoplight/spectral-functions": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-functions/-/spectral-functions-1.10.5.tgz", + "integrity": "sha512-vDCd0NJ93715bcUpZZ5vNHiyxd4cgHF6tuXsDiXOXKAByg+I1fR5/dMijEo6Ce1Lz95a+RZ22JKYhF1YuzVvuA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "^1.4.0", + "@stoplight/better-ajv-errors": "1.0.3", + "@stoplight/json": "^3.17.1", + "@stoplight/spectral-core": "^1.23.0", + "@stoplight/spectral-formats": "^1.8.1", + "@stoplight/spectral-runtime": "^1.1.2", + "ajv": "^8.18.0", + "ajv-draft-04": "~1.0.0", + "ajv-errors": "~3.0.0", + "ajv-formats": "~2.1.1", + "lodash": "^4.18.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-functions/node_modules/@stoplight/better-ajv-errors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@stoplight/better-ajv-errors/-/better-ajv-errors-1.0.3.tgz", + "integrity": "sha512-0p9uXkuB22qGdNfy3VeEhxkU5uwvp/KrBTAbrLBURv6ilxIVwanKwjMc41lQfIVgPGcOkmLbTolfFrSsueu7zA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jsonpointer": "^5.0.0", + "leven": "^3.1.0" + }, + "engines": { + "node": "^12.20 || >= 14.13" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, + "node_modules/@stoplight/spectral-functions/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@stoplight/spectral-functions/node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@stoplight/spectral-functions/node_modules/ajv-errors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", + "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.0.1" + } + }, + "node_modules/@stoplight/spectral-functions/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stoplight/spectral-functions/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@stoplight/spectral-parsers": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-parsers/-/spectral-parsers-1.0.5.tgz", + "integrity": "sha512-ANDTp2IHWGvsQDAY85/jQi9ZrF4mRrA5bciNHX+PUxPr4DwS6iv4h+FVWJMVwcEYdpyoIdyL+SRmHdJfQEPmwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "~3.21.0", + "@stoplight/types": "^14.1.1", + "@stoplight/yaml": "~4.3.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-parsers/node_modules/@stoplight/types": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-14.1.1.tgz", + "integrity": "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/spectral-parsers/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@stoplight/spectral-ref-resolver": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-ref-resolver/-/spectral-ref-resolver-1.0.5.tgz", + "integrity": "sha512-gj3TieX5a9zMW29z3mBlAtDOCgN3GEc1VgZnCVlr5irmR4Qi5LuECuFItAq4pTn5Zu+sW5bqutsCH7D4PkpyAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json-ref-readers": "1.2.2", + "@stoplight/json-ref-resolver": "~3.1.6", + "@stoplight/spectral-runtime": "^1.1.2", + "dependency-graph": "0.11.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-ref-resolver/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@stoplight/spectral-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-runtime/-/spectral-runtime-1.1.6.tgz", + "integrity": "sha512-Y8rEDyMN4bSMJCrDs2shdcVHYyCnH3FvXRP4dBhha4Z8iJv+JPp7KqOV/hwVB/hWFC209upiwj2oDmLfR0qCDg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "^3.20.1", + "@stoplight/path": "^1.3.2", + "@stoplight/types": "^13.6.0", + "lodash": "^4.18.1", + "node-fetch": "^2.7.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-runtime/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/@stoplight/spectral-runtime/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stoplight/spectral-runtime/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@stoplight/spectral-runtime/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/@stoplight/spectral-runtime/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/@stoplight/types": { + "version": "13.20.0", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.20.0.tgz", + "integrity": "sha512-2FNTv05If7ib79VPDA/r9eUet76jewXFH2y2K5vuge6SXbRHtWBhcaRmu+6QpF4/WRNoJj5XYRSwLGXDxysBGA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@stoplight/yaml/-/yaml-4.3.0.tgz", + "integrity": "sha512-JZlVFE6/dYpP9tQmV0/ADfn32L9uFarHWxfcRhReKUnljz1ZiUM5zpX+PH8h5CJs6lao3TuFqnPm9IJJCEkE2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/ordered-object-literal": "^1.0.5", + "@stoplight/types": "^14.1.1", + "@stoplight/yaml-ast-parser": "0.0.50", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=10.8" + } + }, + "node_modules/@stoplight/yaml-ast-parser": { + "version": "0.0.50", + "resolved": "https://registry.npmjs.org/@stoplight/yaml-ast-parser/-/yaml-ast-parser-0.0.50.tgz", + "integrity": "sha512-Pb6M8TDO9DtSVla9yXSTAxmo9GVEouq5P40DWXdOie69bXogZTkgvopCq+yEvTMA0F6PEvdJmbtTV3ccIp11VQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@stoplight/yaml/node_modules/@stoplight/types": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-14.1.1.tgz", + "integrity": "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/yaml/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", @@ -1734,6 +2761,34 @@ "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.13.tgz", + "integrity": "sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -1776,6 +2831,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/es-aggregate-error": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/es-aggregate-error/-/es-aggregate-error-1.0.6.tgz", + "integrity": "sha512-qJ7LIFp06h1QE1aVxbVd+zJP2wdaugYXYfd6JxsyRMrYHaxb6itXPogW2tz+ylUJ1n1b+JF1PHyYCfYHm0dvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1807,6 +2872,16 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/protocol-buffers-schema": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/protocol-buffers-schema/-/protocol-buffers-schema-3.4.3.tgz", + "integrity": "sha512-8cCg6BiIj4jS0LXUFq3sndmd46yyPLYqMzvXLcTM1MRubh3sfZlQiehoCjGDxSHTqGSjjx8EtVNryIAl0njQWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1814,6 +2889,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/urijs": { + "version": "1.19.26", + "resolved": "https://registry.npmjs.org/@types/urijs/-/urijs-1.19.26.tgz", + "integrity": "sha512-wkXrVzX5yoqLnndOwFsieJA7oKM8cNkOKJtf/3vVGSUFkWDKZvFHpIl9Pvqb/T9UsawBBFMTTD8xu7sK5MWuvg==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.61.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", @@ -2180,6 +3262,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2197,6 +3292,64 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/alterschema": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/alterschema/-/alterschema-1.1.3.tgz", + "integrity": "sha512-VqKTk8lX8LHVRvSOgEZDGPeEYOvrSOjlX/1PAi4el7ac8acC6/6a99HuVjfU6N1tNrHV5dU0sQDmuOjRvBf/Sw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@hyperjump/json-schema": "^0.23.5", + "json-e": "^4.4.3", + "lodash": "^4.17.21", + "object-hash": "^3.0.0" + }, + "bin": { + "alterschema": "bindings/node/cli.js" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2223,6 +3376,26 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2240,6 +3413,45 @@ "node": ">= 0.4" } }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2260,6 +3472,52 @@ "node": ">=8" } }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "dev": true, + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/avsc": { + "version": "5.7.9", + "resolved": "https://registry.npmjs.org/avsc/-/avsc-5.7.9.tgz", + "integrity": "sha512-yOA4wFeI7ET3v32Di/sUybQ+ttP20JHSW3mxLuNGeO0uD6PPcvLrIQXSvy/rhJOWU5JrYh7U4OHplWMmtAtjMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.11" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2387,6 +3645,63 @@ "@keyv/serialize": "^1.1.1" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2397,6 +3712,24 @@ "node": ">=6" } }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/camel-case/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", @@ -2428,6 +3761,25 @@ ], "license": "CC-BY-4.0" }, + "node_modules/capital-case": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz", + "integrity": "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case-first": "^2.0.2" + } + }, + "node_modules/capital-case/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2438,6 +3790,34 @@ "node": ">=18" } }, + "node_modules/change-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-4.1.2.tgz", + "integrity": "sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "capital-case": "^1.0.4", + "constant-case": "^3.0.4", + "dot-case": "^3.0.4", + "header-case": "^2.0.4", + "no-case": "^3.0.4", + "param-case": "^3.0.4", + "pascal-case": "^3.1.2", + "path-case": "^3.0.4", + "sentence-case": "^3.0.4", + "snake-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/change-case/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2454,6 +3834,21 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2464,6 +3859,17 @@ "node": ">=6" } }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2491,6 +3897,52 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/constant-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.4.tgz", + "integrity": "sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case": "^2.0.2" + } + }, + "node_modules/constant-case/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2535,6 +3987,13 @@ } } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2608,6 +4067,60 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2650,6 +4163,52 @@ "node": ">=0.10.0" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dependency-graph": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", + "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2667,6 +4226,16 @@ "dev": true, "license": "MIT" }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dom-accessibility-api": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", @@ -2746,6 +4315,39 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dot-case/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.376", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", @@ -2793,6 +4395,137 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-aggregate-error": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/es-aggregate-error/-/es-aggregate-error-1.0.14.tgz", + "integrity": "sha512-3YxX6rVb07B5TV11AV5wsL7nQCHXNwoHPsQC8S4AmBiqYhyNCJ5BRKXkXyDJvs8QzXN20NgRtxe3dEEQD9NLHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "globalthis": "^1.0.4", + "has-property-descriptors": "^1.0.2", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -2800,6 +4533,56 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3127,6 +4910,16 @@ "node": ">=12.0.0" } }, + "node_modules/expr-eval-fork": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/expr-eval-fork/-/expr-eval-fork-3.0.3.tgz", + "integrity": "sha512-BhC+hbc5lIVjygr840n5DEkW3MQq7H9o+mc1/N7Z5uIiCFVyESLL5DIE7LNq4CYUNxy+XjA+3jRrL/h0Kt2xcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3178,6 +4971,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-memoize": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/fast-memoize/-/fast-memoize-2.5.2.tgz", + "integrity": "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -3195,6 +4995,47 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz", + "integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", @@ -3205,6 +5046,13 @@ "node": ">= 4.9.1" } }, + "node_modules/fastest-stable-stringify": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz", + "integrity": "sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -3279,6 +5127,29 @@ "dev": true, "license": "ISC" }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3294,6 +5165,60 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3304,6 +5229,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -3317,6 +5252,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -3327,6 +5287,38 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -3412,6 +5404,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/globby": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.0.tgz", @@ -3450,6 +5459,32 @@ "dev": true, "license": "MIT" }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz", @@ -3463,6 +5498,64 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hashery": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", @@ -3476,6 +5569,37 @@ "node": ">=20" } }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/header-case": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz", + "integrity": "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "capital-case": "^1.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/header-case/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/hookified": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", @@ -3552,6 +5676,17 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "9.0.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", + "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3600,6 +5735,25 @@ "node": ">=8" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", @@ -3607,6 +5761,39 @@ "dev": true, "license": "ISC" }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -3614,6 +5801,123 @@ "dev": true, "license": "MIT" }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3624,6 +5928,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -3634,6 +5954,26 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3647,6 +5987,32 @@ "node": ">=0.10.0" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3657,6 +6023,23 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-path-inside": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", @@ -3687,6 +6070,171 @@ "@types/estree": "^1.0.6" } }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3729,9 +6277,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -3818,6 +6366,16 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3838,6 +6396,20 @@ "dev": true, "license": "MIT" }, + "node_modules/json-e": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/json-e/-/json-e-4.8.4.tgz", + "integrity": "sha512-ilxMMkTN7Iv3zICTia7+c899JPbjQddvdyV9nkh/frdDESDhlt92czMTucKbuUoybLQJt9MUmJFqwfhOylzFEQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash": "^4.17.23" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -3845,6 +6417,43 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-migrate": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json-schema-migrate/-/json-schema-migrate-0.2.0.tgz", + "integrity": "sha512-dq4/oHWmtw/+0ytnXsDqVn+VsVweTEmzm5jLgguPn9BjSzn6/q58ZiZx3BHiQyJs612f0T5Z+MrUEUUY5DHsRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^5.0.0" + } + }, + "node_modules/json-schema-migrate/node_modules/ajv": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", + "integrity": "sha512-Ajr4IcMXq/2QmMkEmSvxqfLN5zGmJ92gHXAeOXq1OekoH2rfDNsgdDoL2f7QaRCy7G/E6TpxBVdRuNraMztGHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "co": "^4.6.0", + "fast-deep-equal": "^1.0.0", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.3.0" + } + }, + "node_modules/json-schema-migrate/node_modules/fast-deep-equal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", + "integrity": "sha512-fueX787WZKCV0Is4/T2cyAdM4+x1S3MXXOAhavE1ys/W42SHAPacLTQhucja22QBYrfGw50M2sRiXPtTGv9Ymw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-migrate/node_modules/json-schema-traverse": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", + "integrity": "sha512-4JD/Ivzg7PoW8NzdrBSr3UFwC9mHgvI7Z6z3QGBsSHgKaRTUDmyZAAKJo2UbG1kUVfS9WS8bi36N49U1xw43DA==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -3872,6 +6481,49 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.2.1.tgz", + "integrity": "sha512-o6/yDBYccGvTz1+QFevz6l6OBZ2+fMVu2JZ9CIhzsYRX4mjaK5IyX9eldUdCmga16zlgQxyrj5pt9kzuj2C02w==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonpath-plus": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz", + "integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/just-curry-it": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/just-curry-it/-/just-curry-it-5.3.0.tgz", + "integrity": "sha512-silMIRiFjUWlfaDhkgSzpuAyQ6EX/o09Eu8ZBfmFwQMbax7+LQzeIU2CBrICT6Ne4l86ITCGvUCBpCubWYy0Yw==", + "dev": true, + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3909,6 +6561,16 @@ "dev": true, "license": "MIT" }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3963,6 +6625,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.topath": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/lodash.topath/-/lodash.topath-4.5.2.tgz", + "integrity": "sha512-1/W4dM+35DwvE/iEd1M9ekewOSTlpFekhw9mhAtrwjVqUr83/ilQiyAvmg4tVX7Unkcfl1KC+i9WdaT4B6aQcg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", @@ -3983,6 +6659,30 @@ "url": "https://tidelift.com/funding/github/npm/loglevel" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lower-case/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/lru-cache": { "version": "11.5.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", @@ -4013,6 +6713,23 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mathml-tag-names": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-4.0.0.tgz", @@ -4104,6 +6821,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/moo": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -4157,6 +6881,90 @@ "dev": true, "license": "MIT" }, + "node_modules/nimma": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/nimma/-/nimma-0.2.3.tgz", + "integrity": "sha512-1ZOI8J+1PKKGceo/5CT5GfQOG6H8I2BencSK06YarZ2wXwH37BSSUWldqJmMJYA5JfqDqffxDXynt6f11AyKcA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsep-plugin/regex": "^1.0.1", + "@jsep-plugin/ternary": "^1.0.2", + "astring": "^1.8.1", + "jsep": "^1.2.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + }, + "optionalDependencies": { + "jsonpath-plus": "^6.0.1 || ^10.1.0", + "lodash.topath": "^4.5.2" + } + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/no-case/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.48", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", @@ -4177,6 +6985,60 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", @@ -4191,6 +7053,23 @@ "node": ">=12.20.0" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "dev": true, + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4209,6 +7088,25 @@ "node": ">= 0.8.0" } }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4251,6 +7149,24 @@ "node": ">=6" } }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/param-case/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4296,6 +7212,203 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parserapiv1": { + "name": "@asyncapi/parser", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@asyncapi/parser/-/parser-2.1.2.tgz", + "integrity": "sha512-2pHKnr2P8EujcrvZo4x4zNwsEIAg5vb1ZEhl2+OH0YBg8EYH/Xx73XZ+bbwLaYIg1gvFjm29jNB9UL3CMeDU5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@asyncapi/specs": "^5.1.0", + "@openapi-contrib/openapi-schema-to-json-schema": "~3.2.0", + "@stoplight/json": "^3.20.2", + "@stoplight/json-ref-readers": "^1.2.2", + "@stoplight/json-ref-resolver": "^3.1.5", + "@stoplight/spectral-core": "^1.16.1", + "@stoplight/spectral-functions": "^1.7.2", + "@stoplight/spectral-parsers": "^1.0.2", + "@stoplight/spectral-ref-resolver": "^1.0.3", + "@stoplight/types": "^13.12.0", + "@types/json-schema": "^7.0.11", + "@types/urijs": "^1.19.19", + "ajv": "^8.11.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1", + "avsc": "^5.7.5", + "js-yaml": "^4.1.0", + "jsonpath-plus": "^7.2.0", + "node-fetch": "2.6.7" + } + }, + "node_modules/parserapiv1/node_modules/@asyncapi/specs": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-5.1.0.tgz", + "integrity": "sha512-yffhETqehkim43luMnPKOwzY0D0YtU4bKpORIXIaid6p5Y5kDLrMGJaEPkNieQp03HMjhjFrnUPtT8kvqe0+aQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.11" + } + }, + "node_modules/parserapiv1/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/parserapiv1/node_modules/ajv-errors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", + "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.0.1" + } + }, + "node_modules/parserapiv1/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/parserapiv1/node_modules/jsonpath-plus": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-7.2.0.tgz", + "integrity": "sha512-zBfiUPM5nD0YZSBT/o/fbCUlCcepMIdP0CJZxM1+KgA4f2T206f6VAg9e7mX35+KlMaIc5qXW34f3BnwJ3w+RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/parserapiv2": { + "name": "@asyncapi/parser", + "version": "3.0.0-next-major-spec.8", + "resolved": "https://registry.npmjs.org/@asyncapi/parser/-/parser-3.0.0-next-major-spec.8.tgz", + "integrity": "sha512-d8ebYM08BCsx3Q4AeLke6naU/NrcAXFEVpS6b3EWcKRdUDce+v0X5k9aDH+YXWCaQApEF28UzcxhlSOJvhIFgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@asyncapi/specs": "^6.0.0-next-major-spec.9", + "@openapi-contrib/openapi-schema-to-json-schema": "~3.2.0", + "@stoplight/json-ref-resolver": "^3.1.5", + "@stoplight/spectral-core": "^1.16.1", + "@stoplight/spectral-functions": "^1.7.2", + "@stoplight/spectral-parsers": "^1.0.2", + "@types/json-schema": "^7.0.11", + "@types/urijs": "^1.19.19", + "ajv": "^8.11.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1", + "avsc": "^5.7.5", + "js-yaml": "^4.1.0", + "jsonpath-plus": "^7.2.0", + "node-fetch": "2.6.7", + "ramldt2jsonschema": "^1.2.3", + "webapi-parser": "^0.5.0" + } + }, + "node_modules/parserapiv2/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/parserapiv2/node_modules/ajv-errors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", + "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.0.1" + } + }, + "node_modules/parserapiv2/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/parserapiv2/node_modules/jsonpath-plus": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-7.2.0.tgz", + "integrity": "sha512-zBfiUPM5nD0YZSBT/o/fbCUlCcepMIdP0CJZxM1+KgA4f2T206f6VAg9e7mX35+KlMaIc5qXW34f3BnwJ3w+RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/pascal-case/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/path-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz", + "integrity": "sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-case/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/path-equal": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/path-equal/-/path-equal-1.2.8.tgz", + "integrity": "sha512-rCJWMlXlk9lZ2FEllgvh2IgPj/kxMx2sOeqtFNLO8dxEY0I4hBTa+ZW3JAsgeiTe8vL4MifATVDwxF9nGUI7HQ==", + "dev": true, + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4306,6 +7419,32 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -4360,6 +7499,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pony-cause": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz", + "integrity": "sha512-PxkIc/2ZpLiEzQXu5YRDOUgBlfGYBY8156HY5ZcRAwwonMk5W/MrJP2LLkG/hF7GEQzaHo2aS7ho6ZLCOvf+6g==", + "dev": true, + "license": "0BSD", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -4585,6 +7744,26 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/protobufjs": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.8.0.tgz", + "integrity": "sha512-N3xhQ5yyBx3vQq4gubBfASzYhJGNzeDbjqBpu61g7UVylsN/qyffU96TKWD3GbbLOKF82VGNRNvv1+BFgE31Eg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/pubsub-js": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/pubsub-js/-/pubsub-js-1.9.5.tgz", + "integrity": "sha512-5MZ0I9i5JWVO7SizvOviKvZU2qaBbl2KQX150FAA+fJBwYpwOUId7aNygURWSdPzlsA/xZ/InUKXqBbzM0czTA==", + "dev": true, + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4636,6 +7815,47 @@ ], "license": "MIT" }, + "node_modules/ramldt2jsonschema": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/ramldt2jsonschema/-/ramldt2jsonschema-1.2.3.tgz", + "integrity": "sha512-+wLDAV2NNv9NkfEUOYStaDu/6RYgYXeC1zLtXE+dMU/jDfjpN4iJnBGycDwFTFaIQGosOQhxph7fEX6Mpwxdug==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "commander": "^5.0.0", + "js-yaml": "^3.14.0", + "json-schema-migrate": "^0.2.0", + "webapi-parser": "^0.5.0" + }, + "bin": { + "dt2js": "bin/dt2js.js", + "js2dt": "bin/js2dt.js" + } + }, + "node_modules/ramldt2jsonschema/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/ramldt2jsonschema/node_modules/js-yaml": { + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -4671,6 +7891,60 @@ "node": ">=8" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -4784,6 +8058,68 @@ "node": ">=6" } }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-stable-stringify": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-1.1.1.tgz", + "integrity": "sha512-ERq4hUjKDbJfE4+XtZLFPCDi8Vb1JqaxAPTxWFLBx8XcAlf9Bda/ZJdVezs/NAfsMQScyIlUMx+Yeu7P7rx5jw==", + "dev": true, + "license": "MIT" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -4810,6 +8146,25 @@ "node": ">=10" } }, + "node_modules/sentence-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/sentence-case/-/sentence-case-3.0.4.tgz", + "integrity": "sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case-first": "^2.0.2" + } + }, + "node_modules/sentence-case/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/set-cookie-parser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", @@ -4817,6 +8172,55 @@ "dev": true, "license": "MIT" }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4840,6 +8244,82 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -4906,6 +8386,24 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/snake-case/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -4947,6 +8445,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4962,6 +8474,66 @@ "node": ">=8" } }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -4988,6 +8560,22 @@ "node": ">=8" } }, + "node_modules/strnum": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, "node_modules/stylelint": { "version": "17.13.0", "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.13.0.tgz", @@ -5618,6 +9206,57 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5631,6 +9270,84 @@ "node": ">= 0.8.0" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -5669,6 +9386,129 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/typescript-json-schema": { + "version": "0.58.1", + "resolved": "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.58.1.tgz", + "integrity": "sha512-EcmquhfGEmEJOAezLZC6CzY0rPNzfXuky+Z3zoXULEEncW8e13aAjmC2r8ppT1bvvDekJj1TJ4xVhOdkjYtkUA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@types/json-schema": "^7.0.9", + "@types/node": "^16.9.2", + "glob": "^7.1.7", + "path-equal": "^1.2.5", + "safe-stable-stringify": "^2.2.0", + "ts-node": "^10.9.1", + "typescript": "~4.9.5", + "yargs": "^17.1.1" + }, + "bin": { + "typescript-json-schema": "bin/typescript-json-schema" + } + }, + "node_modules/typescript-json-schema/node_modules/@types/node": { + "version": "16.18.126", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", + "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript-json-schema/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript-json-schema/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/typescript-json-schema/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typescript-json-schema/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript-json-schema/node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/typescript-json-schema/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/undici": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", @@ -5730,6 +9570,40 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/upper-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-2.0.2.tgz", + "integrity": "sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/upper-case-first": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz", + "integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/upper-case-first/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/upper-case/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -5740,6 +9614,13 @@ "punycode": "^2.1.0" } }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5747,6 +9628,23 @@ "dev": true, "license": "MIT" }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", @@ -6442,6 +10340,36 @@ "node": ">=18" } }, + "node_modules/webapi-parser": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/webapi-parser/-/webapi-parser-0.5.0.tgz", + "integrity": "sha512-fPt6XuMqLSvBz8exwX4QE1UT+pROLHa00EMDCdO0ybICduwQ1V4f7AWX4pNOpCp+x+0FjczEsOxtQU0d8L3QKw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ajv": "6.5.2" + } + }, + "node_modules/webapi-parser/node_modules/ajv": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.5.2.tgz", + "integrity": "sha512-hOs7GfvI6tUI1LfZddH82ky6mOMyTuY0mk7kE2pWpmhhUSkumzaTO5vbVwij39MdwPQWCV4Zv57Eo06NtL/GVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.1" + } + }, + "node_modules/webapi-parser/node_modules/fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", + "dev": true, + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -6493,6 +10421,95 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -6520,6 +10537,31 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/write-file-atomic": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", @@ -6543,6 +10585,22 @@ "node": ">=18" } }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -6550,6 +10608,16 @@ "dev": true, "license": "MIT" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -6557,6 +10625,45 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index dd0287c3..4515fee8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,9 +17,11 @@ "format": "prettier --write .", "test:unit": "LANG=C vitest run", "test:unit:watch": "LANG=C vitest", - "test:unit:coverage": "rm -rf ../tests/e2e/.nyc_output_unit && LANG=C COVERAGE=1 vitest run" + "test:unit:coverage": "rm -rf ../tests/e2e/.nyc_output_unit && LANG=C COVERAGE=1 vitest run", + "gen:realtime": "node scripts/gen-realtime-types.mjs" }, "devDependencies": { + "@asyncapi/modelina": "^5.5.0", "@eslint/js": "^10.0.1", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.66.0", diff --git a/frontend/scripts/gen-realtime-types.mjs b/frontend/scripts/gen-realtime-types.mjs new file mode 100644 index 00000000..468c7cf5 --- /dev/null +++ b/frontend/scripts/gen-realtime-types.mjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node +// Realtime bus — TypeScript DTOs generated from `resources/gen/asyncapi.json`. +// +// Sits on the same axis as `resources/gen/openapi.json`: the wire spec +// (authored by `cargo run --features dev_tools --bin generate-asyncapi`) +// is the source of truth, and this script projects it into typed FE +// interfaces so `lib/composables/useTopic.ts` and every folder-view +// switch statement is compile-time exhaustive over the `rt.event` variants. +// +// Regenerate: `just asyncapi-ts` (or `npm run gen:realtime`). +// CI is expected to run the same command and fail if the working tree is +// dirty afterwards — same discipline `just openapi` follows. +// +// Design notes: +// * `modelType: 'interface'` — plain records, not classes-with-getters. +// Matches the FE codebase style (see `lib/api/types.ts`). +// * Output goes to `src/lib/generated/realtime/` — a directory reserved +// for auto-generated files. Never hand-edit anything inside. +// * Every file gets a `AUTO-GENERATED` banner via a preset so a stray +// edit is obvious at review time. +// * Modelina auto-detects AsyncAPI 3.0 from the top-level `asyncapi` +// field. No explicit input-type flag needed. + +import { execFile as execFileCb } from 'node:child_process'; +import { readFile, readdir, rm, mkdir, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { TypeScriptFileGenerator } from '@asyncapi/modelina'; + +const execFile = promisify(execFileCb); + +// Anchor everything on this script's location so `just asyncapi-ts` from +// the repo root and `npm run gen:realtime` from the frontend both work. +const __dirname = dirname(fileURLToPath(import.meta.url)); +const frontendRoot = resolve(__dirname, '..'); +const repoRoot = resolve(frontendRoot, '..'); + +const specPath = resolve(repoRoot, 'resources/gen/asyncapi.json'); +const outputDir = resolve(frontendRoot, 'src/lib/generated/realtime'); + +// Load the spec. Failing here means the wire spec hasn't been generated +// yet — hint the operator at the right command. +let spec; +try { + spec = JSON.parse(await readFile(specPath, 'utf8')); +} catch (err) { + console.error( + `gen-realtime-types: cannot read ${specPath}: ${err.message}\n` + + `\nDid you run \`just asyncapi\` first? The Rust generator writes\n` + + `resources/gen/asyncapi.json; this script consumes it.` + ); + process.exit(1); +} + +// Fresh output directory every run — no stale files from a schema that +// was removed since last run. CI dirty-tree check catches drift both +// ways (missing new + leftover old). +await rm(outputDir, { recursive: true, force: true }); +await mkdir(outputDir, { recursive: true }); + +const generator = new TypeScriptFileGenerator({ + // Plain interfaces, no class scaffolding. FE consumers use structural + // types via `useTopic<...>` and plain object literals. + modelType: 'interface', + // Use inline types where possible (nested objects) rather than + // generating a separate model for every anonymous subschema — keeps + // the file count tractable. + rawPropertyNames: true, + presets: [ + { + // File-level banner. `class` preset covers both class and + // interface output in Modelina's TS generator. + class: { + self({ content }) { + const banner = + '// AUTO-GENERATED — do not edit by hand.\n' + + '// Regenerate with `just asyncapi-ts` (which runs\n' + + '// `node frontend/scripts/gen-realtime-types.mjs`).\n' + + '// Source of truth: resources/gen/asyncapi.json,\n' + + '// authored by the Rust `generate-asyncapi` binary.\n'; + return `${banner}${content}`; + } + }, + interface: { + self({ content }) { + const banner = + '// AUTO-GENERATED — do not edit by hand.\n' + + '// Regenerate with `just asyncapi-ts`.\n'; + return `${banner}${content}`; + } + } + } + ] +}); + +// Modelina auto-detects AsyncAPI 3.0 from the `asyncapi` root field. +// `generateToFiles` writes one file per top-level model and returns the +// list of models. Any generation error propagates up as a rejection. +const models = await generator.generateToFiles(spec, outputDir, { + moduleSystem: 'ESM' +}); + +// Post-process for `verbatimModuleSyntax: true` — Modelina 5.x emits +// pre-verbatim shapes (`import X from`, `export default X`) that +// modern strict TS rejects. Two mechanical rewrites make the output +// pass `svelte-check` under the frontend's tsconfig: +// +// 1. `import X from './X';` → `import type X from './X';` +// 2. `export default X;` → `export type { X as default };` +// +// Both rewrites are safe because we run Modelina in `modelType: +// 'interface'` mode — every top-level export is a type, and every +// cross-file default import is a type import. If we ever add +// value-emitting output (enums, const objects), tighten this. +const files = await readdir(outputDir); +let rewritten = 0; +for (const f of files) { + if (!f.endsWith('.ts')) continue; + const path = resolve(outputDir, f); + let content = await readFile(path, 'utf8'); + const before = content; + // Match `import from '';` anywhere in the + // file. Modelina puts these at the top; `^...$` with the `m` flag + // scopes to whole lines. + content = content.replace(/^import (\w+) from '(\.\/[\w_]+)';$/gm, "import type $1 from '$2';"); + // Match the trailing `export default ;`. Turn it into the + // type-only default-export form the TS spec accepts. + content = content.replace(/^export default (\w+);$/gm, 'export type { $1 as default };'); + // Modelina-limitation escape hatch: bare `any` → `unknown`. + // + // JSON Schema has no way to express "any JSON value" in a way + // Modelina projects into TypeScript cleanly — a schema of + // `{"type": ["object", "array", "string", "number", "boolean", + // "null"]}` (every JSON type) or an untyped `{}` still comes out + // as `any` in Modelina's default output. The two sites this + // affects are: + // + // * `RtErrorObject.data` — JSON-RPC 2.0 spec: "A Primitive or + // Structured value that contains additional information." + // * `RtSuccessResponseBody.result` — the generic base; each + // specific method has its own typed result schema. + // + // Both are honestly open on the wire; the client checks a + // discriminator (`code` / `method`) before narrowing. + // + // `unknown` is the correct TS type here — strict supertype of + // `any`, forces the consumer to narrow. Every OTHER wart (`Map`, + // `additionalProperties`, `AnonymousSchema_N`) MUST be fixed at + // the AsyncAPI schema level per project convention; this rewrite + // is the sole exception, gated to a Modelina defect. + content = content.replace(/\bany\b/g, 'unknown'); + if (content !== before) { + await writeFile(path, content); + rewritten++; + } +} + +// Guard against reintroducing anonymous schemas. Modelina falls back +// to `AnonymousSchema_N` for every inline / nested schema in the +// AsyncAPI spec that doesn't have an explicit component name — the +// resulting TS files are unreadable in code review, opaque in imports, +// and don't refactor safely. Every real schema should be hoisted to +// `#/components/schemas/` in `src/bin/generate-asyncapi.rs` and +// referenced via `$ref` instead of embedded inline. +// +// If this guard trips, look at which inline schema in the AsyncAPI +// spec triggered it — usually a nested `params`, `result`, `error`, +// or an inline `enum` array — and hoist it to a named schema. +const anonymous = files.filter((f) => f.endsWith('.ts') && /^AnonymousSchema_/i.test(f)); +if (anonymous.length > 0) { + console.error( + `gen-realtime-types: FAIL — Modelina produced ${anonymous.length} ` + + `AnonymousSchema_N file(s):` + ); + for (const f of anonymous) console.error(` - ${f}`); + console.error( + `\nHoist the corresponding inline schema in\n` + + ` src/bin/generate-asyncapi.rs\n` + + `to a named entry under \`components.schemas\` and\n` + + `reference it via \`ref_schema("")\` instead of\n` + + `embedding the object inline. Regenerate with\n` + + ` just asyncapi-ts\n` + + `and the file count for this run should show 0 AnonymousSchema.\n` + ); + process.exit(1); +} + +// Run the repo's Prettier over the generated output so the committed +// files match the same style as hand-written code — otherwise +// `npm run check`'s `prettier --check` step fails. Uses the local +// binary so config (.prettierrc, plugins) applies. Run via npx to +// stay agnostic of monorepo hoisting. +try { + await execFile('npx', ['--no-install', 'prettier', '--write', outputDir, '--log-level', 'warn'], { + cwd: frontendRoot + }); +} catch (err) { + console.error( + `gen-realtime-types: prettier --write failed: ${err.message}\n` + + `The generated files may still be usable but will fail\n` + + `\`npm run check\` on the prettier step. Fix prettier setup\n` + + `(is @prettier installed in frontend/node_modules?) then\n` + + `re-run \`just asyncapi-ts\`.` + ); + process.exit(1); +} + +console.log( + `gen-realtime-types: wrote ${models.length} model(s) to ${outputDir}` + + ` (rewrote ${rewritten} for verbatimModuleSyntax, 0 AnonymousSchema,` + + ` prettier-formatted)` +); diff --git a/frontend/src/lib/generated/realtime/FileCreatedData.ts b/frontend/src/lib/generated/realtime/FileCreatedData.ts new file mode 100644 index 00000000..88ec8c38 --- /dev/null +++ b/frontend/src/lib/generated/realtime/FileCreatedData.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface FileCreatedData { + actor: string; + file_id: string; + name: string; + parent_id: string; +} +export type { FileCreatedData as default }; diff --git a/frontend/src/lib/generated/realtime/FileDeletedData.ts b/frontend/src/lib/generated/realtime/FileDeletedData.ts new file mode 100644 index 00000000..3b35817c --- /dev/null +++ b/frontend/src/lib/generated/realtime/FileDeletedData.ts @@ -0,0 +1,8 @@ +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface FileDeletedData { + actor: string; + file_id: string; + parent_id: string; +} +export type { FileDeletedData as default }; diff --git a/frontend/src/lib/generated/realtime/FileMovedData.ts b/frontend/src/lib/generated/realtime/FileMovedData.ts new file mode 100644 index 00000000..fcabd6ac --- /dev/null +++ b/frontend/src/lib/generated/realtime/FileMovedData.ts @@ -0,0 +1,10 @@ +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface FileMovedData { + actor: string; + file_id: string; + from: string; + name: string; + to: string; +} +export type { FileMovedData as default }; diff --git a/frontend/src/lib/generated/realtime/FileRenamedData.ts b/frontend/src/lib/generated/realtime/FileRenamedData.ts new file mode 100644 index 00000000..1680a56f --- /dev/null +++ b/frontend/src/lib/generated/realtime/FileRenamedData.ts @@ -0,0 +1,10 @@ +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface FileRenamedData { + actor: string; + file_id: string; + new_name: string; + old_name: string; + parent_id: string; +} +export type { FileRenamedData as default }; diff --git a/frontend/src/lib/generated/realtime/Folder.ts b/frontend/src/lib/generated/realtime/Folder.ts new file mode 100644 index 00000000..516d2836 --- /dev/null +++ b/frontend/src/lib/generated/realtime/Folder.ts @@ -0,0 +1,4 @@ +import type RtSuccessResponseBody from './RtSuccessResponseBody'; +import type RtErrorResponseBody from './RtErrorResponseBody'; +type Folder = RtSuccessResponseBody | RtErrorResponseBody; +export type { Folder as default }; diff --git a/frontend/src/lib/generated/realtime/FolderCreatedData.ts b/frontend/src/lib/generated/realtime/FolderCreatedData.ts new file mode 100644 index 00000000..d01f7acf --- /dev/null +++ b/frontend/src/lib/generated/realtime/FolderCreatedData.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface FolderCreatedData { + actor: string; + folder_id: string; + name: string; + parent_id: string; +} +export type { FolderCreatedData as default }; diff --git a/frontend/src/lib/generated/realtime/FolderDeletedData.ts b/frontend/src/lib/generated/realtime/FolderDeletedData.ts new file mode 100644 index 00000000..f41ea5e6 --- /dev/null +++ b/frontend/src/lib/generated/realtime/FolderDeletedData.ts @@ -0,0 +1,8 @@ +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface FolderDeletedData { + actor: string; + folder_id: string; + parent_id: string; +} +export type { FolderDeletedData as default }; diff --git a/frontend/src/lib/generated/realtime/FolderMovedData.ts b/frontend/src/lib/generated/realtime/FolderMovedData.ts new file mode 100644 index 00000000..84d086b2 --- /dev/null +++ b/frontend/src/lib/generated/realtime/FolderMovedData.ts @@ -0,0 +1,10 @@ +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface FolderMovedData { + actor: string; + folder_id: string; + from: string; + name: string; + to: string; +} +export type { FolderMovedData as default }; diff --git a/frontend/src/lib/generated/realtime/FolderRenamedData.ts b/frontend/src/lib/generated/realtime/FolderRenamedData.ts new file mode 100644 index 00000000..fd71db4c --- /dev/null +++ b/frontend/src/lib/generated/realtime/FolderRenamedData.ts @@ -0,0 +1,10 @@ +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface FolderRenamedData { + actor: string; + folder_id: string; + new_name: string; + old_name: string; + parent_id: string; +} +export type { FolderRenamedData as default }; diff --git a/frontend/src/lib/generated/realtime/RtErrorCode.ts b/frontend/src/lib/generated/realtime/RtErrorCode.ts new file mode 100644 index 00000000..967c4a18 --- /dev/null +++ b/frontend/src/lib/generated/realtime/RtErrorCode.ts @@ -0,0 +1,14 @@ +enum RtErrorCode { + MINUS_32001 = -32001, + MINUS_32002 = -32002, + MINUS_32003 = -32003, + MINUS_32004 = -32004, + MINUS_32005 = -32005, + MINUS_32006 = -32006, + MINUS_32007 = -32007, + MINUS_32603 = -32603, + MINUS_32600 = -32600, + MINUS_32601 = -32601, + MINUS_32602 = -32602 +} +export type { RtErrorCode as default }; diff --git a/frontend/src/lib/generated/realtime/RtErrorMessage.ts b/frontend/src/lib/generated/realtime/RtErrorMessage.ts new file mode 100644 index 00000000..69bb4a7b --- /dev/null +++ b/frontend/src/lib/generated/realtime/RtErrorMessage.ts @@ -0,0 +1,14 @@ +enum RtErrorMessage { + NO_READ = 'no_read', + NO_SHARE = 'no_share', + NO_COMMENT = 'no_comment', + TOPIC_FORBIDDEN = 'topic_forbidden', + SUB_LIMIT = 'sub_limit', + RATE_LIMITED = 'rate_limited', + NO_EDIT = 'no_edit', + INTERNAL_ERROR = 'internal_error', + INVALID_REQUEST = 'invalid_request', + METHOD_NOT_FOUND = 'method_not_found', + INVALID_PARAMS = 'invalid_params' +} +export type { RtErrorMessage as default }; diff --git a/frontend/src/lib/generated/realtime/RtErrorObject.ts b/frontend/src/lib/generated/realtime/RtErrorObject.ts new file mode 100644 index 00000000..773876f2 --- /dev/null +++ b/frontend/src/lib/generated/realtime/RtErrorObject.ts @@ -0,0 +1,10 @@ +import type RtErrorCode from './RtErrorCode'; +import type RtErrorMessage from './RtErrorMessage'; +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface RtErrorObject { + code: RtErrorCode; + data?: unknown; + message: RtErrorMessage; +} +export type { RtErrorObject as default }; diff --git a/frontend/src/lib/generated/realtime/RtErrorResponseBody.ts b/frontend/src/lib/generated/realtime/RtErrorResponseBody.ts new file mode 100644 index 00000000..d03d4832 --- /dev/null +++ b/frontend/src/lib/generated/realtime/RtErrorResponseBody.ts @@ -0,0 +1,9 @@ +import type RtErrorObject from './RtErrorObject'; +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface RtErrorResponseBody { + error: RtErrorObject; + id: string | null | number | null | null; + jsonrpc: '2.0'; +} +export type { RtErrorResponseBody as default }; diff --git a/frontend/src/lib/generated/realtime/RtEventKind.ts b/frontend/src/lib/generated/realtime/RtEventKind.ts new file mode 100644 index 00000000..65aa0797 --- /dev/null +++ b/frontend/src/lib/generated/realtime/RtEventKind.ts @@ -0,0 +1,11 @@ +enum RtEventKind { + FILE_CREATED = 'file_created', + FILE_RENAMED = 'file_renamed', + FILE_MOVED = 'file_moved', + FILE_DELETED = 'file_deleted', + FOLDER_CREATED = 'folder_created', + FOLDER_RENAMED = 'folder_renamed', + FOLDER_MOVED = 'folder_moved', + FOLDER_DELETED = 'folder_deleted' +} +export type { RtEventKind as default }; diff --git a/frontend/src/lib/generated/realtime/RtEventParams.ts b/frontend/src/lib/generated/realtime/RtEventParams.ts new file mode 100644 index 00000000..576ca986 --- /dev/null +++ b/frontend/src/lib/generated/realtime/RtEventParams.ts @@ -0,0 +1,25 @@ +import type FileCreatedData from './FileCreatedData'; +import type FileRenamedData from './FileRenamedData'; +import type FileMovedData from './FileMovedData'; +import type FileDeletedData from './FileDeletedData'; +import type FolderCreatedData from './FolderCreatedData'; +import type FolderRenamedData from './FolderRenamedData'; +import type FolderMovedData from './FolderMovedData'; +import type FolderDeletedData from './FolderDeletedData'; +import type RtEventKind from './RtEventKind'; +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface RtEventParams { + data: + | FileCreatedData + | FileRenamedData + | FileMovedData + | FileDeletedData + | FolderCreatedData + | FolderRenamedData + | FolderMovedData + | FolderDeletedData; + event: RtEventKind; + topic: string; +} +export type { RtEventParams as default }; diff --git a/frontend/src/lib/generated/realtime/RtFolderEventBody.ts b/frontend/src/lib/generated/realtime/RtFolderEventBody.ts new file mode 100644 index 00000000..e3c7ff9e --- /dev/null +++ b/frontend/src/lib/generated/realtime/RtFolderEventBody.ts @@ -0,0 +1,9 @@ +import type RtEventParams from './RtEventParams'; +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface RtFolderEventBody { + jsonrpc: '2.0'; + method: 'rt.event'; + params: RtEventParams; +} +export type { RtFolderEventBody as default }; diff --git a/frontend/src/lib/generated/realtime/RtPingRequestBody.ts b/frontend/src/lib/generated/realtime/RtPingRequestBody.ts new file mode 100644 index 00000000..38ffcf55 --- /dev/null +++ b/frontend/src/lib/generated/realtime/RtPingRequestBody.ts @@ -0,0 +1,8 @@ +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface RtPingRequestBody { + id: string | null | number | null | null; + jsonrpc: '2.0'; + method: 'rt.ping'; +} +export type { RtPingRequestBody as default }; diff --git a/frontend/src/lib/generated/realtime/RtPongResponseBody.ts b/frontend/src/lib/generated/realtime/RtPongResponseBody.ts new file mode 100644 index 00000000..0380a3d5 --- /dev/null +++ b/frontend/src/lib/generated/realtime/RtPongResponseBody.ts @@ -0,0 +1,9 @@ +import type RtPongResult from './RtPongResult'; +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface RtPongResponseBody { + id: string | null | number | null | null; + jsonrpc: '2.0'; + result: RtPongResult; +} +export type { RtPongResponseBody as default }; diff --git a/frontend/src/lib/generated/realtime/RtPongResult.ts b/frontend/src/lib/generated/realtime/RtPongResult.ts new file mode 100644 index 00000000..7bb50eed --- /dev/null +++ b/frontend/src/lib/generated/realtime/RtPongResult.ts @@ -0,0 +1,6 @@ +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface RtPongResult { + pong: boolean; +} +export type { RtPongResult as default }; diff --git a/frontend/src/lib/generated/realtime/RtRevokedBody.ts b/frontend/src/lib/generated/realtime/RtRevokedBody.ts new file mode 100644 index 00000000..15c2c177 --- /dev/null +++ b/frontend/src/lib/generated/realtime/RtRevokedBody.ts @@ -0,0 +1,9 @@ +import type RtRevokedParams from './RtRevokedParams'; +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface RtRevokedBody { + jsonrpc: '2.0'; + method: 'rt.revoked'; + params: RtRevokedParams; +} +export type { RtRevokedBody as default }; diff --git a/frontend/src/lib/generated/realtime/RtRevokedParams.ts b/frontend/src/lib/generated/realtime/RtRevokedParams.ts new file mode 100644 index 00000000..fcec1c4a --- /dev/null +++ b/frontend/src/lib/generated/realtime/RtRevokedParams.ts @@ -0,0 +1,8 @@ +import type RtRevokedReason from './RtRevokedReason'; +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface RtRevokedParams { + reason: RtRevokedReason; + topic: string; +} +export type { RtRevokedParams as default }; diff --git a/frontend/src/lib/generated/realtime/RtRevokedReason.ts b/frontend/src/lib/generated/realtime/RtRevokedReason.ts new file mode 100644 index 00000000..555ff99e --- /dev/null +++ b/frontend/src/lib/generated/realtime/RtRevokedReason.ts @@ -0,0 +1,7 @@ +enum RtRevokedReason { + GRANT_REVOKED = 'grant_revoked', + RESOURCE_DELETED = 'resource_deleted', + GROUP_MEMBERSHIP_LOST = 'group_membership_lost', + ADMIN_KICK = 'admin_kick' +} +export type { RtRevokedReason as default }; diff --git a/frontend/src/lib/generated/realtime/RtSubscribeParams.ts b/frontend/src/lib/generated/realtime/RtSubscribeParams.ts new file mode 100644 index 00000000..810d1017 --- /dev/null +++ b/frontend/src/lib/generated/realtime/RtSubscribeParams.ts @@ -0,0 +1,6 @@ +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface RtSubscribeParams { + topic: string; +} +export type { RtSubscribeParams as default }; diff --git a/frontend/src/lib/generated/realtime/RtSubscribeRequestBody.ts b/frontend/src/lib/generated/realtime/RtSubscribeRequestBody.ts new file mode 100644 index 00000000..1d8ef139 --- /dev/null +++ b/frontend/src/lib/generated/realtime/RtSubscribeRequestBody.ts @@ -0,0 +1,10 @@ +import type RtSubscribeParams from './RtSubscribeParams'; +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface RtSubscribeRequestBody { + id: string | null | number | null | null; + jsonrpc: '2.0'; + method: 'rt.subscribe'; + params?: RtSubscribeParams; +} +export type { RtSubscribeRequestBody as default }; diff --git a/frontend/src/lib/generated/realtime/RtSuccessResponseBody.ts b/frontend/src/lib/generated/realtime/RtSuccessResponseBody.ts new file mode 100644 index 00000000..69515b50 --- /dev/null +++ b/frontend/src/lib/generated/realtime/RtSuccessResponseBody.ts @@ -0,0 +1,8 @@ +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface RtSuccessResponseBody { + id: string | null | number | null | null; + jsonrpc: '2.0'; + result: unknown; +} +export type { RtSuccessResponseBody as default }; diff --git a/frontend/src/lib/generated/realtime/RtUnsubscribeParams.ts b/frontend/src/lib/generated/realtime/RtUnsubscribeParams.ts new file mode 100644 index 00000000..c8c83391 --- /dev/null +++ b/frontend/src/lib/generated/realtime/RtUnsubscribeParams.ts @@ -0,0 +1,6 @@ +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface RtUnsubscribeParams { + topic: string; +} +export type { RtUnsubscribeParams as default }; diff --git a/frontend/src/lib/generated/realtime/RtUnsubscribeRequestBody.ts b/frontend/src/lib/generated/realtime/RtUnsubscribeRequestBody.ts new file mode 100644 index 00000000..6e67c2ae --- /dev/null +++ b/frontend/src/lib/generated/realtime/RtUnsubscribeRequestBody.ts @@ -0,0 +1,10 @@ +import type RtUnsubscribeParams from './RtUnsubscribeParams'; +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface RtUnsubscribeRequestBody { + id: string | null | number | null | null; + jsonrpc: '2.0'; + method: 'rt.unsubscribe'; + params?: RtUnsubscribeParams; +} +export type { RtUnsubscribeRequestBody as default }; diff --git a/justfile b/justfile index 39157f38..5ef8b434 100644 --- a/justfile +++ b/justfile @@ -201,6 +201,44 @@ openapi: asyncapi: cargo run --features dev_tools --bin generate-asyncapi +# Regenerate frontend TypeScript DTOs from `resources/gen/asyncapi.json` +# via `@asyncapi/modelina`. Chained to `asyncapi` so the JSON spec is +# always fresh before Modelina consumes it — running one entry point +# with two dependent steps is cheaper cognitively than remembering to +# regenerate the spec first. Cargo incremental keeps the Rust side +# near-instant when nothing changed; Modelina then rewrites the FE +# .ts files (idempotent — same input → same output, CI dirty-tree +# check catches genuine drift). +# +# Output lands in `frontend/src/lib/generated/realtime/`; consumers +# import from there but never edit those files. +asyncapi-ts: asyncapi + cd frontend && npm run gen:realtime + +# Local mirror of the `realtime-spec-drift` CI job. Regenerates both +# artefacts and fails if the committed files differ from the fresh +# generator output. Included in `pre-pull-request` so developers +# catch drift BEFORE pushing — the CI job is belt-and-braces, not the +# only defence. +# +# Depends on `asyncapi-ts` which itself depends on `asyncapi`, so the +# whole chain runs; then we assert on `git diff --exit-code` over +# the two paths we care about. +check-realtime-spec: asyncapi-ts + #!/usr/bin/env bash + set -euo pipefail + if ! git diff --exit-code \ + resources/gen/asyncapi.json \ + frontend/src/lib/generated/realtime/; then + echo "" + echo "❌ realtime spec drift: committed files differ from the fresh" + echo " generator output. Fix:" + echo " git add resources/gen/asyncapi.json frontend/src/lib/generated/realtime/" + echo " git commit -m 'chore(rt): regenerate spec + DTOs'" + exit 1 + fi + echo "✅ realtime spec: committed files match generator output" + db: docker compose up -d postgres @@ -328,16 +366,25 @@ test-caldav: fe-install: cd frontend && npm ci -# Vite dev server only (HMR) — backend must already be running on :8086 -fe-dev: +# Vite dev server only (HMR) — backend must already be running on :8086. +# `asyncapi-ts` prereq runs once at start; Vite's watcher picks up +# any subsequent regenerations for HMR. +fe-dev: asyncapi-ts cd frontend && npm run dev -# build the SPA (Phase 0: -> frontend/build; Phase 5: -> static-dist) -fe-build: +# build the SPA (Phase 0: -> frontend/build; Phase 5: -> static-dist). +# `asyncapi-ts` prerequisite (which itself depends on `asyncapi`) +# guarantees `frontend/src/lib/generated/realtime/*.ts` is in sync +# with the Rust-side wire spec before Vite compiles — no stale-DTO +# window in local dev. CI still runs a dirty-tree check on the +# generated files as belt-and-braces. +fe-build: asyncapi-ts cd frontend && npm run build # Build the SPA with e2e instrumentation for the Playwright coverage -# suite. Both env vars are load-bearing: +# suite. Same asyncapi-ts prereq as `fe-build` — the E2E build must +# see the same generated DTOs the release build sees. Both env vars +# are load-bearing: # * VITE_E2E=1 — keeps the `data-testid` tile hooks the release # build strips, so `page.getByTestId(filename)` and # the drop-zone / preferences selectors work. @@ -348,15 +395,19 @@ fe-build: # report empty. # Called automatically by `front-test`; run manually if you're # invoking Playwright directly. -fe-build-e2e: +fe-build-e2e: asyncapi-ts cd frontend && COVERAGE=1 VITE_E2E=1 npm run build -# svelte-check + eslint + stylelint + prettier -fe-check: +# svelte-check + eslint + stylelint + prettier. Depends on +# `asyncapi-ts` so svelte-check sees current generated types (a stale +# import would surface as a TS error at check time — better to +# regenerate first than chase phantom errors). +fe-check: asyncapi-ts cd frontend && npm run check -# Vitest unit/component tests -fe-test: +# Vitest unit/component tests. Same asyncapi-ts prereq — tests that +# import from `lib/generated/realtime` need it fresh. +fe-test: asyncapi-ts cd frontend && npm run test:unit # Run backend (API) and the Vite dev server together; one Ctrl-C stops both. @@ -396,4 +447,4 @@ test-docker-tags: # Check and test everything # recommanded before pull request -pre-pull-request: test-docker-tags check fe-check audit check-migrations test test-integration fe-test build test-bundle test-api fe-build-e2e front-test +pre-pull-request: test-docker-tags check fe-check audit check-migrations check-realtime-spec test test-integration fe-test build test-bundle test-api fe-build-e2e front-test diff --git a/src/bin/generate-asyncapi.rs b/src/bin/generate-asyncapi.rs index 8824e4ef..4db396a3 100644 --- a/src/bin/generate-asyncapi.rs +++ b/src/bin/generate-asyncapi.rs @@ -198,7 +198,7 @@ fn operations() -> Value { } fn components() -> Value { - json!({ + let mut components = json!({ "messages": { // ── Requests ──────────────────────────────────────────── "RtSubscribeRequest": { @@ -253,14 +253,35 @@ fn components() -> Value { } }, "schemas": { - "RtSubscribeRequestBody": rpc_request_schema("rt.subscribe", topic_params_schema()), - "RtUnsubscribeRequestBody": rpc_request_schema("rt.unsubscribe", topic_params_schema()), - "RtPingRequestBody": rpc_request_schema("rt.ping", json!({ "type": "null" })), + // Top-level JSON-RPC frame bodies. + "RtSubscribeRequestBody": rpc_request_schema("rt.subscribe", Some(ref_schema("RtSubscribeParams"))), + "RtUnsubscribeRequestBody": rpc_request_schema("rt.unsubscribe", Some(ref_schema("RtUnsubscribeParams"))), + "RtPingRequestBody": rpc_request_schema("rt.ping", None), "RtSuccessResponseBody": rpc_success_response_schema(), "RtPongResponseBody": rpc_pong_response_schema(), "RtErrorResponseBody": rpc_error_response_schema(), "RtFolderEventBody": folder_event_notification_schema(), "RtRevokedBody": revoked_notification_schema(), + + // Hoisted nested schemas — pulled out from inline `params`, + // inner `error`, `result`, and enum arrays so Modelina (and + // any other spec-driven codegen) gets real names instead of + // `AnonymousSchema_N`. Keep names in sync with the shape: + // renaming here silently breaks the generated FE types, so + // the CI dirty-tree check catches drift. + "RtSubscribeParams": topic_params_schema(), + "RtUnsubscribeParams": topic_params_schema(), + "RtEventParams": event_params_schema(), + "RtEventDataUnion": event_data_union_schema(), + "RtEventKind": event_kind_schema(), + "RtRevokedParams": revoked_params_schema(), + "RtRevokedReason": revoked_reason_schema(), + "RtErrorObject": rpc_error_object_schema(), + "RtErrorCode": rpc_error_code_schema(), + "RtErrorMessage": rpc_error_message_schema(), + "RtPongResult": rpc_pong_result_schema(), + + // Per-event data payloads (one per `event` discriminator). "FileCreatedData": file_created_schema(), "FileRenamedData": file_renamed_schema(), "FileMovedData": file_moved_schema(), @@ -281,21 +302,74 @@ fn components() -> Value { "description": "OxiCloud JWT — same access_token minted by `POST /api/auth/login` (or the OPAQUE handshake). Programmatic clients set `Authorization: Bearer ` on the WS upgrade request. Browsers, which cannot set headers on `new WebSocket()`, will use the deferred ticket flow (`POST /api/rt/ticket` → short-lived one-shot ticket in the WS URL); see the plan's DPoP-gap section.", } } - }) + }); + + // Close every top-level object schema in components.schemas — + // the Rust wire (`serde` on named struct fields) never emits + // extras, so `additionalProperties: false` is honest, and it + // removes the `additionalProperties?: Record` + // escape-hatch field Modelina would otherwise generate on every + // TS interface. One-shot post-process instead of 19 individual + // `"additionalProperties": false` lines sprinkled through the + // schema builders. + // + // Deliberately NOT recursive: we only close the named top-level + // schemas. Recursing into `properties` closes anonymous inline + // sub-objects, which then triggers Modelina to name them (and + // fail our AnonymousSchema guard). If a nested object needs a + // real name AND `additionalProperties: false`, hoist it explicitly + // to `components.schemas` and reference via `$ref`. + if let Some(schemas) = components.get_mut("schemas").and_then(Value::as_object_mut) { + for schema in schemas.values_mut() { + close_object_schema_shallow(schema); + } + } + + components +} + +/// Add `additionalProperties: false` to a top-level object schema if +/// it declares `type: "object"` and doesn't already set the field. +/// Non-object schemas (`enum`, `oneOf`, `type: "integer"`, string +/// types, etc.) are untouched. Never descends — see `components()`. +fn close_object_schema_shallow(schema: &mut Value) { + let Value::Object(map) = schema else { return }; + let is_object = matches!(map.get("type"), Some(Value::String(s)) if s == "object"); + if is_object && !map.contains_key("additionalProperties") { + map.insert("additionalProperties".to_string(), Value::Bool(false)); + } } // ─── Schema builders ──────────────────────────────────────────────────────── -fn rpc_request_schema(method: &str, params_schema: Value) -> Value { +/// `$ref` shorthand — every hoisted inline schema below is referenced +/// through this so consumers of the spec (Modelina, AsyncAPI Studio, any +/// SDK generator) see named types instead of `AnonymousSchema_N`. +fn ref_schema(name: &str) -> Value { + json!({ "$ref": format!("#/components/schemas/{name}") }) +} + +/// JSON-RPC 2.0 request envelope. `params_schema` is `Some(...)` for +/// methods that take arguments (`rt.subscribe`, `rt.unsubscribe`) and +/// `None` for methods that don't (`rt.ping`). Omitting `params` from +/// the properties entirely — rather than declaring it as +/// `{"type": "null"}` — keeps Modelina from emitting `params?: any` +/// on the generated TS: no property in the schema → no property in +/// the interface, which is what JSON-RPC 2.0 allows anyway (`params` +/// is optional per spec). +fn rpc_request_schema(method: &str, params_schema: Option) -> Value { + let mut properties = json!({ + "jsonrpc": { "type": "string", "const": "2.0" }, + "id": { "type": ["integer", "string", "null"] }, + "method": { "type": "string", "const": method }, + }); + if let Some(params) = params_schema { + properties["params"] = params; + } json!({ "type": "object", "required": ["jsonrpc", "id", "method"], - "properties": { - "jsonrpc": { "type": "string", "const": "2.0" }, - "id": { "type": ["integer", "string", "null"] }, - "method": { "type": "string", "const": method }, - "params": params_schema, - } + "properties": properties, }) } @@ -320,13 +394,24 @@ fn rpc_success_response_schema() -> Value { "properties": { "jsonrpc": { "type": "string", "const": "2.0" }, "id": { "type": ["integer", "string", "null"] }, - "result": { "type": "object" }, + // Generic base shape — every specific method has its own + // typed result schema (RtPongResult, subscribed ack, etc.). + // Declaring every JSON type explicitly nudges Modelina + // toward a real union rather than the bare `any` it emits + // for a purely descriptive schema — matches the JSON-RPC + // spec's "any JSON value" phrasing while giving downstream + // codegens something to project. + "result": { + "description": "Method-specific result payload. See the concrete response schema for each `method`.", + "type": ["object", "array", "string", "number", "integer", "boolean", "null"], + }, } }) } /// Reply to `rt.ping` — the shape pins `result.pong == true` so -/// contract tests can assert on it directly. +/// contract tests can assert on it directly. `result` is hoisted to +/// [`RtPongResult`] so Modelina gets a named type. fn rpc_pong_response_schema() -> Value { json!({ "type": "object", @@ -334,13 +419,17 @@ fn rpc_pong_response_schema() -> Value { "properties": { "jsonrpc": { "type": "string", "const": "2.0" }, "id": { "type": ["integer", "string", "null"] }, - "result": { - "type": "object", - "required": ["pong"], - "properties": { - "pong": { "type": "boolean", "const": true } - } - }, + "result": ref_schema("RtPongResult"), + } + }) +} + +fn rpc_pong_result_schema() -> Value { + json!({ + "type": "object", + "required": ["pong"], + "properties": { + "pong": { "type": "boolean", "const": true } } }) } @@ -348,91 +437,126 @@ fn rpc_pong_response_schema() -> Value { fn rpc_error_response_schema() -> Value { // The `code`/`message` catalog is the stable public vocabulary — // any change here IS a wire break. Every entry mirrors - // `application/ports/realtime_ports.rs::error_code`. + // `application/ports/realtime_ports.rs::error_code`. The inner + // error object is hoisted to `RtErrorObject` so Modelina emits a + // named type instead of `AnonymousSchema_N`. json!({ "type": "object", "required": ["jsonrpc", "id", "error"], "properties": { "jsonrpc": { "type": "string", "const": "2.0" }, "id": { "type": ["integer", "string", "null"] }, - "error": { - "type": "object", - "required": ["code", "message"], - "properties": { - "code": { - "type": "integer", - "enum": [ - error_code::NO_READ, - error_code::NO_SHARE, - error_code::NO_COMMENT, - error_code::TOPIC_FORBIDDEN, - error_code::SUB_LIMIT, - error_code::RATE_LIMITED, - error_code::NO_EDIT, - error_code::INTERNAL_ERROR, - error_code::INVALID_REQUEST, - error_code::METHOD_NOT_FOUND, - error_code::INVALID_PARAMS, - ], - }, - "message": { - "type": "string", - "description": "Stable wire vocabulary; matches the `code`.", - "enum": [ - "no_read", "no_share", "no_comment", "topic_forbidden", - "sub_limit", "rate_limited", "no_edit", - "internal_error", "invalid_request", - "method_not_found", "invalid_params", - ], - }, - "data": { - "type": "object", - "description": "Optional caller-facing context (e.g. offending topic).", - } - } + "error": ref_schema("RtErrorObject"), + } + }) +} + +fn rpc_error_object_schema() -> Value { + json!({ + "type": "object", + "description": "JSON-RPC 2.0 error object. `code` + `message` form a stable pair; `data` optionally carries caller-visible context (e.g. offending topic).", + "required": ["code", "message"], + "properties": { + "code": ref_schema("RtErrorCode"), + "message": ref_schema("RtErrorMessage"), + // Per JSON-RPC 2.0: "A Primitive or Structured value that + // contains additional information about the error." The + // union covers every JSON type so Modelina emits a real + // TS union rather than a bare `any`. Client MUST check + // `code` before assuming `data`'s shape. + "data": { + "description": "Optional caller-facing context; shape depends on the specific `code`.", + "type": ["object", "array", "string", "number", "integer", "boolean", "null"], } } }) } +fn rpc_error_code_schema() -> Value { + json!({ + "type": "integer", + "description": "Stable integer error code. Values are frozen across releases — a new denial cause gets a new value, never repurposes an existing one.", + "enum": [ + error_code::NO_READ, + error_code::NO_SHARE, + error_code::NO_COMMENT, + error_code::TOPIC_FORBIDDEN, + error_code::SUB_LIMIT, + error_code::RATE_LIMITED, + error_code::NO_EDIT, + error_code::INTERNAL_ERROR, + error_code::INVALID_REQUEST, + error_code::METHOD_NOT_FOUND, + error_code::INVALID_PARAMS, + ], + }) +} + +fn rpc_error_message_schema() -> Value { + json!({ + "type": "string", + "description": "Stable wire vocabulary; matches the corresponding `code`.", + "enum": [ + "no_read", "no_share", "no_comment", "topic_forbidden", + "sub_limit", "rate_limited", "no_edit", + "internal_error", "invalid_request", + "method_not_found", "invalid_params", + ], + }) +} + fn folder_event_notification_schema() -> Value { json!({ "type": "object", - "description": "JSON-RPC notification (no `id`). `method = \"rt.event\"`.", + "description": "JSON-RPC notification (no `id`). `method = \"rt.event\"`. `params` is hoisted to `RtEventParams`.", "required": ["jsonrpc", "method", "params"], "properties": { "jsonrpc": { "type": "string", "const": "2.0" }, "method": { "type": "string", "const": "rt.event" }, - "params": { - "type": "object", - "required": ["topic", "event", "data"], - "properties": { - "topic": { "type": "string" }, - "event": { - "type": "string", - "enum": [ - "file_created", "file_renamed", "file_moved", "file_deleted", - "folder_created", "folder_renamed", "folder_moved", "folder_deleted", - ], - }, - "data": { - "oneOf": [ - { "$ref": "#/components/schemas/FileCreatedData" }, - { "$ref": "#/components/schemas/FileRenamedData" }, - { "$ref": "#/components/schemas/FileMovedData" }, - { "$ref": "#/components/schemas/FileDeletedData" }, - { "$ref": "#/components/schemas/FolderCreatedData" }, - { "$ref": "#/components/schemas/FolderRenamedData" }, - { "$ref": "#/components/schemas/FolderMovedData" }, - { "$ref": "#/components/schemas/FolderDeletedData" }, - ] - } - } - } + "params": ref_schema("RtEventParams"), } }) } +fn event_params_schema() -> Value { + json!({ + "type": "object", + "required": ["topic", "event", "data"], + "properties": { + "topic": { "type": "string" }, + "event": ref_schema("RtEventKind"), + "data": ref_schema("RtEventDataUnion"), + } + }) +} + +fn event_kind_schema() -> Value { + json!({ + "type": "string", + "description": "Discriminator for the `data` payload. Mirrors the `#[serde(tag = \"event\", rename_all = \"snake_case\")]` variants of the Rust `RealtimeEvent` enum — a new event kind is a new enum variant on both sides.", + "enum": [ + "file_created", "file_renamed", "file_moved", "file_deleted", + "folder_created", "folder_renamed", "folder_moved", "folder_deleted", + ], + }) +} + +fn event_data_union_schema() -> Value { + json!({ + "description": "Tagged union of every possible `rt.event` payload. Discriminated by the sibling `event` field (see `RtEventKind`).", + "oneOf": [ + ref_schema("FileCreatedData"), + ref_schema("FileRenamedData"), + ref_schema("FileMovedData"), + ref_schema("FileDeletedData"), + ref_schema("FolderCreatedData"), + ref_schema("FolderRenamedData"), + ref_schema("FolderMovedData"), + ref_schema("FolderDeletedData"), + ] + }) +} + fn file_created_schema() -> Value { json!({ "type": "object", @@ -551,27 +675,36 @@ fn folder_deleted_schema() -> Value { fn revoked_notification_schema() -> Value { json!({ "type": "object", - "description": "JSON-RPC notification (no `id`). `method = \"rt.revoked\"`.", + "description": "JSON-RPC notification (no `id`). `method = \"rt.revoked\"`. `params` hoisted to `RtRevokedParams`.", "required": ["jsonrpc", "method", "params"], "properties": { "jsonrpc": { "type": "string", "const": "2.0" }, "method": { "type": "string", "const": "rt.revoked" }, - "params": { - "type": "object", - "required": ["topic", "reason"], - "properties": { - "topic": { "type": "string" }, - "reason": { - "type": "string", - "enum": [ - "grant_revoked", - "resource_deleted", - "group_membership_lost", - "admin_kick", - ] - } - } - } + "params": ref_schema("RtRevokedParams"), } }) } + +fn revoked_params_schema() -> Value { + json!({ + "type": "object", + "required": ["topic", "reason"], + "properties": { + "topic": { "type": "string" }, + "reason": ref_schema("RtRevokedReason"), + } + }) +} + +fn revoked_reason_schema() -> Value { + json!({ + "type": "string", + "description": "Server-side eviction cause. Stable vocabulary; a new eviction reason is a new enum value.", + "enum": [ + "grant_revoked", + "resource_deleted", + "group_membership_lost", + "admin_kick", + ] + }) +} From 7918fff47bb04e1b1fb477639ccf06c625ec4eae Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 11 Sep 2026 00:25:25 +0200 Subject: [PATCH 08/21] refactor(msg-bus): prefer MessageBus as Realtime --- .github/workflows/ci.yml | 30 ++++---- Cargo.toml | 4 +- docs/config/env.md | 2 +- docs/plan/message-bus.md | 75 ++++++++++--------- frontend/AGENTS.md | 67 +++++++++++++++++ frontend/package.json | 2 +- ...me-types.mjs => gen-message-bus-types.mjs} | 20 ++--- .../FileCreatedData.ts | 0 .../FileDeletedData.ts | 0 .../FileMovedData.ts | 0 .../FileRenamedData.ts | 0 .../{realtime => message-bus}/Folder.ts | 0 .../FolderCreatedData.ts | 0 .../FolderDeletedData.ts | 0 .../FolderMovedData.ts | 0 .../FolderRenamedData.ts | 0 .../{realtime => message-bus}/RtErrorCode.ts | 0 .../RtErrorMessage.ts | 0 .../RtErrorObject.ts | 0 .../RtErrorResponseBody.ts | 0 .../{realtime => message-bus}/RtEventKind.ts | 0 .../RtEventParams.ts | 0 .../RtFolderEventBody.ts | 0 .../RtPingRequestBody.ts | 0 .../RtPongResponseBody.ts | 0 .../{realtime => message-bus}/RtPongResult.ts | 0 .../RtRevokedBody.ts | 0 .../RtRevokedParams.ts | 0 .../RtRevokedReason.ts | 0 .../RtSubscribeParams.ts | 0 .../RtSubscribeRequestBody.ts | 0 .../RtSuccessResponseBody.ts | 0 .../RtUnsubscribeParams.ts | 0 .../RtUnsubscribeRequestBody.ts | 0 justfile | 36 ++++----- ...realtime_ports.rs => message_bus_ports.rs} | 64 ++++++++-------- src/application/ports/mod.rs | 2 +- .../services/file_management_service.rs | 30 ++++---- .../services/file_upload_service.rs | 16 ++-- src/application/services/folder_service.rs | 32 ++++---- src/bin/generate-asyncapi.rs | 14 ++-- src/bin/opaque-hurl-helper.rs | 2 +- src/bin/rt-hurl-helper.rs | 2 +- src/common/di.rs | 30 ++++---- ...ltime_bus.rs => in_process_message_bus.rs} | 40 +++++----- src/infrastructure/services/mod.rs | 2 +- src/interfaces/api/handlers/grant_handler.rs | 8 +- src/interfaces/api/handlers/rt_ws.rs | 32 ++++---- src/interfaces/api/routes.rs | 2 +- tests/api/rt_bus_check.sh | 4 +- tests/api/run.sh | 6 +- 51 files changed, 295 insertions(+), 227 deletions(-) rename frontend/scripts/{gen-realtime-types.mjs => gen-message-bus-types.mjs} (91%) rename frontend/src/lib/generated/{realtime => message-bus}/FileCreatedData.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/FileDeletedData.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/FileMovedData.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/FileRenamedData.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/Folder.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/FolderCreatedData.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/FolderDeletedData.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/FolderMovedData.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/FolderRenamedData.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/RtErrorCode.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/RtErrorMessage.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/RtErrorObject.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/RtErrorResponseBody.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/RtEventKind.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/RtEventParams.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/RtFolderEventBody.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/RtPingRequestBody.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/RtPongResponseBody.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/RtPongResult.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/RtRevokedBody.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/RtRevokedParams.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/RtRevokedReason.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/RtSubscribeParams.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/RtSubscribeRequestBody.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/RtSuccessResponseBody.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/RtUnsubscribeParams.ts (100%) rename frontend/src/lib/generated/{realtime => message-bus}/RtUnsubscribeRequestBody.ts (100%) rename src/application/ports/{realtime_ports.rs => message_bus_ports.rs} (93%) rename src/infrastructure/services/{in_process_realtime_bus.rs => in_process_message_bus.rs} (91%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a925ef9e..af9dcc55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: wasm: ${{ steps.filter.outputs.wasm }} plugins: ${{ steps.filter.outputs.plugins }} migrations: ${{ steps.filter.outputs.migrations }} - realtime_spec: ${{ steps.filter.outputs.realtime_spec }} + message_bus_spec: ${{ steps.filter.outputs.message_bus_spec }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 @@ -53,12 +53,12 @@ jobs: - 'src/application/adapters/plugin_user_lifecycle_hook.rs' migrations: - 'migrations/**' - realtime_spec: - - 'src/application/ports/realtime_ports.rs' + message_bus_spec: + - 'src/application/ports/message_bus_ports.rs' - 'src/bin/generate-asyncapi.rs' - 'resources/gen/asyncapi.json' - - 'frontend/scripts/gen-realtime-types.mjs' - - 'frontend/src/lib/generated/realtime/**' + - 'frontend/scripts/gen-message-bus-types.mjs' + - 'frontend/src/lib/generated/message-bus/**' - 'frontend/package.json' frontend-check: @@ -92,18 +92,18 @@ jobs: # scratch, then fails the PR if either output drifts from what was # committed. Same discipline as the OpenAPI + wasm-fixture approach # elsewhere in this file — the wire spec is a compile-time artefact - # of the Rust source (`realtime_ports.rs`), and the TS DTOs are a - # compile-time artefact of the spec, so both must be reproducible. + # of the Rust source (`message_bus_ports.rs`), and the TS DTOs are + # a compile-time artefact of the spec, so both must be reproducible. # - # Scoped by the `realtime_spec` path filter so a PR that doesn't + # Scoped by the `message_bus_spec` path filter so a PR that doesn't # touch the wire (or its generator scripts, or the Modelina version) # skips this job entirely. Needs BOTH Rust and Node toolchains, so # it's slightly heavier than a single-toolchain job — the filter # keeps it off the hot path. - realtime-spec-drift: - name: Realtime spec — AsyncAPI + TypeScript DTO drift + message-bus-spec-drift: + name: Message-bus spec — AsyncAPI + TypeScript DTO drift needs: changes - if: needs.changes.outputs.realtime_spec == 'true' + if: needs.changes.outputs.message_bus_spec == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -112,7 +112,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 # Node for `just asyncapi-ts` — Modelina projects the spec into - # the FE `src/lib/generated/realtime/` folder. + # the FE `src/lib/generated/message-bus/` folder. - name: Setup Node uses: actions/setup-node@v4 with: @@ -127,7 +127,7 @@ jobs: run: cargo run --features dev_tools --bin generate-asyncapi - name: Regenerate TS DTOs (Modelina) working-directory: frontend - run: npm run gen:realtime + run: npm run gen:message-bus - name: Fail if committed files drifted # A non-empty diff means a contributor edited the Rust wire # source (or Modelina config) without regenerating, or hand- @@ -136,9 +136,9 @@ jobs: run: | if ! git diff --exit-code \ resources/gen/asyncapi.json \ - frontend/src/lib/generated/realtime/; then + frontend/src/lib/generated/message-bus/; then echo "" - echo "::error::Realtime spec drift: the committed files differ from what the" + echo "::error::Message-bus spec drift: the committed files differ from what the" echo "::error::generator produces from source. Run \`just asyncapi-ts\` locally" echo "::error::and commit the result — that recipe re-runs both stages." exit 1 diff --git a/Cargo.toml b/Cargo.toml index 049f814e..ecf09c4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -271,7 +271,7 @@ name = "generate-asyncapi" path = "src/bin/generate-asyncapi.rs" # Dev-only: regenerates `resources/gen/asyncapi.json` — the WS surface's # analogue of openapi.json. Constructed from the same `error_code` -# constants + `Topic`/`RealtimeEvent` shapes the server uses, so the +# constants + `Topic`/`MessageBusEvent` shapes the server uses, so the # spec stays in sync with the implementation by construction. Same # gating as `generate-openapi`. Invoked by `just asyncapi`. required-features = ["dev_tools"] @@ -309,7 +309,7 @@ required-features = ["load_seed_bin"] [[bin]] name = "rt-hurl-helper" path = "src/bin/rt-hurl-helper.rs" -# Test-suite WebSocket client for the realtime message bus. Hurl is +# Test-suite WebSocket client for the message bus. Hurl is # HTTP-only and cannot drive a WS handshake or read frames; this bin # supplies the two modes the smoke test needs — `subscribe-and-collect` # (background subscriber that captures events to JSON) and diff --git a/docs/config/env.md b/docs/config/env.md index 7ca660da..d9b4c385 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -355,7 +355,7 @@ Today's shipped locales: `ar, de, en, es, fa, fr, hi, it, ja, ko, nl, pl, pt, ru Example: `OXICLOUD_TRUST_PROXY_CIDR=127.0.0.1/32,10.0.0.0/8,172.16.0.0/12` -## Realtime WebSocket +## Message bus WebSocket | Variable | Default | Description | |---|---|---| diff --git a/docs/plan/message-bus.md b/docs/plan/message-bus.md index aab0be0f..c58250ae 100644 --- a/docs/plan/message-bus.md +++ b/docs/plan/message-bus.md @@ -1,4 +1,4 @@ -# Plan — Realtime message bus over WebSocket +# Plan — Message bus over WebSocket ## Context @@ -10,7 +10,7 @@ notifications, sync-client push invalidation — and it makes existing surfaces feel dated compared to Google Drive, Notion, Nextcloud, and M365. -This plan introduces a single realtime bus over WebSocket that any +This plan introduces a single message bus over WebSocket that any service can publish facts to and any client can subscribe to. Collab editing is one consumer on top; folder-live updates, notifications, job progress, presence, and sync-client push invalidation follow with @@ -41,13 +41,13 @@ almost no extra scaffolding. │ CollabSessionService.apply() ─────────────────▶ bus.publish(...) │ │ │ └───────────────────────────────┬──────────────────────────────────────┘ - │ publish(&Topic, RealtimeEvent) + │ publish(&Topic, MessageBusEvent) ▼ ┌──────────────────────────────────────────────────────────────────────┐ -│ REALTIME BUS (RealtimeBus trait — application/ports) │ +│ MESSAGE BUS (MessageBus trait — application/ports) │ │ │ -│ InProcessRealtimeBus (v1) │ -│ DashMap> │ +│ InProcessMessageBus (v1) │ +│ DashMap> │ │ │ └──────┬───────────────────────────────────────────────────────────────┘ │ @@ -59,7 +59,7 @@ almost no extra scaffolding. │ │ - v2: PgListenReplicator (pg_notify) │ │ │ - v3: BrokerReplicator (RabbitMQ / NATS) │ │ │ │ - │ │ Sits BESIDE InProcessRealtimeBus, forwards │ + │ │ Sits BESIDE InProcessMessageBus, forwards │ │ │ local publishes outbound + inbound events │ │ │ from the broker back into local publish. │ │ └──────────────────────────────────────────────────────┘ @@ -67,7 +67,7 @@ almost no extra scaffolding. ┌──────────────────────────────────────────────────────────────────────┐ │ WS HANDLER (interfaces/api/handlers/rt_ws.rs) │ │ │ -│ One RealtimeSession per WS: HashSet + outbound mpsc │ +│ One BusSession per WS: HashSet + outbound mpsc │ │ - subscribe/unsubscribe frames → bus.subscribe(topic) │ │ - each subscribed stream drains into the outbound mpsc │ │ - AuthZ at subscribe (once), evict on grant-revoked │ @@ -77,12 +77,12 @@ almost no extra scaffolding. **The seam that keeps RabbitMQ/NATS doors open is the replicator, not the bus.** Services and the WS handler only ever see the local -`RealtimeBus`. A future `BrokerReplicator` publishes outbound + injects +`MessageBus`. A future `BrokerReplicator` publishes outbound + injects inbound. Zero touch to callers. ## Backend components -### 1. Port + event types (`application/ports/realtime_ports.rs`) +### 1. Port + event types (`application/ports/message_bus_ports.rs`) ```rust // Topic is a typed enum, not a string. Prevents typos, gives @@ -127,7 +127,7 @@ pub enum PrincipalRef { #[derive(Serialize, Deserialize)] #[serde(tag = "event", rename_all = "snake_case")] -pub enum RealtimeEvent { +pub enum MessageBusEvent { // Folder / File verbs — thin facts only, client refetches details. FileCreated { file_id: FileId, name: String, parent_id: FolderId, actor: UserId }, FileDeleted { file_id: FileId, parent_id: FolderId, actor: UserId }, @@ -168,21 +168,21 @@ pub enum RealtimeEvent { } #[async_trait] -pub trait RealtimeBus: Send + Sync { +pub trait MessageBus: Send + Sync { /// Fire-and-forget. SYNC (not async) — services must not await /// under a DB transaction. - fn publish(&self, topic: &Topic, event: RealtimeEvent); + fn publish(&self, topic: &Topic, event: MessageBusEvent); /// Returns a Stream so the impl can change (broadcast, mpsc, /// pg listener) without churn. - fn subscribe(&self, topic: &Topic) -> Pin + Send>>; + fn subscribe(&self, topic: &Topic) -> Pin + Send>>; } -/// Kept SEPARATE from RealtimeBus so v2/v3 wiring is drop-in. +/// Kept SEPARATE from MessageBus so v2/v3 wiring is drop-in. #[async_trait] pub trait BusReplicator: Send + Sync { /// Called whenever the local bus publishes; may forward to broker. - fn on_local_publish(&self, topic: &Topic, event: &RealtimeEvent); + fn on_local_publish(&self, topic: &Topic, event: &MessageBusEvent); /// Long-running consumer task: reads remote messages and /// re-publishes locally. Started by DI, returns on shutdown. @@ -203,7 +203,7 @@ paths**: | Each affected user (persistent "shared with you") | `user:{member}:notifications` (one publish per member) | Becomes a `notif.notifications` row via `NotificationService::create` | The bus **never expands groups**. `NotificationService` is the -group-expansion boundary. `RealtimeBus` only fans out topics that +group-expansion boundary. `MessageBus` only fans out topics that already exist as concrete `user:*` streams. Post-commit sequence for `ShareService::grant(file=F, principal=Group(G), role=R)`: @@ -262,9 +262,9 @@ Coalescing: `NotificationService::create` de-dupes on window. Alice in both `G1` and `G2`, both granted `F`, gets one notification, not two. -### 2. In-process impl (`infrastructure/services/in_process_realtime_bus.rs`) +### 2. In-process impl (`infrastructure/services/in_process_message_bus.rs`) -- `DashMap>`, capacity 256 per topic. +- `DashMap>`, capacity 256 per topic. - `subscribe` creates the entry lazily; wraps `Receiver` in `BroadcastStream` (converts `Lagged` into a stream-level marker; WS handler kills that session with a `revoked` frame, reason @@ -275,7 +275,7 @@ notification, not two. ### 3. Replicator scaffolding (day-1) - `NoopReplicator` in v1. Wired in DI as `Arc`. -- `InProcessRealtimeBus::publish` calls +- `InProcessMessageBus::publish` calls `replicator.on_local_publish(...)` **after** local fan-out. Futures: @@ -313,7 +313,7 @@ Futures: - Extract `caller_id` from the auth mechanism above. - Auto-subscribe to `user:{caller}:notifications`, `user:{caller}:authz`, `user:{caller}:sessions`. - - Spawn `RealtimeSession` actor: owns `HashSet`, outbound + - Spawn `BusSession` actor: owns `HashSet`, outbound `mpsc::Sender` (bounded 512), one reader task per subscribed topic. - Per-frame: @@ -324,7 +324,7 @@ Futures: admin bypass), role-scoped (`caller.role == Admin`); plus the bespoke job-originator-or-admin check for `job:{id}`. Full matrix in **§ AuthZ model**. Deny → `denied` frame + audit - `event = "realtime.subscribe_denied"`. Allow → subscribe on bus, + `event = "message_bus.subscribe_denied"`. Allow → subscribe on bus, ack. - `unsubscribe`: drop the reader task for that topic. - `ping/pong` for keepalive. @@ -345,14 +345,14 @@ never before, never inside**. If publish were inside the tx, a rollback would still fan out to clients. If publish were async and awaited, a slow subscriber could hold the tx open. -Pattern: services return `(result, Vec)` from the tx +Pattern: services return `(result, Vec)` from the tx boundary; the calling layer publishes after commit. Or a `TxCommitHook` queues events and flushes on commit. Pick one, apply everywhere. ## Frontend components -### 1. Singleton client (`lib/stores/realtime.svelte.ts`) +### 1. Singleton client (`lib/stores/message-bus.svelte.ts`) - Fetches a ticket via `POST /api/rt/ticket` (through `apiFetch`, so DPoP is applied). @@ -390,9 +390,10 @@ Two wire formats share the same WS connection: - **CRDT binary frames: Yjs sync protocol** — de-facto standard in the Yjs ecosystem, kept as-is because it's the reason we picked Yjs. -Method namespace for our JSON-RPC methods: `rt.*` (short for -realtime). Prevents collisions if we ever expose additional RPCs on -the same WS (not planned, but the namespace costs nothing). +Method namespace for our JSON-RPC methods: `rt.*` — a short opaque +prefix reserved for message-bus methods. Prevents collisions if we +ever expose additional RPCs on the same WS (not planned, but the +namespace costs nothing). ### JSON-RPC frames (control + events) @@ -511,7 +512,7 @@ implementation by construction — no hand-written spec that drifts. - **Message schemas** — the JSON-RPC envelope and one schema per `event` variant (`file_created`, `folder_created`, `share_granted`, `notification`, …). Generated via `schemars` from - the same Rust `RealtimeEvent` enum the server publishes, so the + the same Rust `MessageBusEvent` enum the server publishes, so the schema is authoritative, not aspirational. - **Error object shape + `code`/`message` catalog** — the JSON-RPC error table above becomes an AsyncAPI-declared `errors` block on @@ -529,8 +530,8 @@ implementation by construction — no hand-written spec that drifts. Follows the same shape as `generate-openapi`: - New binary `src/bin/generate_asyncapi.rs` that constructs the - spec from `Topic`, `RealtimeEvent`, `AuthzCheck`, and the JSON-RPC - method/error tables — all live in `application/ports/realtime_ports.rs` + spec from `Topic`, `MessageBusEvent`, `AuthzCheck`, and the JSON-RPC + method/error tables — all live in `application/ports/message_bus_ports.rs` as the single source of truth. - Uses `schemars` for JSON Schema of each event variant (already compatible with `serde` derives; no re-annotation needed). @@ -618,8 +619,8 @@ it once, avoid hand-maintaining a growing catalog of message types. stays; only the message DTOs come from codegen. - **Wiring:** - `frontend/package.json` dev-dep: `@asyncapi/modelina`. - - Script `frontend/scripts/gen-realtime-types.mjs` invokes Modelina, - writes to `frontend/src/lib/generated/realtime/`. + - Script `frontend/scripts/gen-message-bus-types.mjs` invokes Modelina, + writes to `frontend/src/lib/generated/message-bus/`. - `just asyncapi-ts` recipe alongside `just asyncapi`. - CI dirty-tree check — regenerate on every build, fail if `git diff` on the generated folder is non-empty. Same discipline as @@ -747,10 +748,10 @@ confirms the anti-enumeration collapse rules the wire honours. - **Connect reject** — `event = "auth.rt_ticket_rejected"`, `reason ∈ {expired, unknown, ip_mismatch, replay}`. -- **Subscribe deny** — `event = "realtime.subscribe_denied"`, `reason` +- **Subscribe deny** — `event = "message_bus.subscribe_denied"`, `reason` from the audit column above, plus `caller_id`, `topic`. Emitted BEFORE the wire `denied` frame. -- **Evict** — `event = "realtime.subscription_evicted"`, +- **Evict** — `event = "message_bus.subscription_evicted"`, `reason ∈ {grant_revoked, resource_deleted, admin_kick, group_membership_lost}`, plus `caller_id`, `topic`. - **Collab edit rejected** — `event = "collab.write_denied"`, @@ -796,7 +797,7 @@ larger (notifications table, presence, collab) rides on top later. ### Scope in -- `RealtimeBus` port + `InProcessRealtimeBus`. +- `MessageBus` port + `InProcessMessageBus`. - WS handler at `GET /api/rt/ws` with `subscribe` / `unsubscribe` / `ping` frames only (no CRDT binary frames yet). - Auth: reuse existing `auth_middleware` — session cookie for @@ -954,7 +955,7 @@ subscribe to but didn't. Verifies: a user without `Read` on a folder cannot subscribe to its topic. Denial wire reason is `no_read`; audit line records -`realtime.subscribe_denied` with `reason ∈ {no_read, +`message_bus.subscribe_denied` with `reason ∈ {no_read, no_such_resource}`. ``` @@ -1046,7 +1047,7 @@ this baseline once the baseline is green. Ships the infrastructure and the two most visible consumers together. -- Bus port + `InProcessRealtimeBus` + `NoopReplicator` + WS handler +- Bus port + `InProcessMessageBus` + `NoopReplicator` + WS handler + ticket endpoint. - Frontend singleton + `useTopic` composable. - Topics live: `folder:{id}`, `user:{u}:notifications`, `job:{id}`, diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 4ff903a0..f972b1f0 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -9,3 +9,70 @@ Prefix `oxi-`, kebab-case separators. Example: `oxi-view-mode`. Enforced by `$lib/utils/localStoragePrefs::wipeAppKeys()` which sweeps every `oxi-*` key on user-account switches — any other prefix leaks the previous user's state into the new one. + +## Logging — `loglevel` with `oxi:*` namespaces + +**Never use bare `console.debug/info/warn/error` in `$lib` or route code.** +Route through the shared [`loglevel`](https://github.com/pimterry/loglevel) +logger so users and support can dial verbosity per subsystem from the +browser console without a redeploy. + +```ts +import log from 'loglevel'; + +const bus = log.getLogger('oxi:message-bus'); +bus.debug('subscribed', { topic }); +bus.warn('reconnect scheduled', { attempt, backoffMs }); +bus.error('unexpected frame', { raw }); +``` + +Convention: + +- **Namespace = `oxi:`** in kebab-case. One namespace per + subsystem/module boundary — e.g. `oxi:upload` (delta + direct + uploader), `oxi:message-bus` (WS client + `useTopic`). Do not create + finer-grained per-file namespaces; users tune subsystems, not files. +- **Level is user-controlled** via the DevTools helper installed in + `src/hooks.client.ts`: + ```js + oxi.setLogLevel('oxi:message-bus', 'debug'); + oxi.listLogLevels(); + ``` + Choices persist to `localStorage['loglevel:']`. Default is + loglevel's `warn` — production stays quiet unless the user opts in. +- **Add every new namespace to the DevTools comment block** in + `hooks.client.ts` (the `Log levels — namespaces used today: …` line) + so users have a discoverable list. +- **No `console.log` at all** — Stylelint/ESLint don't flag it, but the + codebase convention does. `console.error` is only acceptable in + boot-time paths (`hooks.client.ts`, generator scripts, worker + bootstraps) where the shared logger isn't reachable yet. +- **Workers can't `import log` from a static path** — see + `lib/api/endpoints/deltaUpload.ts`: the worker `postMessage`s a + `{type: 'log', level, msg, extra}` envelope and the main thread relays + it through the shared logger. Mirror this pattern for any new worker. + +## Message bus naming + +The realtime channel is the **message bus** everywhere — backend port +`MessageBus`, plan doc `docs/plan/message-bus.md`, generated DTOs under +`$lib/generated/message-bus/`, FE store/composables named accordingly. +Only two things keep the older `rt`/`Rt` shorthand, and both for wire- +protocol reasons: + +- **JSON-RPC method prefix** — `rt.subscribe`, `rt.event`, `rt.revoked`, + `rt.ping`, `rt.error`. The prefix is opaque wire vocabulary and does + not have to expand to "realtime"; treat it as a short namespace tag + reserved for message-bus methods. +- **Generated type names** — `RtSubscribeParams`, `RtEventBody`, etc. + Modelina keys off the AsyncAPI schema names, which mirror the JSON-RPC + method names. + +When adding FE code around the bus, use `message-bus` in file names, +store names, and logger namespaces: + +- Store: `$lib/stores/message-bus.svelte.ts` +- Composables: `$lib/composables/useTopic.svelte.ts` (topic-generic — no + bus name in the file) +- Logger namespace: `oxi:message-bus` +- localStorage keys (if any): `oxi-message-bus-*` diff --git a/frontend/package.json b/frontend/package.json index 4515fee8..1cf4c204 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,7 +18,7 @@ "test:unit": "LANG=C vitest run", "test:unit:watch": "LANG=C vitest", "test:unit:coverage": "rm -rf ../tests/e2e/.nyc_output_unit && LANG=C COVERAGE=1 vitest run", - "gen:realtime": "node scripts/gen-realtime-types.mjs" + "gen:message-bus": "node scripts/gen-message-bus-types.mjs" }, "devDependencies": { "@asyncapi/modelina": "^5.5.0", diff --git a/frontend/scripts/gen-realtime-types.mjs b/frontend/scripts/gen-message-bus-types.mjs similarity index 91% rename from frontend/scripts/gen-realtime-types.mjs rename to frontend/scripts/gen-message-bus-types.mjs index 468c7cf5..1d763aa9 100644 --- a/frontend/scripts/gen-realtime-types.mjs +++ b/frontend/scripts/gen-message-bus-types.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Realtime bus — TypeScript DTOs generated from `resources/gen/asyncapi.json`. +// Message bus — TypeScript DTOs generated from `resources/gen/asyncapi.json`. // // Sits on the same axis as `resources/gen/openapi.json`: the wire spec // (authored by `cargo run --features dev_tools --bin generate-asyncapi`) @@ -7,14 +7,14 @@ // interfaces so `lib/composables/useTopic.ts` and every folder-view // switch statement is compile-time exhaustive over the `rt.event` variants. // -// Regenerate: `just asyncapi-ts` (or `npm run gen:realtime`). +// Regenerate: `just asyncapi-ts` (or `npm run gen:message-bus`). // CI is expected to run the same command and fail if the working tree is // dirty afterwards — same discipline `just openapi` follows. // // Design notes: // * `modelType: 'interface'` — plain records, not classes-with-getters. // Matches the FE codebase style (see `lib/api/types.ts`). -// * Output goes to `src/lib/generated/realtime/` — a directory reserved +// * Output goes to `src/lib/generated/message-bus/` — a directory reserved // for auto-generated files. Never hand-edit anything inside. // * Every file gets a `AUTO-GENERATED` banner via a preset so a stray // edit is obvious at review time. @@ -32,13 +32,13 @@ import { TypeScriptFileGenerator } from '@asyncapi/modelina'; const execFile = promisify(execFileCb); // Anchor everything on this script's location so `just asyncapi-ts` from -// the repo root and `npm run gen:realtime` from the frontend both work. +// the repo root and `npm run gen:message-bus` from the frontend both work. const __dirname = dirname(fileURLToPath(import.meta.url)); const frontendRoot = resolve(__dirname, '..'); const repoRoot = resolve(frontendRoot, '..'); const specPath = resolve(repoRoot, 'resources/gen/asyncapi.json'); -const outputDir = resolve(frontendRoot, 'src/lib/generated/realtime'); +const outputDir = resolve(frontendRoot, 'src/lib/generated/message-bus'); // Load the spec. Failing here means the wire spec hasn't been generated // yet — hint the operator at the right command. @@ -47,7 +47,7 @@ try { spec = JSON.parse(await readFile(specPath, 'utf8')); } catch (err) { console.error( - `gen-realtime-types: cannot read ${specPath}: ${err.message}\n` + + `gen-message-bus-types: cannot read ${specPath}: ${err.message}\n` + `\nDid you run \`just asyncapi\` first? The Rust generator writes\n` + `resources/gen/asyncapi.json; this script consumes it.` ); @@ -77,7 +77,7 @@ const generator = new TypeScriptFileGenerator({ const banner = '// AUTO-GENERATED — do not edit by hand.\n' + '// Regenerate with `just asyncapi-ts` (which runs\n' + - '// `node frontend/scripts/gen-realtime-types.mjs`).\n' + + '// `node frontend/scripts/gen-message-bus-types.mjs`).\n' + '// Source of truth: resources/gen/asyncapi.json,\n' + '// authored by the Rust `generate-asyncapi` binary.\n'; return `${banner}${content}`; @@ -171,7 +171,7 @@ for (const f of files) { const anonymous = files.filter((f) => f.endsWith('.ts') && /^AnonymousSchema_/i.test(f)); if (anonymous.length > 0) { console.error( - `gen-realtime-types: FAIL — Modelina produced ${anonymous.length} ` + + `gen-message-bus-types: FAIL — Modelina produced ${anonymous.length} ` + `AnonymousSchema_N file(s):` ); for (const f of anonymous) console.error(` - ${f}`); @@ -198,7 +198,7 @@ try { }); } catch (err) { console.error( - `gen-realtime-types: prettier --write failed: ${err.message}\n` + + `gen-message-bus-types: prettier --write failed: ${err.message}\n` + `The generated files may still be usable but will fail\n` + `\`npm run check\` on the prettier step. Fix prettier setup\n` + `(is @prettier installed in frontend/node_modules?) then\n` + @@ -208,7 +208,7 @@ try { } console.log( - `gen-realtime-types: wrote ${models.length} model(s) to ${outputDir}` + + `gen-message-bus-types: wrote ${models.length} model(s) to ${outputDir}` + ` (rewrote ${rewritten} for verbatimModuleSyntax, 0 AnonymousSchema,` + ` prettier-formatted)` ); diff --git a/frontend/src/lib/generated/realtime/FileCreatedData.ts b/frontend/src/lib/generated/message-bus/FileCreatedData.ts similarity index 100% rename from frontend/src/lib/generated/realtime/FileCreatedData.ts rename to frontend/src/lib/generated/message-bus/FileCreatedData.ts diff --git a/frontend/src/lib/generated/realtime/FileDeletedData.ts b/frontend/src/lib/generated/message-bus/FileDeletedData.ts similarity index 100% rename from frontend/src/lib/generated/realtime/FileDeletedData.ts rename to frontend/src/lib/generated/message-bus/FileDeletedData.ts diff --git a/frontend/src/lib/generated/realtime/FileMovedData.ts b/frontend/src/lib/generated/message-bus/FileMovedData.ts similarity index 100% rename from frontend/src/lib/generated/realtime/FileMovedData.ts rename to frontend/src/lib/generated/message-bus/FileMovedData.ts diff --git a/frontend/src/lib/generated/realtime/FileRenamedData.ts b/frontend/src/lib/generated/message-bus/FileRenamedData.ts similarity index 100% rename from frontend/src/lib/generated/realtime/FileRenamedData.ts rename to frontend/src/lib/generated/message-bus/FileRenamedData.ts diff --git a/frontend/src/lib/generated/realtime/Folder.ts b/frontend/src/lib/generated/message-bus/Folder.ts similarity index 100% rename from frontend/src/lib/generated/realtime/Folder.ts rename to frontend/src/lib/generated/message-bus/Folder.ts diff --git a/frontend/src/lib/generated/realtime/FolderCreatedData.ts b/frontend/src/lib/generated/message-bus/FolderCreatedData.ts similarity index 100% rename from frontend/src/lib/generated/realtime/FolderCreatedData.ts rename to frontend/src/lib/generated/message-bus/FolderCreatedData.ts diff --git a/frontend/src/lib/generated/realtime/FolderDeletedData.ts b/frontend/src/lib/generated/message-bus/FolderDeletedData.ts similarity index 100% rename from frontend/src/lib/generated/realtime/FolderDeletedData.ts rename to frontend/src/lib/generated/message-bus/FolderDeletedData.ts diff --git a/frontend/src/lib/generated/realtime/FolderMovedData.ts b/frontend/src/lib/generated/message-bus/FolderMovedData.ts similarity index 100% rename from frontend/src/lib/generated/realtime/FolderMovedData.ts rename to frontend/src/lib/generated/message-bus/FolderMovedData.ts diff --git a/frontend/src/lib/generated/realtime/FolderRenamedData.ts b/frontend/src/lib/generated/message-bus/FolderRenamedData.ts similarity index 100% rename from frontend/src/lib/generated/realtime/FolderRenamedData.ts rename to frontend/src/lib/generated/message-bus/FolderRenamedData.ts diff --git a/frontend/src/lib/generated/realtime/RtErrorCode.ts b/frontend/src/lib/generated/message-bus/RtErrorCode.ts similarity index 100% rename from frontend/src/lib/generated/realtime/RtErrorCode.ts rename to frontend/src/lib/generated/message-bus/RtErrorCode.ts diff --git a/frontend/src/lib/generated/realtime/RtErrorMessage.ts b/frontend/src/lib/generated/message-bus/RtErrorMessage.ts similarity index 100% rename from frontend/src/lib/generated/realtime/RtErrorMessage.ts rename to frontend/src/lib/generated/message-bus/RtErrorMessage.ts diff --git a/frontend/src/lib/generated/realtime/RtErrorObject.ts b/frontend/src/lib/generated/message-bus/RtErrorObject.ts similarity index 100% rename from frontend/src/lib/generated/realtime/RtErrorObject.ts rename to frontend/src/lib/generated/message-bus/RtErrorObject.ts diff --git a/frontend/src/lib/generated/realtime/RtErrorResponseBody.ts b/frontend/src/lib/generated/message-bus/RtErrorResponseBody.ts similarity index 100% rename from frontend/src/lib/generated/realtime/RtErrorResponseBody.ts rename to frontend/src/lib/generated/message-bus/RtErrorResponseBody.ts diff --git a/frontend/src/lib/generated/realtime/RtEventKind.ts b/frontend/src/lib/generated/message-bus/RtEventKind.ts similarity index 100% rename from frontend/src/lib/generated/realtime/RtEventKind.ts rename to frontend/src/lib/generated/message-bus/RtEventKind.ts diff --git a/frontend/src/lib/generated/realtime/RtEventParams.ts b/frontend/src/lib/generated/message-bus/RtEventParams.ts similarity index 100% rename from frontend/src/lib/generated/realtime/RtEventParams.ts rename to frontend/src/lib/generated/message-bus/RtEventParams.ts diff --git a/frontend/src/lib/generated/realtime/RtFolderEventBody.ts b/frontend/src/lib/generated/message-bus/RtFolderEventBody.ts similarity index 100% rename from frontend/src/lib/generated/realtime/RtFolderEventBody.ts rename to frontend/src/lib/generated/message-bus/RtFolderEventBody.ts diff --git a/frontend/src/lib/generated/realtime/RtPingRequestBody.ts b/frontend/src/lib/generated/message-bus/RtPingRequestBody.ts similarity index 100% rename from frontend/src/lib/generated/realtime/RtPingRequestBody.ts rename to frontend/src/lib/generated/message-bus/RtPingRequestBody.ts diff --git a/frontend/src/lib/generated/realtime/RtPongResponseBody.ts b/frontend/src/lib/generated/message-bus/RtPongResponseBody.ts similarity index 100% rename from frontend/src/lib/generated/realtime/RtPongResponseBody.ts rename to frontend/src/lib/generated/message-bus/RtPongResponseBody.ts diff --git a/frontend/src/lib/generated/realtime/RtPongResult.ts b/frontend/src/lib/generated/message-bus/RtPongResult.ts similarity index 100% rename from frontend/src/lib/generated/realtime/RtPongResult.ts rename to frontend/src/lib/generated/message-bus/RtPongResult.ts diff --git a/frontend/src/lib/generated/realtime/RtRevokedBody.ts b/frontend/src/lib/generated/message-bus/RtRevokedBody.ts similarity index 100% rename from frontend/src/lib/generated/realtime/RtRevokedBody.ts rename to frontend/src/lib/generated/message-bus/RtRevokedBody.ts diff --git a/frontend/src/lib/generated/realtime/RtRevokedParams.ts b/frontend/src/lib/generated/message-bus/RtRevokedParams.ts similarity index 100% rename from frontend/src/lib/generated/realtime/RtRevokedParams.ts rename to frontend/src/lib/generated/message-bus/RtRevokedParams.ts diff --git a/frontend/src/lib/generated/realtime/RtRevokedReason.ts b/frontend/src/lib/generated/message-bus/RtRevokedReason.ts similarity index 100% rename from frontend/src/lib/generated/realtime/RtRevokedReason.ts rename to frontend/src/lib/generated/message-bus/RtRevokedReason.ts diff --git a/frontend/src/lib/generated/realtime/RtSubscribeParams.ts b/frontend/src/lib/generated/message-bus/RtSubscribeParams.ts similarity index 100% rename from frontend/src/lib/generated/realtime/RtSubscribeParams.ts rename to frontend/src/lib/generated/message-bus/RtSubscribeParams.ts diff --git a/frontend/src/lib/generated/realtime/RtSubscribeRequestBody.ts b/frontend/src/lib/generated/message-bus/RtSubscribeRequestBody.ts similarity index 100% rename from frontend/src/lib/generated/realtime/RtSubscribeRequestBody.ts rename to frontend/src/lib/generated/message-bus/RtSubscribeRequestBody.ts diff --git a/frontend/src/lib/generated/realtime/RtSuccessResponseBody.ts b/frontend/src/lib/generated/message-bus/RtSuccessResponseBody.ts similarity index 100% rename from frontend/src/lib/generated/realtime/RtSuccessResponseBody.ts rename to frontend/src/lib/generated/message-bus/RtSuccessResponseBody.ts diff --git a/frontend/src/lib/generated/realtime/RtUnsubscribeParams.ts b/frontend/src/lib/generated/message-bus/RtUnsubscribeParams.ts similarity index 100% rename from frontend/src/lib/generated/realtime/RtUnsubscribeParams.ts rename to frontend/src/lib/generated/message-bus/RtUnsubscribeParams.ts diff --git a/frontend/src/lib/generated/realtime/RtUnsubscribeRequestBody.ts b/frontend/src/lib/generated/message-bus/RtUnsubscribeRequestBody.ts similarity index 100% rename from frontend/src/lib/generated/realtime/RtUnsubscribeRequestBody.ts rename to frontend/src/lib/generated/message-bus/RtUnsubscribeRequestBody.ts diff --git a/justfile b/justfile index 5ef8b434..1608d376 100644 --- a/justfile +++ b/justfile @@ -195,8 +195,8 @@ openapi: cargo run --features dev_tools --bin generate-openapi # Regenerate `resources/gen/asyncapi.json` — the WS surface's spec, -# analogue of openapi.json. Built from the `Topic`, `RealtimeEvent`, -# and `error_code` constants in `application/ports/realtime_ports.rs` +# analogue of openapi.json. Built from the `Topic`, `MessageBusEvent`, +# and `error_code` constants in `application/ports/message_bus_ports.rs` # so the spec stays in sync with the wire by construction. asyncapi: cargo run --features dev_tools --bin generate-asyncapi @@ -210,34 +210,34 @@ asyncapi: # .ts files (idempotent — same input → same output, CI dirty-tree # check catches genuine drift). # -# Output lands in `frontend/src/lib/generated/realtime/`; consumers +# Output lands in `frontend/src/lib/generated/message-bus/`; consumers # import from there but never edit those files. asyncapi-ts: asyncapi - cd frontend && npm run gen:realtime + cd frontend && npm run gen:message-bus -# Local mirror of the `realtime-spec-drift` CI job. Regenerates both -# artefacts and fails if the committed files differ from the fresh -# generator output. Included in `pre-pull-request` so developers -# catch drift BEFORE pushing — the CI job is belt-and-braces, not the -# only defence. +# Local mirror of the `message-bus-spec-drift` CI job. Regenerates +# both artefacts and fails if the committed files differ from the +# fresh generator output. Included in `pre-pull-request` so +# developers catch drift BEFORE pushing — the CI job is +# belt-and-braces, not the only defence. # # Depends on `asyncapi-ts` which itself depends on `asyncapi`, so the # whole chain runs; then we assert on `git diff --exit-code` over # the two paths we care about. -check-realtime-spec: asyncapi-ts +check-message-bus-spec: asyncapi-ts #!/usr/bin/env bash set -euo pipefail if ! git diff --exit-code \ resources/gen/asyncapi.json \ - frontend/src/lib/generated/realtime/; then + frontend/src/lib/generated/message-bus/; then echo "" - echo "❌ realtime spec drift: committed files differ from the fresh" + echo "❌ message-bus spec drift: committed files differ from the fresh" echo " generator output. Fix:" - echo " git add resources/gen/asyncapi.json frontend/src/lib/generated/realtime/" - echo " git commit -m 'chore(rt): regenerate spec + DTOs'" + echo " git add resources/gen/asyncapi.json frontend/src/lib/generated/message-bus/" + echo " git commit -m 'chore(bus): regenerate spec + DTOs'" exit 1 fi - echo "✅ realtime spec: committed files match generator output" + echo "✅ message-bus spec: committed files match generator output" db: docker compose up -d postgres @@ -374,7 +374,7 @@ fe-dev: asyncapi-ts # build the SPA (Phase 0: -> frontend/build; Phase 5: -> static-dist). # `asyncapi-ts` prerequisite (which itself depends on `asyncapi`) -# guarantees `frontend/src/lib/generated/realtime/*.ts` is in sync +# guarantees `frontend/src/lib/generated/message-bus/*.ts` is in sync # with the Rust-side wire spec before Vite compiles — no stale-DTO # window in local dev. CI still runs a dirty-tree check on the # generated files as belt-and-braces. @@ -406,7 +406,7 @@ fe-check: asyncapi-ts cd frontend && npm run check # Vitest unit/component tests. Same asyncapi-ts prereq — tests that -# import from `lib/generated/realtime` need it fresh. +# import from `lib/generated/message-bus` need it fresh. fe-test: asyncapi-ts cd frontend && npm run test:unit @@ -447,4 +447,4 @@ test-docker-tags: # Check and test everything # recommanded before pull request -pre-pull-request: test-docker-tags check fe-check audit check-migrations check-realtime-spec test test-integration fe-test build test-bundle test-api fe-build-e2e front-test +pre-pull-request: test-docker-tags check fe-check audit check-migrations check-message-bus-spec test test-integration fe-test build test-bundle test-api fe-build-e2e front-test diff --git a/src/application/ports/realtime_ports.rs b/src/application/ports/message_bus_ports.rs similarity index 93% rename from src/application/ports/realtime_ports.rs rename to src/application/ports/message_bus_ports.rs index 3e0e2c39..ecdfe6ff 100644 --- a/src/application/ports/realtime_ports.rs +++ b/src/application/ports/message_bus_ports.rs @@ -1,13 +1,13 @@ -//! Realtime message-bus port — the seam every service publishes through and -//! every WS session subscribes on. +//! Message-bus port — the seam every service publishes through and every WS +//! session subscribes on. //! //! # Design (see `docs/plan/message-bus.md`) //! -//! - [`RealtimeBus`] is the **local-facing** trait: services publish, the WS +//! - [`MessageBus`] is the **local-facing** trait: services publish, the WS //! handler subscribes. It never involves the network. //! - [`BusReplicator`] is the OPTIONAL seam that mirrors local publishes to //! and from a broker (pg `LISTEN/NOTIFY`, RabbitMQ, NATS). Callers see only -//! [`RealtimeBus`]; a real replicator plugs into the in-process impl without +//! [`MessageBus`]; a real replicator plugs into the in-process impl without //! touching consumers. Day-1 impl is [`NoopReplicator`]. //! //! # MVP scope @@ -17,10 +17,10 @@ //! to folders the caller can't `Read`: //! //! - Topics: [`Topic::Folder`] and [`Topic::UserAuthz`] -//! - Events: [`RealtimeEvent::FileCreated`], [`RealtimeEvent::FileRenamed`], -//! [`RealtimeEvent::FileMoved`], [`RealtimeEvent::FileDeleted`], -//! [`RealtimeEvent::FolderCreated`], [`RealtimeEvent::FolderRenamed`], -//! [`RealtimeEvent::FolderMoved`], [`RealtimeEvent::FolderDeleted`] +//! - Events: [`MessageBusEvent::FileCreated`], [`MessageBusEvent::FileRenamed`], +//! [`MessageBusEvent::FileMoved`], [`MessageBusEvent::FileDeleted`], +//! [`MessageBusEvent::FolderCreated`], [`MessageBusEvent::FolderRenamed`], +//! [`MessageBusEvent::FolderMoved`], [`MessageBusEvent::FolderDeleted`] //! //! Adding a variant is a one-line change plus a match arm in `to_wire_key` / //! `parse` / `required_perm`. Other topics (`file:{id}`, `job:{id}`, @@ -47,7 +47,7 @@ use crate::common::errors::DomainError; // Topic — a typed key on the bus // ════════════════════════════════════════════════════════════════════════════ -/// A topic on the realtime bus. Typed enum, not a string — prevents typos +/// A topic on the message bus. Typed enum, not a string — prevents typos /// and gives exhaustive matching in the AuthZ dispatch and the wire encoder. /// /// Encodes to a stable dotted wire key that maps naturally onto RabbitMQ @@ -157,7 +157,7 @@ pub enum AuthzCheck { } // ════════════════════════════════════════════════════════════════════════════ -// RealtimeEvent — the payload +// MessageBusEvent — the payload // ════════════════════════════════════════════════════════════════════════════ /// A fact that has just become true. Emitted by services AFTER commit, @@ -176,7 +176,7 @@ pub enum AuthzCheck { /// per project convention. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "event", rename_all = "snake_case")] -pub enum RealtimeEvent { +pub enum MessageBusEvent { /// A file was created inside `parent_id`. FileCreated { file_id: Uuid, @@ -315,7 +315,7 @@ pub mod error_code { } // ════════════════════════════════════════════════════════════════════════════ -// RealtimeBus — the port +// MessageBus — the port // ════════════════════════════════════════════════════════════════════════════ /// The local-facing message bus. Fire-and-forget publish, stream subscribe. @@ -326,11 +326,11 @@ pub mod error_code { /// /// `subscribe` returns a `Stream` so the impl can change (broadcast, mpsc, /// pg listener) without churn at the consumer. -pub trait RealtimeBus: Send + Sync + 'static { +pub trait MessageBus: Send + Sync + 'static { /// Fan an event out to every current subscriber of `topic`. Never /// blocks; slow subscribers are dropped by the impl (they'll reconnect /// and refetch). - fn publish(&self, topic: &Topic, event: RealtimeEvent); + fn publish(&self, topic: &Topic, event: MessageBusEvent); /// Subscribe to `topic`. The returned stream yields events until the /// subscriber is dropped or the impl kicks it out (e.g. for lagging @@ -338,15 +338,15 @@ pub trait RealtimeBus: Send + Sync + 'static { fn subscribe(&self, topic: &Topic) -> BusStream; } -/// Boxed stream returned by [`RealtimeBus::subscribe`]. Aliased so +/// Boxed stream returned by [`MessageBus::subscribe`]. Aliased so /// consumers don't need to spell out the `Pin>` shape. -pub type BusStream = Pin + Send>>; +pub type BusStream = Pin + Send>>; // ════════════════════════════════════════════════════════════════════════════ // BusReplicator — the multi-instance seam (day-1 noop) // ════════════════════════════════════════════════════════════════════════════ -/// Cross-instance replicator. Sits BESIDE [`RealtimeBus`], not in front of +/// Cross-instance replicator. Sits BESIDE [`MessageBus`], not in front of /// it — the bus does the local fan-out; the replicator forwards outbound /// publishes to the broker (pg NOTIFY, RabbitMQ, NATS) and injects inbound /// broker messages back into the local bus. @@ -358,7 +358,7 @@ pub trait BusReplicator: Send + Sync + 'static { /// Called by the local bus for every publish. Fire-and-forget — must not /// block or await; forwarding to the broker happens on a background task /// owned by the impl. - fn on_local_publish(&self, topic: &Topic, event: &RealtimeEvent); + fn on_local_publish(&self, topic: &Topic, event: &MessageBusEvent); /// Long-running consumer task: reads remote messages and re-publishes /// locally. Returns when `shutdown` is notified — DI calls @@ -382,7 +382,7 @@ pub struct NoopReplicator; #[async_trait::async_trait] impl BusReplicator for NoopReplicator { - fn on_local_publish(&self, _topic: &Topic, _event: &RealtimeEvent) { + fn on_local_publish(&self, _topic: &Topic, _event: &MessageBusEvent) { // Intentionally empty. Local fan-out already happened in the bus. } @@ -466,9 +466,9 @@ mod tests { // every variant's discriminator with a snapshot so an accidental // rename fails the test instead of silently breaking clients — // the AsyncAPI spec's `event` enum mirrors these exact strings. - let cases: &[(RealtimeEvent, &str)] = &[ + let cases: &[(MessageBusEvent, &str)] = &[ ( - RealtimeEvent::FileCreated { + MessageBusEvent::FileCreated { file_id: Uuid::nil(), name: "notes.md".into(), parent_id: Uuid::nil(), @@ -477,7 +477,7 @@ mod tests { "file_created", ), ( - RealtimeEvent::FileRenamed { + MessageBusEvent::FileRenamed { file_id: Uuid::nil(), old_name: "a.md".into(), new_name: "b.md".into(), @@ -487,7 +487,7 @@ mod tests { "file_renamed", ), ( - RealtimeEvent::FileMoved { + MessageBusEvent::FileMoved { file_id: Uuid::nil(), name: "a.md".into(), from: Uuid::nil(), @@ -497,7 +497,7 @@ mod tests { "file_moved", ), ( - RealtimeEvent::FileDeleted { + MessageBusEvent::FileDeleted { file_id: Uuid::nil(), parent_id: Uuid::nil(), actor: Uuid::nil(), @@ -505,7 +505,7 @@ mod tests { "file_deleted", ), ( - RealtimeEvent::FolderCreated { + MessageBusEvent::FolderCreated { folder_id: Uuid::nil(), name: "docs".into(), parent_id: Uuid::nil(), @@ -514,7 +514,7 @@ mod tests { "folder_created", ), ( - RealtimeEvent::FolderRenamed { + MessageBusEvent::FolderRenamed { folder_id: Uuid::nil(), old_name: "old".into(), new_name: "new".into(), @@ -524,7 +524,7 @@ mod tests { "folder_renamed", ), ( - RealtimeEvent::FolderMoved { + MessageBusEvent::FolderMoved { folder_id: Uuid::nil(), name: "docs".into(), from: Uuid::nil(), @@ -534,7 +534,7 @@ mod tests { "folder_moved", ), ( - RealtimeEvent::FolderDeleted { + MessageBusEvent::FolderDeleted { folder_id: Uuid::nil(), parent_id: Uuid::nil(), actor: Uuid::nil(), @@ -542,7 +542,7 @@ mod tests { "folder_deleted", ), ( - RealtimeEvent::AuthzChanged { + MessageBusEvent::AuthzChanged { affected_folders: vec![Uuid::nil()], }, "authz_changed", @@ -562,14 +562,14 @@ mod tests { let file_id = Uuid::new_v4(); let parent_id = Uuid::new_v4(); let actor = Uuid::new_v4(); - let original = RealtimeEvent::FileCreated { + let original = MessageBusEvent::FileCreated { file_id, name: "a.txt".into(), parent_id, actor, }; let json = serde_json::to_string(&original).unwrap(); - let decoded: RealtimeEvent = serde_json::from_str(&json).unwrap(); + let decoded: MessageBusEvent = serde_json::from_str(&json).unwrap(); assert_eq!(decoded, original); } @@ -618,7 +618,7 @@ mod tests { // on_local_publish is a no-op that should not panic or spawn work. repl.on_local_publish( &Topic::Folder(Uuid::nil()), - &RealtimeEvent::FileCreated { + &MessageBusEvent::FileCreated { file_id: Uuid::nil(), name: "x".into(), parent_id: Uuid::nil(), diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index a246d661..3a2496c7 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -18,11 +18,11 @@ pub mod file_lifecycle; pub mod file_ports; pub mod folder_ports; pub mod inbound; +pub mod message_bus_ports; pub mod music_ports; pub mod opaque_ports; pub mod outbound; pub mod plugin_ports; -pub mod realtime_ports; pub mod recent_ports; pub mod resource_access_hook; pub mod share_ports; diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index cdad81bf..b004e294 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -57,12 +57,12 @@ pub struct FileManagementService { /// (stub/test builders); production DI wires it in. storage_usage: Option>, - /// Realtime message bus. When wired, delete / rename / move - /// mutations publish their corresponding `RealtimeEvent` on + /// Message bus. When wired, delete / rename / move + /// mutations publish their corresponding `MessageBusEvent` on /// `Topic::Folder(parent_id)` (both source AND destination for /// move) after the DB commit. `None` silently no-ops the publish /// path — same pattern as `bus` on FileUploadService. - bus: Option>, + bus: Option>, /// Read repository — needed by the mutation publish path /// (delete / rename / move) to snapshot the file's pre-mutation /// parent folder BEFORE the write commits: delete removes the row, @@ -102,11 +102,11 @@ impl FileManagementService { } } - /// Wire the realtime message bus. When set, delete / rename / move + /// Wire the message bus. When set, delete / rename / move /// mutations publish on the affected folder topics after commit. - pub fn with_realtime_bus( + pub fn with_message_bus( mut self, - bus: Arc, + bus: Arc, ) -> Self { self.bus = Some(bus); self @@ -208,7 +208,7 @@ impl FileManagementService { } /// Snapshot the (uuid, name, parent-folder-uuid) of a file BEFORE - /// a mutation, so the realtime publish path has a stable + /// a mutation, so the message-bus publish path has a stable /// `Topic::Folder(parent)` to address even after the write commits /// (delete removes the row; move rewrites `folder_id`). /// @@ -239,10 +239,10 @@ impl FileManagementService { /// file, mount, unwired `file_read`). fn publish_file_deleted(&self, caller_id: Uuid, snapshot: Option<(Uuid, String, Uuid)>) { if let (Some(bus), Some((file_uuid, _name, parent_uuid))) = (&self.bus, snapshot) { - use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; + use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic}; bus.publish( &Topic::Folder(parent_uuid), - RealtimeEvent::FileDeleted { + MessageBusEvent::FileDeleted { file_id: file_uuid, parent_id: parent_uuid, actor: caller_id, @@ -539,7 +539,7 @@ impl FileManagementUseCase for FileManagementService { let dto = self.move_file(file_id, folder_id, caller_id).await?; - // Realtime fan-out on BOTH source and destination folder + // Bus fan-out on BOTH source and destination folder // topics. Subscribers to the source see the file "gone" from // their view; subscribers to the destination see it "appear". // Silent no-op when the bus isn't wired, the source snapshot @@ -552,8 +552,8 @@ impl FileManagementUseCase for FileManagementService { && let Ok(dest_uuid) = Uuid::parse_str(dest_str) && source_uuid != dest_uuid { - use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; - let event = RealtimeEvent::FileMoved { + use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic}; + let event = MessageBusEvent::FileMoved { file_id: file_uuid, name, from: source_uuid, @@ -674,7 +674,7 @@ impl FileManagementUseCase for FileManagementService { let dto = self.rename_file(file_id, new_name, caller_id).await?; - // Realtime publish AFTER commit. Silent no-op when the bus + // Bus publish AFTER commit. Silent no-op when the bus // isn't wired, the pre-fetch failed (old_name = None), or the // file has no folder (`dto.folder_id = None` — drive-root). if let (Some(bus), Some(old_name), Some(parent_str)) = @@ -682,10 +682,10 @@ impl FileManagementUseCase for FileManagementService { && let (Ok(file_uuid), Ok(parent_uuid)) = (Uuid::parse_str(&dto.id), Uuid::parse_str(parent_str)) { - use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; + use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic}; bus.publish( &Topic::Folder(parent_uuid), - RealtimeEvent::FileRenamed { + MessageBusEvent::FileRenamed { file_id: file_uuid, old_name, new_name: dto.name.clone(), diff --git a/src/application/services/file_upload_service.rs b/src/application/services/file_upload_service.rs index 1a10258d..1195f73e 100644 --- a/src/application/services/file_upload_service.rs +++ b/src/application/services/file_upload_service.rs @@ -56,13 +56,13 @@ pub struct FileUploadService { /// (`create_file_from_owned_blob_with_perms`); `None` in minimal test /// wiring. instant_upload: Option, - /// Realtime message bus. When wired, `upload_file_streaming` + /// Message bus. When wired, `upload_file_streaming` /// publishes a `FileCreated` event on `Topic::Folder(parent_id)` /// after the DB commit — subscribers see the new file appear in /// their live folder view. Optional so stub / test factories can /// build the service without a bus; a `None` bus is a silent no-op /// on the publish path. - bus: Option>, + bus: Option>, } /// Everything the instant-upload path needs beyond the upload service's own @@ -117,13 +117,13 @@ impl FileUploadService { self } - /// Wire the realtime message bus. Enables live folder-view updates: + /// Wire the message bus. Enables live folder-view updates: /// after `upload_file_streaming` commits, a `FileCreated` event /// fires on `Topic::Folder(parent_id)` — subscribers see the new /// file appear without polling. - pub fn with_realtime_bus( + pub fn with_message_bus( mut self, - bus: Arc, + bus: Arc, ) -> Self { self.bus = Some(bus); self @@ -476,7 +476,7 @@ impl FileUploadUseCase for FileUploadService { // "I just uploaded X" UX matches the pre-SvelteKit behaviour. self.notify_file_accessed(caller_id, &dto.id); - // Realtime fan-out AFTER commit — subscribers to the parent + // Bus fan-out AFTER commit — subscribers to the parent // folder's topic see the new file appear live. Silent no-op if // the bus isn't wired (stubs / tests) or the file landed at // drive-root (no folder id → nothing to publish on). @@ -484,10 +484,10 @@ impl FileUploadUseCase for FileUploadService { && let (Ok(parent_uuid), Ok(file_uuid)) = (Uuid::parse_str(parent_folder_id), Uuid::parse_str(&dto.id)) { - use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; + use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic}; bus.publish( &Topic::Folder(parent_uuid), - RealtimeEvent::FileCreated { + MessageBusEvent::FileCreated { file_id: file_uuid, name: dto.name.clone(), parent_id: parent_uuid, diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index e52723e8..93c4bca2 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -49,13 +49,13 @@ pub struct FolderService { /// on cross-drive MOVE. Silently skipped when unwired (stubs). storage_usage: Option>, - /// Realtime message bus. When wired, `create_folder_with_perms` + /// Message bus. When wired, `create_folder_with_perms` /// publishes a `FolderCreated` event on `Topic::Folder(parent_id)` /// after the DB commit — subscribers see the new folder appear in /// their live folder view. Optional so stub / test factories can /// build the service without a bus; a `None` bus is a silent no-op /// on the publish path (no fan-out, no audit). - bus: Option>, + bus: Option>, } impl FolderService { @@ -77,12 +77,12 @@ impl FolderService { } } - /// Wire the realtime message bus. Enables live folder-view updates: + /// Wire the message bus. Enables live folder-view updates: /// after `create_folder_with_perms` commits, a `FolderCreated` event /// fires on `Topic::Folder(parent_id)`. Off in stubs / tests. - pub fn with_realtime_bus( + pub fn with_message_bus( mut self, - bus: Arc, + bus: Arc, ) -> Self { self.bus = Some(bus); self @@ -406,10 +406,10 @@ impl FolderUseCase for FolderService { parent_uuid_for_publish, Uuid::parse_str(folder.id()), ) { - use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; + use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic}; bus.publish( &Topic::Folder(parent_uuid), - RealtimeEvent::FolderCreated { + MessageBusEvent::FolderCreated { folder_id: folder_uuid, name: folder.name().to_owned(), parent_id: parent_uuid, @@ -813,7 +813,7 @@ impl FolderUseCase for FolderService { drive_repo.invalidate_default_drive_all(); } - // Realtime publish AFTER commit. Root folders (`parent_id() = None`) + // Bus publish AFTER commit. Root folders (`parent_id() = None`) // have no parent folder topic to publish on — the drive's // display-name change is handled by the readable/default-drive // cache invalidations above, not the bus. Silent no-op if the @@ -822,10 +822,10 @@ impl FolderUseCase for FolderService { && let (Ok(folder_uuid), Ok(parent_uuid)) = (Uuid::parse_str(renamed.id()), Uuid::parse_str(parent_str)) { - use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; + use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic}; bus.publish( &Topic::Folder(parent_uuid), - RealtimeEvent::FolderRenamed { + MessageBusEvent::FolderRenamed { folder_id: folder_uuid, old_name: folder.name().to_owned(), new_name: renamed.name().to_owned(), @@ -984,7 +984,7 @@ impl FolderUseCase for FolderService { ) })?; - // Realtime fan-out on BOTH source and destination folder + // Bus fan-out on BOTH source and destination folder // topics. Same shape as `FileMoved` — subscribers to either // see the event exactly once. Silent no-op when the bus isn't // wired, the source snapshot failed, or the destination is @@ -995,8 +995,8 @@ impl FolderUseCase for FolderService { (Uuid::parse_str(folder.id()), Uuid::parse_str(dest_str)) && source_uuid != dest_uuid { - use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; - let event = RealtimeEvent::FolderMoved { + use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic}; + let event = MessageBusEvent::FolderMoved { folder_id: folder_uuid, name: folder.name().to_owned(), from: source_uuid, @@ -1106,15 +1106,15 @@ impl FolderUseCase for FolderService { self.file_lifecycle.on_file_deleted(file_id); } - // Realtime publish AFTER the DELETE commits. Root folders + // Bus publish AFTER the DELETE commits. Root folders // (no parent) can't be deleted through this endpoint per the // mount / drive-root guards above, so `publish_snapshot` is // effectively always Some for regular deletes. if let (Some(bus), Some((folder_uuid, parent_uuid))) = (&self.bus, publish_snapshot) { - use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; + use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic}; bus.publish( &Topic::Folder(parent_uuid), - RealtimeEvent::FolderDeleted { + MessageBusEvent::FolderDeleted { folder_id: folder_uuid, parent_id: parent_uuid, actor: caller_id, diff --git a/src/bin/generate-asyncapi.rs b/src/bin/generate-asyncapi.rs index 4db396a3..91337f84 100644 --- a/src/bin/generate-asyncapi.rs +++ b/src/bin/generate-asyncapi.rs @@ -1,7 +1,7 @@ -//! AsyncAPI 3.0 spec generator for the realtime message bus. +//! AsyncAPI 3.0 spec generator for the message bus. //! //! Mirrors `generate-openapi.rs`: constructs the spec from the same -//! Rust enums the server uses (`Topic`, `RealtimeEvent`, JSON-RPC +//! Rust enums the server uses (`Topic`, `MessageBusEvent`, JSON-RPC //! error codes) and writes `resources/gen/asyncapi.json`. //! //! This is the first-PR MVP surface — the two topics and two events @@ -24,7 +24,7 @@ use std::fs; use std::path::PathBuf; -use oxicloud::application::ports::realtime_ports::error_code; +use oxicloud::application::ports::message_bus_ports::error_code; use serde_json::{Value, json}; fn main() { @@ -48,7 +48,7 @@ fn build_asyncapi() -> Value { json!({ "asyncapi": "3.0.0", "info": { - "title": "OxiCloud realtime message bus", + "title": "OxiCloud message bus", "version": env!("CARGO_PKG_VERSION"), "description": r#" JSON-RPC 2.0 over WebSocket for control + events, Yjs sync protocol for @@ -68,7 +68,7 @@ Phase C (sync-client push, album live) extend the same channels — see "host": "{host}", "pathname": "/api/rt/ws", "protocol": "wss", - "description": "OxiCloud realtime bus WebSocket endpoint. Text frames are JSON-RPC 2.0. Binary frames (out of AsyncAPI scope) are Yjs sync protocol for the collab editor — see `docs/plan/markdown-collab.md`.", + "description": "OxiCloud message bus WebSocket endpoint. Text frames are JSON-RPC 2.0. Binary frames (out of AsyncAPI scope) are Yjs sync protocol for the collab editor — see `docs/plan/markdown-collab.md`.", "variables": { "host": { "description": "Server host — replace with the deployment domain", @@ -437,7 +437,7 @@ fn rpc_pong_result_schema() -> Value { fn rpc_error_response_schema() -> Value { // The `code`/`message` catalog is the stable public vocabulary — // any change here IS a wire break. Every entry mirrors - // `application/ports/realtime_ports.rs::error_code`. The inner + // `application/ports/message_bus_ports.rs::error_code`. The inner // error object is hoisted to `RtErrorObject` so Modelina emits a // named type instead of `AnonymousSchema_N`. json!({ @@ -533,7 +533,7 @@ fn event_params_schema() -> Value { fn event_kind_schema() -> Value { json!({ "type": "string", - "description": "Discriminator for the `data` payload. Mirrors the `#[serde(tag = \"event\", rename_all = \"snake_case\")]` variants of the Rust `RealtimeEvent` enum — a new event kind is a new enum variant on both sides.", + "description": "Discriminator for the `data` payload. Mirrors the `#[serde(tag = \"event\", rename_all = \"snake_case\")]` variants of the Rust `MessageBusEvent` enum — a new event kind is a new enum variant on both sides.", "enum": [ "file_created", "file_renamed", "file_moved", "file_deleted", "folder_created", "folder_renamed", "folder_moved", "folder_deleted", diff --git a/src/bin/opaque-hurl-helper.rs b/src/bin/opaque-hurl-helper.rs index ed8325f4..de18f0ce 100644 --- a/src/bin/opaque-hurl-helper.rs +++ b/src/bin/opaque-hurl-helper.rs @@ -402,7 +402,7 @@ async fn main() -> ExitCode { Err(e) => return fail(format!("/api/admin/sessions network: {e}")), } - // ── OPAQUE-minted JWT works against the realtime WS ───────────── + // ── OPAQUE-minted JWT works against the WebSocket ───────────── // // Regression guard: `auth_middleware` doesn't inspect how a JWT // was minted, so an OPAQUE-issued access_token must Just Work on diff --git a/src/bin/rt-hurl-helper.rs b/src/bin/rt-hurl-helper.rs index 36bc446b..136efbbf 100644 --- a/src/bin/rt-hurl-helper.rs +++ b/src/bin/rt-hurl-helper.rs @@ -1,4 +1,4 @@ -//! WebSocket-side smoke-test helper for the realtime message bus. +//! WebSocket-side smoke-test helper for the message bus. //! //! Hurl is HTTP-only — it can't do a WS upgrade, let alone read frames //! for later assertion. This binary is the WS half of the smoke test: diff --git a/src/common/di.rs b/src/common/di.rs index 7377bdaa..78b62c2f 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -703,12 +703,12 @@ impl AppServiceFactory { resource_access_hook: Option< Arc, >, - bus: &Arc, + bus: &Arc, ) -> ApplicationServices { // Upcast the concrete bus once — service builders take the // trait object so the wire remains stable across future bus // impls. - let bus_trait: Arc = + let bus_trait: Arc = bus.clone(); // Main services @@ -732,9 +732,9 @@ impl AppServiceFactory { // already runs. Without this, a Move that would push the // destination past its cap succeeds silently. .with_storage_usage(storage_usage.clone()) - // Realtime fan-out on `create_folder_with_perms` — the + // Bus fan-out on `create_folder_with_perms` — the // parent-folder subscribers see new sub-folders live. - .with_realtime_bus(bus_trait.clone()), + .with_message_bus(bus_trait.clone()), ); // Built before the upload/management services so the plugin lifecycle @@ -782,10 +782,10 @@ impl AppServiceFactory { core.dedup_service.clone(), storage_usage.clone(), ) - // Realtime fan-out — every successful `upload_file_streaming` + // Bus fan-out — every successful `upload_file_streaming` // publishes a `FileCreated` event on the parent folder's // topic so open folder views refresh live. - .with_realtime_bus(bus_trait.clone()); + .with_message_bus(bus_trait.clone()); if let Some(hook) = resource_access_hook.clone() { svc = svc.with_resource_access_hook(hook); } @@ -826,11 +826,11 @@ impl AppServiceFactory { // Destination-drive quota pre-check on cross-drive file // MOVE. Same rationale as the folder side above. .with_storage_usage(storage_usage.clone()) - // Realtime fan-out on delete / rename / move — each hook + // Bus fan-out on delete / rename / move — each hook // publishes on the affected folder topic (move fans out on // BOTH source and destination) so folder-view subscribers // see the mutation live. - .with_realtime_bus(bus_trait.clone()); + .with_message_bus(bus_trait.clone()); if let Some(hook) = resource_access_hook.clone() { svc = svc.with_resource_access_hook(hook); } @@ -1794,15 +1794,15 @@ impl AppServiceFactory { crate::application::services::external_mount_router::MountRouter::new(mount_registry), ); - // Realtime bus: single instance for the app lifetime, wired + // Message bus: single instance for the app lifetime, wired // with a no-op replicator (multi-instance broker is a follow-up // per `docs/plan/message-bus.md § Roadmap`). Constructed here // so `create_application_services` can hand it to services that // publish after their DB commits (`FolderService`, // `FileUploadService`, …). Spawns its own GC task in // `with_replicator` — no supervisor setup required. - let bus = crate::infrastructure::services::in_process_realtime_bus::InProcessRealtimeBus::with_replicator( - Arc::new(crate::application::ports::realtime_ports::NoopReplicator), + let bus = crate::infrastructure::services::in_process_message_bus::InProcessMessageBus::with_replicator( + Arc::new(crate::application::ports::message_bus_ports::NoopReplicator), ); let mut apps = self.create_application_services( @@ -3209,19 +3209,19 @@ pub struct AppState { /// method (which still owns the authorization check). pub mount_router: Arc, - /// Realtime message bus. Always present — an empty bus (no + /// Message bus. Always present — an empty bus (no /// subscribers, no publishes) costs a single `DashMap` allocation. /// The WS handler reads `subscribe`; service publish hooks /// (`FolderService::create_folder_with_perms`, /// `FileManagementService`'s file-create commit) call `publish` /// AFTER their DB transaction commits. /// - /// Stored as the concrete type (not `Arc`) so the + /// Stored as the concrete type (not `Arc`) so the /// GC task's `Weak` lifecycle is legible from di.rs. Consumers /// that only need the trait obtain it via - /// `Arc::clone(&state.bus) as Arc`. + /// `Arc::clone(&state.bus) as Arc`. pub bus: Arc< - crate::infrastructure::services::in_process_realtime_bus::InProcessRealtimeBus, + crate::infrastructure::services::in_process_message_bus::InProcessMessageBus, >, pub auth_service: Option, /// OPAQUE aPAKE substrate (RFC 9807). Populated only when diff --git a/src/infrastructure/services/in_process_realtime_bus.rs b/src/infrastructure/services/in_process_message_bus.rs similarity index 91% rename from src/infrastructure/services/in_process_realtime_bus.rs rename to src/infrastructure/services/in_process_message_bus.rs index afd0f7cb..e92200d1 100644 --- a/src/infrastructure/services/in_process_realtime_bus.rs +++ b/src/infrastructure/services/in_process_message_bus.rs @@ -1,4 +1,4 @@ -//! In-process `RealtimeBus` — one `broadcast::Sender` per active topic, +//! In-process `MessageBus` — one `broadcast::Sender` per active topic, //! held in a [`DashMap`] keyed by [`Topic`]. Publish is fire-and-forget, //! subscribe returns a `Stream` backed by [`BroadcastStream`]. //! @@ -28,8 +28,8 @@ use futures::StreamExt; use tokio::sync::broadcast; use tokio_stream::wrappers::BroadcastStream; -use crate::application::ports::realtime_ports::{ - BusReplicator, BusStream, RealtimeBus, RealtimeEvent, Topic, +use crate::application::ports::message_bus_ports::{ + BusReplicator, BusStream, MessageBus, MessageBusEvent, Topic, }; /// Per-topic ring-buffer size for slow subscribers. When a subscriber lags @@ -44,20 +44,20 @@ pub const BROADCAST_RING_CAPACITY: usize = 256; /// long enough that GC overhead stays trivial. pub const GC_INTERVAL: Duration = Duration::from_secs(60); -/// The in-process implementation of [`RealtimeBus`]. +/// The in-process implementation of [`MessageBus`]. /// -/// Callers hold `Arc` (or `Arc`). +/// Callers hold `Arc` (or `Arc`). /// The struct owns its topic map and — when constructed via -/// [`InProcessRealtimeBus::with_replicator`] — an [`Arc`] +/// [`InProcessMessageBus::with_replicator`] — an [`Arc`] /// that gets fed every local publish for outbound broker forwarding. -pub struct InProcessRealtimeBus { - topics: DashMap>, +pub struct InProcessMessageBus { + topics: DashMap>, replicator: Arc, } -impl InProcessRealtimeBus { +impl InProcessMessageBus { /// Construct with a replicator. In v1 that's a - /// [`crate::application::ports::realtime_ports::NoopReplicator`]; when + /// [`crate::application::ports::message_bus_ports::NoopReplicator`]; when /// multi-instance ships, it becomes the pg-NOTIFY or broker impl. /// /// The GC task holds a [`Weak`] handle so it exits naturally when the @@ -111,7 +111,7 @@ impl InProcessRealtimeBus { /// receiver. Used by both `publish` (for the sender) and `subscribe` /// (for the receiver) — one code path for the map insert avoids a race /// where publish creates a sender concurrent subscribers miss. - fn sender_for(&self, topic: &Topic) -> broadcast::Sender { + fn sender_for(&self, topic: &Topic) -> broadcast::Sender { self.topics .entry(*topic) .or_insert_with(|| broadcast::channel(BROADCAST_RING_CAPACITY).0) @@ -119,8 +119,8 @@ impl InProcessRealtimeBus { } } -impl RealtimeBus for InProcessRealtimeBus { - fn publish(&self, topic: &Topic, event: RealtimeEvent) { +impl MessageBus for InProcessMessageBus { + fn publish(&self, topic: &Topic, event: MessageBusEvent) { // Feed the replicator FIRST — if it were called after local fan-out, // an unwind on a broken subscriber could skip broker forwarding. // `on_local_publish` is a sync fire-and-forget contract; slow @@ -162,23 +162,23 @@ impl RealtimeBus for InProcessRealtimeBus { #[cfg(test)] mod tests { use super::*; - use crate::application::ports::realtime_ports::NoopReplicator; + use crate::application::ports::message_bus_ports::NoopReplicator; use futures::StreamExt; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use tokio::sync::Notify; use uuid::Uuid; - fn make_bus() -> Arc { - InProcessRealtimeBus::with_replicator(Arc::new(NoopReplicator)) + fn make_bus() -> Arc { + InProcessMessageBus::with_replicator(Arc::new(NoopReplicator)) } fn folder_topic() -> Topic { Topic::Folder(Uuid::new_v4()) } - fn file_created(parent_id: Uuid) -> RealtimeEvent { - RealtimeEvent::FileCreated { + fn file_created(parent_id: Uuid) -> MessageBusEvent { + MessageBusEvent::FileCreated { file_id: Uuid::new_v4(), name: "a.txt".into(), parent_id, @@ -299,7 +299,7 @@ mod tests { } #[async_trait::async_trait] impl BusReplicator for CountingReplicator { - fn on_local_publish(&self, _topic: &Topic, _event: &RealtimeEvent) { + fn on_local_publish(&self, _topic: &Topic, _event: &MessageBusEvent) { self.count.fetch_add(1, Ordering::SeqCst); } async fn run( @@ -314,7 +314,7 @@ mod tests { let counter = Arc::new(CountingReplicator { count: AtomicUsize::new(0), }); - let bus = InProcessRealtimeBus::with_replicator(Arc::clone(&counter) as Arc<_>); + let bus = InProcessMessageBus::with_replicator(Arc::clone(&counter) as Arc<_>); let topic = folder_topic(); let _sub = bus.subscribe(&topic); let parent = match topic { diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 1fb189c3..bb17125c 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -27,7 +27,7 @@ pub mod files_consistency_service; pub mod folders_consistency_service; pub mod grant_cleanup_service; pub mod image_transcode_service; -pub mod in_process_realtime_bus; +pub mod in_process_message_bus; pub mod jwt_service; pub mod last_seen_tracker; pub mod local_blob_backend; diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index 93dda8f1..b302fc62 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -505,7 +505,7 @@ pub async fn revoke_grant( "🗑️ grant revoked", ); - // Realtime eviction cascade — the revoke committed, so any WS + // Message-bus eviction cascade — the revoke committed, so any WS // session that had the affected user auto-subscribed to // `user:{u}:authz` gets an AuthzChanged event and drops any live // subscriptions to the affected resource. Silent no-op when the @@ -514,11 +514,11 @@ pub async fn revoke_grant( // membership expansion ships). Folder resources only for MVP; // File/Drive topics don't exist yet. if let (Subject::User(target_user), Resource::Folder(folder_id)) = (subject, resource) { - use crate::application::ports::realtime_ports::{RealtimeBus, RealtimeEvent, Topic}; - RealtimeBus::publish( + use crate::application::ports::message_bus_ports::{MessageBus, MessageBusEvent, Topic}; + MessageBus::publish( state.bus.as_ref(), &Topic::UserAuthz(target_user), - RealtimeEvent::AuthzChanged { + MessageBusEvent::AuthzChanged { affected_folders: vec![folder_id], }, ); diff --git a/src/interfaces/api/handlers/rt_ws.rs b/src/interfaces/api/handlers/rt_ws.rs index d96ee7ad..fb8329a5 100644 --- a/src/interfaces/api/handlers/rt_ws.rs +++ b/src/interfaces/api/handlers/rt_ws.rs @@ -1,4 +1,4 @@ -//! Realtime bus WebSocket handler — the endpoint every WS session +//! Message bus WebSocket handler — the endpoint every WS session //! multiplexes over. See `docs/plan/message-bus.md § Wire protocol`. //! //! # Wire @@ -50,8 +50,8 @@ use tokio::time::MissedTickBehavior; use uuid::Uuid; use crate::application::ports::authorization_ports::AuthorizationEngine; -use crate::application::ports::realtime_ports::{ - AuthzCheck, BusResource, ParseTopicErr, RealtimeBus, RealtimeEvent, Topic, error_code, +use crate::application::ports::message_bus_ports::{ + AuthzCheck, BusResource, MessageBus, MessageBusEvent, ParseTopicErr, Topic, error_code, }; use crate::common::di::AppState; use crate::domain::services::authorization::{Permission, Resource, Subject}; @@ -192,7 +192,7 @@ impl Drop for Sub { /// the socket. /// - `EvictFolders` — internal control signal. The reader for the /// session's auto-subscribed `user:{caller}:authz` topic translates -/// inbound [`RealtimeEvent::AuthzChanged`] events into this rather +/// inbound [`MessageBusEvent::AuthzChanged`] events into this rather /// than a client-visible frame. Main loop walks its subs, drops any /// whose resource is in the list, and emits one `rt.revoked` frame /// per evicted topic. @@ -522,7 +522,7 @@ fn handle_unsubscribe(id: Value, params: Value, subs: &mut HashMap) /// /// The reader interprets bus events differently by topic class: /// -/// - For `Topic::UserAuthz(_)`: an incoming `RealtimeEvent::AuthzChanged` +/// - For `Topic::UserAuthz(_)`: an incoming `MessageBusEvent::AuthzChanged` /// is translated to `SessionOut::EvictFolders(affected)` — the main /// loop then walks the sub set and drops matching topics. Any other /// event kind on this topic is ignored (defensive; shouldn't happen @@ -536,7 +536,7 @@ fn install_subscription( state: &Arc, ) { let topic_wire = topic.to_wire_key(); - let mut stream = RealtimeBus::subscribe(state.bus.as_ref(), &topic); + let mut stream = MessageBus::subscribe(state.bus.as_ref(), &topic); let out_tx_task = out_tx.clone(); let translate_authz = matches!(topic, Topic::UserAuthz(_)); // Clone for the reader closure; keep the original to key `subs`. @@ -546,7 +546,7 @@ fn install_subscription( while let Some(event) = stream.next().await { let message = if translate_authz { match event { - RealtimeEvent::AuthzChanged { affected_folders } => { + MessageBusEvent::AuthzChanged { affected_folders } => { SessionOut::EvictFolders(affected_folders) } // The authz topic only carries AuthzChanged in @@ -597,15 +597,15 @@ fn error_response(id: Value, code: i32, message: &str, data: Option) -> S /// Build an `rt.event` JSON-RPC notification for a bus event. /// -/// Payload discipline (see plan): thin facts only. The `RealtimeEvent`'s +/// Payload discipline (see plan): thin facts only. The `MessageBusEvent`'s /// own `#[serde(tag = "event")]` shape provides `event` + variant fields /// under one flat object; we lift them into `params.data` alongside a /// `topic` selector for the client. -fn event_notification(topic_wire: &str, event: &RealtimeEvent) -> String { +fn event_notification(topic_wire: &str, event: &MessageBusEvent) -> String { // Serialize the event to extract `event` (discriminator) and the // remaining fields as `data`. Two-step to avoid re-inventing the // enum's discriminator string here. - let event_json = serde_json::to_value(event).expect("RealtimeEvent always serializes"); + let event_json = serde_json::to_value(event).expect("MessageBusEvent always serializes"); let (event_name, data) = split_event_discriminator(event_json); let params = serde_json::json!({ @@ -622,7 +622,7 @@ fn event_notification(topic_wire: &str, event: &RealtimeEvent) -> String { .expect("RpcNotification always serializes") } -/// Given a `RealtimeEvent` serialised as `{ "event": "file_created", ...rest }`, +/// Given a `MessageBusEvent` serialised as `{ "event": "file_created", ...rest }`, /// split into `(event_name, rest)`. Falls back to `("unknown", full)` if /// the shape doesn't match (defensive — shouldn't happen given the enum /// derive, but a future untagged variant would land here). @@ -658,11 +658,11 @@ fn revoked_notification(topic_wire: &str, reason: &'static str) -> String { fn audit_denied(caller_id: Uuid, topic: &str, reason: &'static str) { tracing::info!( target: "audit", - event = "realtime.subscribe_denied", + event = "message_bus.subscribe_denied", reason = reason, caller_id = %caller_id, topic = %topic, - "👮🏻‍♂️ realtime subscribe rejected", + "👮🏻‍♂️ message-bus subscribe rejected", ); } @@ -672,11 +672,11 @@ fn audit_denied(caller_id: Uuid, topic: &str, reason: &'static str) { fn audit_evicted(caller_id: Uuid, topic: &str, reason: &'static str) { tracing::info!( target: "audit", - event = "realtime.subscription_evicted", + event = "message_bus.subscription_evicted", reason = reason, caller_id = %caller_id, topic = %topic, - "🚫 realtime subscription evicted", + "🚫 message-bus subscription evicted", ); } @@ -720,7 +720,7 @@ mod tests { #[test] fn event_notification_shape() { - let event = RealtimeEvent::FileCreated { + let event = MessageBusEvent::FileCreated { file_id: Uuid::nil(), name: "notes.md".into(), parent_id: Uuid::nil(), diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 2f0e11a2..5cc5967d 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -674,7 +674,7 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { .with_state(app_state.clone()); router = router.nest("/users", users_router); - // Realtime bus WebSocket. Auth (session cookie or bearer JWT) via + // Message bus WebSocket. Auth (session cookie or bearer JWT) via // the same `auth_middleware` the rest of `/api/*` gets; the handler // extracts `CurrentUserId` from the extension the middleware // installs. See `docs/plan/message-bus.md` and the module doc on diff --git a/tests/api/rt_bus_check.sh b/tests/api/rt_bus_check.sh index 350d6eb1..06e99018 100755 --- a/tests/api/rt_bus_check.sh +++ b/tests/api/rt_bus_check.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── -# Realtime bus smoke test — the parts Hurl can't drive. +# Message bus smoke test — the parts Hurl can't drive. # # Hurl is HTTP-only and cannot open a WebSocket, so the WS half of the test # runs through `rt-hurl-helper` (a small Rust bin gated on `test_utils`). @@ -496,4 +496,4 @@ sub_count=$(jq -r '.subscribed | length' "$out_s8") log "S8 OK" -log "All eight realtime-bus scenarios passed." +log "All eight message-bus scenarios passed." diff --git a/tests/api/run.sh b/tests/api/run.sh index 5c4e073d..6dfe31c9 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -267,7 +267,7 @@ bash "$API_DIR/thumb_import_check.sh" bash "$API_DIR/storage_cleanup_check.sh" -# ── 5. Realtime message bus — WebSocket smoke test ────────────────────── +# ── 5. Message bus — WebSocket smoke test ─────────────────────────────── # Runs BEFORE the OPAQUE helper so its user registration + login uses # the legacy password path (opaque_substrate.hurl migrates the admin # account, but by running first this check is unaffected by whatever @@ -275,9 +275,9 @@ bash "$API_DIR/storage_cleanup_check.sh" # positive delivery, topic isolation, AuthZ denial on subscribe, # anti-enumeration parity. See `tests/api/rt_bus_check.sh` and # `docs/plan/message-bus.md`. -log "Running realtime-bus smoke test..." +log "Running message-bus smoke test..." BUILD_TARGET="$BUILD_TARGET" bash "$REPO_ROOT/tests/api/rt_bus_check.sh" \ - || die "realtime-bus smoke test failed" + || die "message-bus smoke test failed" # ── 6. OPAQUE crypto handshake — the parts Hurl can't drive ───────────── # Full OPAQUE register + login handshake against the running server, From ad9eab6f92e0b842da519a46b1e73d4427e01547 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 11 Sep 2026 00:33:45 +0200 Subject: [PATCH 09/21] refactor(msg-bus): prefer explicit enum on AsyncAPI error --- .../lib/generated/message-bus/RtErrorCode.ts | 14 ----- .../generated/message-bus/RtErrorObject.ts | 3 +- frontend/src/lib/message-bus/error-codes.ts | 55 +++++++++++++++++++ src/bin/generate-asyncapi.rs | 51 ++++++++++++----- 4 files changed, 93 insertions(+), 30 deletions(-) delete mode 100644 frontend/src/lib/generated/message-bus/RtErrorCode.ts create mode 100644 frontend/src/lib/message-bus/error-codes.ts diff --git a/frontend/src/lib/generated/message-bus/RtErrorCode.ts b/frontend/src/lib/generated/message-bus/RtErrorCode.ts deleted file mode 100644 index 967c4a18..00000000 --- a/frontend/src/lib/generated/message-bus/RtErrorCode.ts +++ /dev/null @@ -1,14 +0,0 @@ -enum RtErrorCode { - MINUS_32001 = -32001, - MINUS_32002 = -32002, - MINUS_32003 = -32003, - MINUS_32004 = -32004, - MINUS_32005 = -32005, - MINUS_32006 = -32006, - MINUS_32007 = -32007, - MINUS_32603 = -32603, - MINUS_32600 = -32600, - MINUS_32601 = -32601, - MINUS_32602 = -32602 -} -export type { RtErrorCode as default }; diff --git a/frontend/src/lib/generated/message-bus/RtErrorObject.ts b/frontend/src/lib/generated/message-bus/RtErrorObject.ts index 773876f2..6c4b6eb2 100644 --- a/frontend/src/lib/generated/message-bus/RtErrorObject.ts +++ b/frontend/src/lib/generated/message-bus/RtErrorObject.ts @@ -1,9 +1,8 @@ -import type RtErrorCode from './RtErrorCode'; import type RtErrorMessage from './RtErrorMessage'; // AUTO-GENERATED — do not edit by hand. // Regenerate with `just asyncapi-ts`. interface RtErrorObject { - code: RtErrorCode; + code: number; data?: unknown; message: RtErrorMessage; } diff --git a/frontend/src/lib/message-bus/error-codes.ts b/frontend/src/lib/message-bus/error-codes.ts new file mode 100644 index 00000000..766e338c --- /dev/null +++ b/frontend/src/lib/message-bus/error-codes.ts @@ -0,0 +1,55 @@ +// Named constants for JSON-RPC 2.0 error codes emitted on the message-bus +// WebSocket wire. Mirrors `application/ports/message_bus_ports.rs::error_code` +// on the server — the Rust module is the source of truth. +// +// Hand-written, deliberately not generated: Modelina projects a JSON-Schema +// `enum` of numeric values into a TS enum with mangled member names +// (`MINUS_32001 = -32001`), which reads worse than no enum at all. The wire +// type is just `number`; readable name-to-code lookup lives here. +// +// Values are frozen across releases — a new denial cause gets a new value, +// never repurposes an existing one. Adding a code: bump the Rust module and +// this file in the same commit; the wire spec's description text is a +// derivative of the Rust constants (see `generate-asyncapi.rs`). + +/** + * Application-defined codes live in the JSON-RPC 2.0 server-defined range + * `-32099..-32000`; standard envelope codes live in `-32700..-32600`. + */ +export const RtErrorCode = { + // ── Application-defined (subscribe / edit path denials) ───────────── + /** Resource-scoped topic, caller lacks Read on the resource (or the + * resource does not exist — the two outcomes are indistinguishable to + * the caller by design, to preserve anti-enumeration). */ + NO_READ: -32001, + /** Resource-scoped topic requires Share, caller has Read but not Share. + * Applies to `file:{id}:shares` (Phase B). */ + NO_SHARE: -32002, + /** Resource-scoped topic requires Comment (`file:{id}:comments`, Phase B). */ + NO_COMMENT: -32003, + /** Identity-scoped mismatch, OR unknown/malformed topic. Same wire code + * regardless of whether the target exists — anti-enum. */ + TOPIC_FORBIDDEN: -32004, + /** Per-connection subscription cap hit. */ + SUB_LIMIT: -32005, + /** Subscribe-frame token bucket exhausted. */ + RATE_LIMITED: -32006, + /** CRDT edit frame from a caller without Edit on the doc. Emitted as an + * `rt.write_denied` notification (not tied to a request id). */ + NO_EDIT: -32007, + + // ── JSON-RPC 2.0 standard envelope codes ──────────────────────────── + /** Server-side failure the client should retry. */ + INTERNAL_ERROR: -32603, + /** Malformed JSON-RPC envelope (missing `method`, wrong `jsonrpc` version). */ + INVALID_REQUEST: -32600, + /** Method outside the `rt.*` allow-list. */ + METHOD_NOT_FOUND: -32601, + /** Method known but `params` shape wrong (missing `topic`, unparseable). */ + INVALID_PARAMS: -32602 +} as const satisfies Record; + +/** Union of every named code's numeric value. Narrows a bare `number` on + * `RtErrorObject.code` to the eleven known literals for exhaustive + * `switch` blocks. */ +export type RtErrorCodeValue = (typeof RtErrorCode)[keyof typeof RtErrorCode]; diff --git a/src/bin/generate-asyncapi.rs b/src/bin/generate-asyncapi.rs index 91337f84..769a62d6 100644 --- a/src/bin/generate-asyncapi.rs +++ b/src/bin/generate-asyncapi.rs @@ -473,22 +473,45 @@ fn rpc_error_object_schema() -> Value { } fn rpc_error_code_schema() -> Value { + // Kept as plain `integer` — Modelina projects a JSON-Schema `enum` of + // numeric values into a TS enum with mangled member names + // (`MINUS_32001 = -32001`), which is worse than no enum at all. The + // Rust `error_code` module is the source of truth for named + // constants; the FE mirrors it in `frontend/src/lib/message-bus/ + // error-codes.ts` (hand-written, 11 lines, sits alongside the + // generated DTOs). Description enumerates the full set inline so the + // AsyncAPI spec is still self-documenting. + let full_description = format!( + "Stable integer error code. Values are frozen across releases — a \ + new denial cause gets a new value, never repurposes an existing \ + one. Application-defined codes ({}..={}):\n\ + · {} NO_READ — resource-scoped topic, caller lacks Read (or \ + resource doesn't exist — indistinguishable by design)\n\ + · {} NO_SHARE — resource requires Share, caller has Read but not Share\n\ + · {} NO_COMMENT — resource requires Comment\n\ + · {} TOPIC_FORBIDDEN — identity-scoped mismatch or unknown/malformed topic\n\ + · {} SUB_LIMIT — per-connection subscription cap hit\n\ + · {} RATE_LIMITED — subscribe-frame token bucket exhausted\n\ + · {} NO_EDIT — CRDT edit frame from a caller without Edit\n\ + Standard JSON-RPC 2.0 codes:\n\ + · {} INTERNAL_ERROR · {} INVALID_REQUEST · {} METHOD_NOT_FOUND · {} INVALID_PARAMS", + -32099, + -32000, + error_code::NO_READ, + error_code::NO_SHARE, + error_code::NO_COMMENT, + error_code::TOPIC_FORBIDDEN, + error_code::SUB_LIMIT, + error_code::RATE_LIMITED, + error_code::NO_EDIT, + error_code::INTERNAL_ERROR, + error_code::INVALID_REQUEST, + error_code::METHOD_NOT_FOUND, + error_code::INVALID_PARAMS, + ); json!({ "type": "integer", - "description": "Stable integer error code. Values are frozen across releases — a new denial cause gets a new value, never repurposes an existing one.", - "enum": [ - error_code::NO_READ, - error_code::NO_SHARE, - error_code::NO_COMMENT, - error_code::TOPIC_FORBIDDEN, - error_code::SUB_LIMIT, - error_code::RATE_LIMITED, - error_code::NO_EDIT, - error_code::INTERNAL_ERROR, - error_code::INVALID_REQUEST, - error_code::METHOD_NOT_FOUND, - error_code::INVALID_PARAMS, - ], + "description": full_description, }) } From f7222ea99683f34030fc057bdcd323c8e17cb442 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 11 Sep 2026 00:52:32 +0200 Subject: [PATCH 10/21] test(msg-bus): ensure a userA cannot sub to topic user:{userB}:... --- tests/api/rt_bus_check.sh | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/tests/api/rt_bus_check.sh b/tests/api/rt_bus_check.sh index 06e99018..137046c3 100755 --- a/tests/api/rt_bus_check.sh +++ b/tests/api/rt_bus_check.sh @@ -7,7 +7,7 @@ # This script orchestrates it against a live oxicloud server: bootstraps # state with curl, exercises the bus, asserts on the helper's JSON output. # -# Seven scenarios: +# Nine scenarios: # S1 Positive delivery — subscribe to folder A, upload into A, see event. # S2 Topic isolation — subscribe to folder A only, upload into B and # then A; must see A's event only. @@ -35,6 +35,13 @@ # Locks in three invariants: eviction # fires, scoping is per-topic, session # survives. +# S9 Cross-user identity — user1 tries to subscribe to +# `user:{user2_id}:authz` (an identity-scoped +# topic that resolves to somebody else). Server +# must reject with `topic_forbidden` (same wire +# shape as an unknown topic — anti-enumeration). +# Guards the strict-privacy Class-2 AuthZ gate: +# no admin bypass, direct UUID equality only. # # Exit non-zero on any failure — run.sh treats that as a suite failure. # ───────────────────────────────────────────────────────────────────────────── @@ -496,4 +503,32 @@ sub_count=$(jq -r '.subscribed | length' "$out_s8") log "S8 OK" -log "All eight message-bus scenarios passed." +# ── Scenario 9 — Cross-user identity topic denial ─────────────────────────── +# Identity-scoped topics (`user:{u}:authz`, later `user:{u}:notifications`, +# `user:{u}:sessions`) use a Class-2 AuthZ gate: `caller_id == user_id` by +# direct UUID equality. No admin bypass, no group expansion — privacy is +# absolute. Regression guard: user1 asks for user2's authz stream; server +# MUST reject. +# +# The wire response uses `topic_forbidden` — the SAME error string the +# server returns for a malformed/unknown topic — so an attacker cannot +# distinguish "no such user" from "user exists but not you". `expect-denied +# --reason topic_forbidden` matches on the wire `error.message` string +# emitted by `application/ports/message_bus_ports.rs::error_message`. +# +# If this ever regresses to `no_read` or delivers events, someone changed +# the identity gate (removed the equality check, wired the AuthorizationEngine +# on the identity path, or reused the folder AuthZ dispatch). All three +# would leak user metadata across accounts. +log "S9: user1 subscribes to user:{user2_id}:authz; expect topic_forbidden." +if ! "$HELPER_BIN" expect-denied \ + --url "$ws_url" \ + --token "$user1_token" \ + --subscribe "user:${user2_id}:authz" \ + --reason topic_forbidden \ + --timeout 3s; then + die "S9: user1 was NOT denied on user2's authz topic (identity gate broken?)" +fi +log "S9 OK" + +log "All nine message-bus scenarios passed." From 821f76b471b813f15f08f28cb9a1040aa3f11247 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 11 Sep 2026 00:59:49 +0200 Subject: [PATCH 11/21] feat(msg-bus): wire message bus on frontend --- frontend/AGENTS.md | 16 +- frontend/src/hooks.client.ts | 3 +- .../lib/composables/useFolderTopic.svelte.ts | 96 ++++ .../src/lib/composables/useTopic.svelte.ts | 40 ++ frontend/src/lib/message-bus/client.svelte.ts | 418 ++++++++++++++++++ frontend/src/lib/message-bus/frames.test.ts | 107 +++++ frontend/src/lib/message-bus/frames.ts | 110 +++++ .../src/routes/files/[...path]/+page.svelte | 57 +++ 8 files changed, 842 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/composables/useFolderTopic.svelte.ts create mode 100644 frontend/src/lib/composables/useTopic.svelte.ts create mode 100644 frontend/src/lib/message-bus/client.svelte.ts create mode 100644 frontend/src/lib/message-bus/frames.test.ts create mode 100644 frontend/src/lib/message-bus/frames.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index f972b1f0..cc478099 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -71,8 +71,16 @@ protocol reasons: When adding FE code around the bus, use `message-bus` in file names, store names, and logger namespaces: -- Store: `$lib/stores/message-bus.svelte.ts` +- Subsystem dir: `$lib/message-bus/` — reactive client (`client.svelte.ts`, + a `MessageBusClient` singleton owning the WebSocket, refcounted topic + subs, and reconnect), frame builders (`frames.ts`), error-code + constants (`error-codes.ts`). Mirrors the `$lib/auth/` and + `$lib/upload/` subsystem-dir pattern rather than living in + `$lib/stores/` — the client is subsystem-scoped plumbing that only + the message-bus composables reach for, not a global reactive store + read from route decisions like `session`. - Composables: `$lib/composables/useTopic.svelte.ts` (topic-generic — no - bus name in the file) -- Logger namespace: `oxi:message-bus` -- localStorage keys (if any): `oxi-message-bus-*` + bus name in the file), `$lib/composables/useFolderTopic.svelte.ts` + (folder-view sugar with per-verb handlers). +- Logger namespace: `oxi:message-bus`. +- localStorage keys (if any): `oxi-message-bus-*`. diff --git a/frontend/src/hooks.client.ts b/frontend/src/hooks.client.ts index 5329bb23..c8c35418 100644 --- a/frontend/src/hooks.client.ts +++ b/frontend/src/hooks.client.ts @@ -14,7 +14,8 @@ import { seedNonceFromCookie } from '$lib/auth/dpop-proof'; // needing to import anything. // // Log levels — namespaces used today: `oxi:upload` (delta + direct -// upload pipeline). Levels: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent'. +// upload pipeline), `oxi:message-bus` (WebSocket client + `useTopic`). +// Levels: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent'. // Choices persist to `localStorage['loglevel:']` via loglevel. // // oxi.setLogLevel('oxi:upload', 'debug') // deep dive diff --git a/frontend/src/lib/composables/useFolderTopic.svelte.ts b/frontend/src/lib/composables/useFolderTopic.svelte.ts new file mode 100644 index 00000000..747bec37 --- /dev/null +++ b/frontend/src/lib/composables/useFolderTopic.svelte.ts @@ -0,0 +1,96 @@ +// Folder-view sugar around `useTopic`. +// +// Discriminates the `rt.event` union at the composable boundary so +// each consumer supplies per-verb handlers with correctly-typed +// payloads. Adding a new event kind in Rust regenerates +// `RtEventKind` — the switch below fails to type-check until every +// arm is handled, keeping the FE exhaustive. + +import { useTopic } from './useTopic.svelte'; +import type RtEventParams from '$lib/generated/message-bus/RtEventParams'; +import type RtRevokedParams from '$lib/generated/message-bus/RtRevokedParams'; +import type FileCreatedData from '$lib/generated/message-bus/FileCreatedData'; +import type FileRenamedData from '$lib/generated/message-bus/FileRenamedData'; +import type FileMovedData from '$lib/generated/message-bus/FileMovedData'; +import type FileDeletedData from '$lib/generated/message-bus/FileDeletedData'; +import type FolderCreatedData from '$lib/generated/message-bus/FolderCreatedData'; +import type FolderRenamedData from '$lib/generated/message-bus/FolderRenamedData'; +import type FolderMovedData from '$lib/generated/message-bus/FolderMovedData'; +import type FolderDeletedData from '$lib/generated/message-bus/FolderDeletedData'; + +/** + * Optional per-verb handlers. Any subset is accepted; unhandled verbs + * are silently ignored. Fires only when the folder view actually cares + * about that kind — leave a handler undefined to opt out. + * + * Callers commonly bind ONE `refresh` function to every handler (see + * `routes/files/[...path]/+page.svelte`) rather than reason about + * surgical mutations — that keeps the folder listing consistent + * with server-side sort/pagination without maintaining a second + * mutation path. + */ +export interface FolderTopicHandlers { + onFileCreated?: (data: FileCreatedData) => void; + onFileRenamed?: (data: FileRenamedData) => void; + onFileMoved?: (data: FileMovedData) => void; + onFileDeleted?: (data: FileDeletedData) => void; + onFolderCreated?: (data: FolderCreatedData) => void; + onFolderRenamed?: (data: FolderRenamedData) => void; + onFolderMoved?: (data: FolderMovedData) => void; + onFolderDeleted?: (data: FolderDeletedData) => void; + /** Grant revoked or folder deleted — the subscription is gone + * server-side. Reasonable UX: toast + navigate away. */ + onRevoked?: (params: RtRevokedParams) => void; +} + +/** + * Subscribe to `folder:{folderId}` and dispatch each `rt.event` + * notification to the matching per-verb handler. + * + * `folderId` accepts the same shapes as `useTopic`'s `topic` — a + * plain string, a nullable string (null = don't subscribe yet), or a + * getter that reads from reactive state (route param) so the + * subscription follows the current folder. + */ +export function useFolderTopic( + folderId: string | null | (() => string | null), + handlers: FolderTopicHandlers +): void { + const topic = () => { + const id = typeof folderId === 'function' ? folderId() : folderId; + return id ? `folder:${id}` : null; + }; + useTopic(topic, (params) => dispatch(params, handlers), handlers.onRevoked); +} + +function dispatch(params: RtEventParams, handlers: FolderTopicHandlers): void { + // The generated `RtEventKind` string-enum values match the Rust + // `#[serde(rename_all = "snake_case")]` variants exactly — see + // `application/ports/message_bus_ports.rs::MessageBusEvent`. + switch (params.event) { + case 'file_created': + handlers.onFileCreated?.(params.data as FileCreatedData); + return; + case 'file_renamed': + handlers.onFileRenamed?.(params.data as FileRenamedData); + return; + case 'file_moved': + handlers.onFileMoved?.(params.data as FileMovedData); + return; + case 'file_deleted': + handlers.onFileDeleted?.(params.data as FileDeletedData); + return; + case 'folder_created': + handlers.onFolderCreated?.(params.data as FolderCreatedData); + return; + case 'folder_renamed': + handlers.onFolderRenamed?.(params.data as FolderRenamedData); + return; + case 'folder_moved': + handlers.onFolderMoved?.(params.data as FolderMovedData); + return; + case 'folder_deleted': + handlers.onFolderDeleted?.(params.data as FolderDeletedData); + return; + } +} diff --git a/frontend/src/lib/composables/useTopic.svelte.ts b/frontend/src/lib/composables/useTopic.svelte.ts new file mode 100644 index 00000000..cf5fe4a9 --- /dev/null +++ b/frontend/src/lib/composables/useTopic.svelte.ts @@ -0,0 +1,40 @@ +// Svelte 5 rune wrapper around `messageBus.subscribe`. +// +// Call from a component's initialisation phase — `$effect` handles the +// mount/unmount lifecycle so the caller never sees the underlying +// WebSocket or the refcount plumbing. Two subscribers of the same +// topic share one wire subscription automatically (refcount lives in +// `MessageBusClient`). +// +// Reactive `topic`: pass a `$derived` or a getter and the composable +// re-subscribes when it changes. Static `topic`: pass a plain string. + +import { messageBus } from '$lib/message-bus/client.svelte'; +import type RtEventParams from '$lib/generated/message-bus/RtEventParams'; +import type RtRevokedParams from '$lib/generated/message-bus/RtRevokedParams'; + +/** + * Subscribe to `topic` for the lifetime of the calling component. + * + * Accepts `topic` as either a plain string or a getter — pass a + * function returning the current topic when it's reactive (e.g. + * derived from a route param) and `$effect` will re-subscribe when + * the returned value changes. A `null` value means "not subscribed + * right now" — useful during route load before the folder id is known. + * + * `onRevoked` fires when the server evicts the subscription (grant + * revoked, folder deleted, etc.); by then the local state is already + * cleared, so the handler can safely re-subscribe or navigate away. + */ +export function useTopic( + topic: string | null | (() => string | null), + onEvent: (params: RtEventParams) => void, + onRevoked?: (params: RtRevokedParams) => void +): void { + $effect(() => { + const resolved = typeof topic === 'function' ? topic() : topic; + if (!resolved) return; + const release = messageBus.subscribe(resolved, onEvent, onRevoked); + return () => release(); + }); +} diff --git a/frontend/src/lib/message-bus/client.svelte.ts b/frontend/src/lib/message-bus/client.svelte.ts new file mode 100644 index 00000000..58d1045c --- /dev/null +++ b/frontend/src/lib/message-bus/client.svelte.ts @@ -0,0 +1,418 @@ +// Message-bus WebSocket client — one connection per tab. +// +// Owns the single `/api/rt/ws` connection, refcounted per-topic +// subscriptions, JSON-RPC request/response correlation, and reconnect +// with jittered exponential backoff. Consumers reach for this through +// the `useTopic` / `useFolderTopic` composables and never see the +// connection directly. +// +// Related files: +// * `frames.ts` — JSON-RPC framing (pure functions). +// * `error-codes.ts` — named constants for `RtErrorObject.code`. +// * `$lib/composables/useTopic.svelte.ts` — per-component lifecycle. +// * `$lib/generated/message-bus/` — wire DTOs (Modelina, auto). +// +// Auth: same-origin WS carries the session cookie automatically. DPoP- +// required deployments need the ticket flow (Phase F, deferred); the +// unauthenticated close is surfaced through `state = 'disconnected'` +// and the console logger so users can diagnose without a redeploy. + +import log from 'loglevel'; + +import { RtErrorCode } from './error-codes'; +import { + parseIncoming, + pingFrame, + subscribeFrame, + unsubscribeFrame, + type IncomingFrame +} from './frames'; +import type RtEventParams from '$lib/generated/message-bus/RtEventParams'; +import type RtRevokedParams from '$lib/generated/message-bus/RtRevokedParams'; + +/** Logger namespace — matches `frontend/AGENTS.md § Logging`. Users + * tune with `oxi.setLogLevel('oxi:message-bus', 'debug')`. */ +const busLog = log.getLogger('oxi:message-bus'); + +/** Reactive connection state. `idle` before the first `subscribe`; + * `connecting` while the handshake is in flight; `connected` once + * the server has accepted the upgrade; `disconnected` after any + * close (reconnect fires from the client). */ +export type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected'; + +/** Callback invoked for every `rt.event` notification on a topic. */ +export type EventHandler = (params: RtEventParams) => void; + +/** Callback invoked when the server sends `rt.revoked` for a topic — + * the subscription is already gone server-side by the time the frame + * arrives; the client removes it from the local refcount map and + * fires this so the consumer can toast / redirect / whatever. */ +export type RevokedHandler = (params: RtRevokedParams) => void; + +/** Handle returned by `subscribe`. Call to release one refcount on the + * topic; the client unsubscribes over the wire only when the last + * refcount drops. Idempotent — calling twice from the same subscriber + * is safe (second call is a no-op). */ +export type UnsubscribeHandle = () => void; + +/** + * Shape returned by a rejected JSON-RPC call. Structurally a superset + * of `RtErrorObject` — every server-side error slots in, and this + * type also lets the client raise synthetic errors (`ws_closed`, + * `send_failed`, `not_connected`) whose `message` is a plain string + * outside the wire's `RtErrorMessage` enum. + */ +export interface MessageBusError { + code: number; + message: string; + data?: unknown; +} + +/** Reconnect backoff — 250 ms doubling with full jitter, capped at 30 s. + * Same shape as the HTTP retry we use in the fetch interceptor. */ +const RECONNECT_MIN_MS = 250; +const RECONNECT_MAX_MS = 30_000; + +interface SubEntry { + count: number; + handlers: Set; + revokedHandlers: Set; + /** True once the server has ack'd `rt.subscribe`. Used by + * reconnect: on wire-up we re-send every already-ack'd topic. */ + acked: boolean; +} + +interface PendingCall { + resolve: (result: unknown) => void; + reject: (error: MessageBusError) => void; +} + +export class MessageBusClient { + /** Reactive connection state — exposed for a debug indicator or + * Playwright test. Not consumed by the composables directly. */ + state = $state('idle'); + /** Last observed round-trip in ms, updated on each `rt.pong`. + * `null` until the first ping completes. */ + latencyMs = $state(null); + + #ws: WebSocket | null = null; + /** Backoff for the NEXT reconnect attempt. Reset to + * `RECONNECT_MIN_MS` on every successful open. */ + #backoffMs = RECONNECT_MIN_MS; + /** setTimeout handle for a scheduled reconnect. Cleared on + * explicit `close()` so we don't reconnect after teardown. */ + #reconnectTimer: ReturnType | null = null; + + /** `topic` → `{count, handlers, revokedHandlers, acked}`. Refcount + * drives the wire: first refcount ⇒ send `rt.subscribe`; last drop + * ⇒ send `rt.unsubscribe`. Plain `Map` (not `SvelteMap`) — this is + * internal plumbing keyed by topic string; a reactive collection + * would re-run every component's `$effect` on unrelated + * subscribes. */ + // eslint-disable-next-line svelte/prefer-svelte-reactivity + #subs = new Map(); + /** Pending JSON-RPC requests keyed by id. Same rationale as + * `#subs` — internal state, not reactive. */ + // eslint-disable-next-line svelte/prefer-svelte-reactivity + #pending = new Map(); + #nextId = 1; + + /** URL for the WebSocket. Injectable so tests can point at a mock. */ + #url: string; + /** WebSocket constructor. Injectable for the same reason. */ + #WebSocketCtor: typeof WebSocket; + + constructor(opts?: { url?: string; WebSocketCtor?: typeof WebSocket }) { + // Default to same-origin `/api/rt/ws`. `location` is unavailable + // in SSR; the client is instantiated lazily on first `subscribe` + // so this executes in the browser. + const defaultUrl = () => { + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${proto}//${window.location.host}/api/rt/ws`; + }; + this.#url = opts?.url ?? (typeof window !== 'undefined' ? defaultUrl() : ''); + this.#WebSocketCtor = opts?.WebSocketCtor ?? WebSocket; + } + + /** + * Refcounted subscribe. Adds `onEvent` (and optional `onRevoked`) + * to the local handlers for `topic`, sends `rt.subscribe` on the + * wire only for the first ref, and returns an unsubscribe fn that + * drops that same ref (last ref out sends `rt.unsubscribe`). + */ + subscribe(topic: string, onEvent: EventHandler, onRevoked?: RevokedHandler): UnsubscribeHandle { + let entry = this.#subs.get(topic); + if (!entry) { + // Plain Sets: internal callback registries, not reactive. Same + // rationale as `#subs` / `#pending` — see the doc there. + entry = { + count: 0, + // eslint-disable-next-line svelte/prefer-svelte-reactivity + handlers: new Set(), + // eslint-disable-next-line svelte/prefer-svelte-reactivity + revokedHandlers: new Set(), + acked: false + }; + this.#subs.set(topic, entry); + } + entry.count += 1; + entry.handlers.add(onEvent); + if (onRevoked) entry.revokedHandlers.add(onRevoked); + + // Kick the connection if nothing is holding it yet, otherwise + // send `rt.subscribe` if this is the first ref on this topic. + if (this.state === 'idle' || this.state === 'disconnected') { + this.#connect(); + } else if (entry.count === 1 && this.state === 'connected') { + this.#sendSubscribe(topic).catch((err) => + busLog.warn('subscribe failed', { topic, error: err }) + ); + } + + let released = false; + return () => { + if (released) return; + released = true; + this.#releaseOne(topic, onEvent, onRevoked); + }; + } + + /** Force a fresh reconnect — for a live-updates toggle or a manual + * "reconnect" button. Rare; not part of the normal flow. */ + reconnect(): void { + if (this.#ws) this.#ws.close(); + this.#backoffMs = RECONNECT_MIN_MS; + this.#scheduleReconnect(0); + } + + /** Tear down. Currently only meaningful in tests — the singleton + * lives for the lifetime of the tab. */ + close(): void { + if (this.#reconnectTimer !== null) { + clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = null; + } + if (this.#ws) { + this.#ws.close(); + this.#ws = null; + } + this.state = 'idle'; + this.#subs.clear(); + this.#pending.clear(); + } + + // ─────────────────────── connection lifecycle ──────────────────── + + #connect(): void { + if (this.state === 'connecting' || this.state === 'connected') return; + this.state = 'connecting'; + busLog.debug('connecting', { url: this.#url }); + let ws: WebSocket; + try { + ws = new this.#WebSocketCtor(this.#url); + } catch (err) { + busLog.warn('WebSocket ctor threw — reconnect scheduled', { error: err }); + this.state = 'disconnected'; + this.#scheduleReconnect(); + return; + } + this.#ws = ws; + ws.onopen = () => this.#onOpen(); + ws.onmessage = (ev) => this.#onMessage(ev); + ws.onerror = (ev) => busLog.debug('ws error event', { ev }); + ws.onclose = (ev) => this.#onClose(ev); + } + + #onOpen(): void { + busLog.debug('connected'); + this.state = 'connected'; + this.#backoffMs = RECONNECT_MIN_MS; + // Replay every already-known topic. `entry.acked` is reset here + // because the fresh connection has no server-side memory of + // prior subscriptions. + for (const [topic, entry] of this.#subs) { + entry.acked = false; + this.#sendSubscribe(topic).catch((err) => + busLog.warn('resubscribe failed', { topic, error: err }) + ); + } + } + + #onMessage(ev: MessageEvent): void { + if (typeof ev.data !== 'string') { + // Binary frames are the Yjs sync protocol (Phase G) — not in + // scope yet. Silently drop; a future collab store will + // receive them via a separate handler. + busLog.debug('binary frame dropped (Phase G)'); + return; + } + const frame = parseIncoming(ev.data); + this.#dispatch(frame); + } + + #dispatch(frame: IncomingFrame): void { + switch (frame.kind) { + case 'event': { + const entry = this.#subs.get(frame.params.topic); + if (!entry) { + busLog.debug('event for unknown topic', { topic: frame.params.topic }); + return; + } + for (const handler of entry.handlers) { + try { + handler(frame.params); + } catch (err) { + busLog.warn('event handler threw', { topic: frame.params.topic, error: err }); + } + } + break; + } + case 'revoked': { + const entry = this.#subs.get(frame.params.topic); + if (!entry) { + busLog.debug('revoked for unknown topic', { topic: frame.params.topic }); + return; + } + busLog.warn('subscription revoked', { + topic: frame.params.topic, + reason: frame.params.reason + }); + // Server-side sub is already gone; drop local state + // BEFORE firing consumer handlers so any handler that + // re-subscribes gets a fresh entry with `count = 1`. + const revokedHandlers = [...entry.revokedHandlers]; + this.#subs.delete(frame.params.topic); + for (const handler of revokedHandlers) { + try { + handler(frame.params); + } catch (err) { + busLog.warn('revoked handler threw', { topic: frame.params.topic, error: err }); + } + } + break; + } + case 'success': { + const pending = this.#pending.get(frame.id); + if (!pending) return; + this.#pending.delete(frame.id); + pending.resolve(frame.result); + break; + } + case 'error': { + busLog.warn('rt.error', { id: frame.id, error: frame.error }); + if (frame.id === null) return; + const pending = this.#pending.get(frame.id); + if (!pending) return; + this.#pending.delete(frame.id); + pending.reject(frame.error); + break; + } + case 'ignore': { + busLog.warn('ignored frame', { reason: frame.reason, raw: frame.raw }); + break; + } + } + } + + #onClose(ev: CloseEvent): void { + busLog.debug('close', { code: ev.code, reason: ev.reason }); + this.#ws = null; + this.state = 'disconnected'; + // Reject every pending call — the caller sees a synthetic + // error rather than hanging. Reconnect will re-issue the + // subscribe via `#onOpen`, not by resolving these. + const closed: MessageBusError = { code: RtErrorCode.INTERNAL_ERROR, message: 'ws_closed' }; + for (const pending of this.#pending.values()) pending.reject(closed); + this.#pending.clear(); + // Only reconnect if we still have subscribers waiting. + if (this.#subs.size > 0) this.#scheduleReconnect(); + } + + #scheduleReconnect(overrideMs?: number): void { + if (this.#reconnectTimer !== null) return; + const delay = overrideMs ?? this.#backoffMs; + // Full jitter — random in [0, backoff]. Prevents thundering + // herd if the server was momentarily overloaded. + const jittered = Math.floor(Math.random() * (delay + 1)); + busLog.warn('reconnect scheduled', { attemptBackoffMs: delay, jitteredMs: jittered }); + this.#reconnectTimer = setTimeout(() => { + this.#reconnectTimer = null; + this.#backoffMs = Math.min(this.#backoffMs * 2, RECONNECT_MAX_MS); + this.#connect(); + }, jittered); + } + + // ─────────────────────── request/response ──────────────────────── + + #sendSubscribe(topic: string): Promise { + return this.#call((id) => subscribeFrame(id, topic)).then((result) => { + const entry = this.#subs.get(topic); + if (entry) entry.acked = true; + busLog.debug('subscribed', { topic, result }); + }); + } + + #sendUnsubscribe(topic: string): Promise { + // Fire-and-forget — the server accepts idempotently. Not chained + // on the promise because by the time we send this the caller + // has already cleaned up its local state. + return this.#call((id) => unsubscribeFrame(id, topic)).then(() => { + busLog.debug('unsubscribed', { topic }); + }); + } + + /** Public latency probe. Sends `rt.ping` and updates `latencyMs`. */ + async ping(): Promise { + const started = performance.now(); + await this.#call((id) => pingFrame(id)); + const elapsed = Math.round(performance.now() - started); + this.latencyMs = elapsed; + return elapsed; + } + + #call(makeFrame: (id: number) => object): Promise { + if (this.state !== 'connected' || !this.#ws) { + const err: MessageBusError = { + code: RtErrorCode.INTERNAL_ERROR, + message: 'not_connected' + }; + return Promise.reject(err); + } + const id = this.#nextId++; + const frame = makeFrame(id); + return new Promise((resolve, reject) => { + this.#pending.set(id, { resolve, reject }); + try { + this.#ws!.send(JSON.stringify(frame)); + } catch (err) { + this.#pending.delete(id); + busLog.warn('send failed', { id, error: err }); + reject({ code: RtErrorCode.INTERNAL_ERROR, message: 'send_failed' }); + } + }); + } + + // ─────────────────────── refcount teardown ──────────────────────── + + #releaseOne(topic: string, onEvent: EventHandler, onRevoked?: RevokedHandler): void { + const entry = this.#subs.get(topic); + if (!entry) return; + entry.handlers.delete(onEvent); + if (onRevoked) entry.revokedHandlers.delete(onRevoked); + entry.count -= 1; + if (entry.count > 0) return; + this.#subs.delete(topic); + if (this.state === 'connected' && entry.acked) { + void this.#sendUnsubscribe(topic).catch(() => { + // Server drops idempotently; nothing to do if it errors. + }); + } + } +} + +/** + * Process-wide singleton — one WebSocket per tab. Lazy: nothing opens + * until the first `subscribe`. Exported for `useTopic` to consume; + * app code should reach for the composables instead. + */ +export const messageBus = new MessageBusClient(); diff --git a/frontend/src/lib/message-bus/frames.test.ts b/frontend/src/lib/message-bus/frames.test.ts new file mode 100644 index 00000000..75bd84d1 --- /dev/null +++ b/frontend/src/lib/message-bus/frames.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import { parseIncoming, pingFrame, subscribeFrame, unsubscribeFrame } from './frames'; + +describe('frame builders', () => { + it('subscribeFrame produces a valid JSON-RPC 2.0 request', () => { + expect(subscribeFrame(7, 'folder:abc')).toEqual({ + jsonrpc: '2.0', + id: 7, + method: 'rt.subscribe', + params: { topic: 'folder:abc' } + }); + }); + + it('unsubscribeFrame mirrors the subscribe shape', () => { + expect(unsubscribeFrame(8, 'folder:abc')).toEqual({ + jsonrpc: '2.0', + id: 8, + method: 'rt.unsubscribe', + params: { topic: 'folder:abc' } + }); + }); + + it('pingFrame omits params entirely (matches the wire spec)', () => { + const frame = pingFrame(9); + expect(frame).toEqual({ jsonrpc: '2.0', id: 9, method: 'rt.ping' }); + expect('params' in frame).toBe(false); + }); +}); + +describe('parseIncoming', () => { + it('recognises an `rt.event` notification', () => { + const raw = JSON.stringify({ + jsonrpc: '2.0', + method: 'rt.event', + params: { + topic: 'folder:abc', + event: 'file_created', + data: { file_id: 'x', name: 'a.txt', parent_id: 'abc', actor: 'me' } + } + }); + const result = parseIncoming(raw); + expect(result.kind).toBe('event'); + if (result.kind === 'event') { + expect(result.params.topic).toBe('folder:abc'); + expect(result.params.event).toBe('file_created'); + } + }); + + it('recognises an `rt.revoked` notification', () => { + const raw = JSON.stringify({ + jsonrpc: '2.0', + method: 'rt.revoked', + params: { topic: 'folder:abc', reason: 'grant_revoked' } + }); + const result = parseIncoming(raw); + expect(result.kind).toBe('revoked'); + if (result.kind === 'revoked') expect(result.params.topic).toBe('folder:abc'); + }); + + it('recognises a success response', () => { + const raw = JSON.stringify({ + jsonrpc: '2.0', + id: 42, + result: { subscribed: 'folder:abc' } + }); + const result = parseIncoming(raw); + expect(result.kind).toBe('success'); + if (result.kind === 'success') { + expect(result.id).toBe(42); + expect(result.result).toEqual({ subscribed: 'folder:abc' }); + } + }); + + it('recognises an error response and preserves the code', () => { + const raw = JSON.stringify({ + jsonrpc: '2.0', + id: 42, + error: { code: -32001, message: 'no_read', data: { topic: 'folder:xyz' } } + }); + const result = parseIncoming(raw); + expect(result.kind).toBe('error'); + if (result.kind === 'error') { + expect(result.id).toBe(42); + expect(result.error.code).toBe(-32001); + expect(result.error.message).toBe('no_read'); + } + }); + + it('collapses malformed frames to `ignore` with a stable reason key', () => { + expect(parseIncoming('not-json').kind).toBe('ignore'); + expect(parseIncoming('[]').kind).toBe('ignore'); + expect(parseIncoming(JSON.stringify({ jsonrpc: '1.0', method: 'rt.event' })).kind).toBe( + 'ignore' + ); + expect(parseIncoming(JSON.stringify({ jsonrpc: '2.0', method: 'rt.unknown' })).kind).toBe( + 'ignore' + ); + }); + + it('never throws — always returns a discriminated result', () => { + // Random shapes that used to trigger throws in earlier drafts. + const cases: string[] = ['', 'null', '42', '{}', '{"jsonrpc":"2.0"}']; + for (const c of cases) { + expect(() => parseIncoming(c)).not.toThrow(); + } + }); +}); diff --git a/frontend/src/lib/message-bus/frames.ts b/frontend/src/lib/message-bus/frames.ts new file mode 100644 index 00000000..800a1988 --- /dev/null +++ b/frontend/src/lib/message-bus/frames.ts @@ -0,0 +1,110 @@ +// JSON-RPC 2.0 frame builders + parsers for the message bus. +// +// Pure functions — no I/O, no state, no side effects. Sits between +// `client.svelte.ts` (owns the WebSocket + subscription refcounts) and +// the generated wire DTOs under `$lib/generated/message-bus/`. Keeping +// the framing logic isolated makes it directly unit-testable and keeps +// `client.svelte.ts` focused on lifecycle. +// +// One-way import direction: this file reads from `$lib/generated/…`; +// nothing under `generated/` imports from here. + +import type RtSubscribeRequestBody from '$lib/generated/message-bus/RtSubscribeRequestBody'; +import type RtUnsubscribeRequestBody from '$lib/generated/message-bus/RtUnsubscribeRequestBody'; +import type RtPingRequestBody from '$lib/generated/message-bus/RtPingRequestBody'; +import type RtEventParams from '$lib/generated/message-bus/RtEventParams'; +import type RtRevokedParams from '$lib/generated/message-bus/RtRevokedParams'; +import type RtErrorObject from '$lib/generated/message-bus/RtErrorObject'; + +/** + * Discriminated result of parsing one text frame off the wire. + * + * A well-formed frame lands as `event`, `revoked`, `success`, or + * `error`. Anything the client should silently drop (a malformed + * payload, an unknown notification method, a frame with the wrong + * `jsonrpc` version) collapses to `ignore` with a `reason` so the + * logger can surface it at `warn` without the caller having to + * distinguish. + */ +export type IncomingFrame = + | { kind: 'event'; params: RtEventParams } + | { kind: 'revoked'; params: RtRevokedParams } + | { kind: 'success'; id: number; result: unknown } + | { kind: 'error'; id: number | null; error: RtErrorObject } + | { kind: 'ignore'; reason: string; raw: unknown }; + +/** JSON-RPC subscribe request. `id` correlates the eventual success/error. */ +export function subscribeFrame(id: number, topic: string): RtSubscribeRequestBody { + return { + jsonrpc: '2.0', + id, + method: 'rt.subscribe', + params: { topic } + }; +} + +/** JSON-RPC unsubscribe request. */ +export function unsubscribeFrame(id: number, topic: string): RtUnsubscribeRequestBody { + return { + jsonrpc: '2.0', + id, + method: 'rt.unsubscribe', + params: { topic } + }; +} + +/** JSON-RPC application-level ping. The server also issues protocol-level + * RFC 6455 Pings on its own timer (keepalive); this request is available + * for the client to probe round-trip latency on demand. */ +export function pingFrame(id: number): RtPingRequestBody { + return { jsonrpc: '2.0', id, method: 'rt.ping' }; +} + +/** + * Parse one inbound text frame. Never throws — every unrecoverable + * shape maps to `{kind: 'ignore', reason, raw}` so the caller can log + * once and move on. The caller decides whether an ignored frame is + * noise (double-ping) or a bug (unknown method). + */ +export function parseIncoming(raw: string): IncomingFrame { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { kind: 'ignore', reason: 'not_json', raw }; + } + if (!isJsonObject(parsed)) { + return { kind: 'ignore', reason: 'not_object', raw }; + } + if (parsed.jsonrpc !== '2.0') { + return { kind: 'ignore', reason: 'wrong_jsonrpc_version', raw }; + } + + // Notification (server → client, no id). + if (typeof parsed.method === 'string') { + if (parsed.method === 'rt.event' && isJsonObject(parsed.params)) { + return { kind: 'event', params: parsed.params as unknown as RtEventParams }; + } + if (parsed.method === 'rt.revoked' && isJsonObject(parsed.params)) { + return { kind: 'revoked', params: parsed.params as unknown as RtRevokedParams }; + } + return { kind: 'ignore', reason: `unknown_method:${parsed.method}`, raw }; + } + + // Response to one of our requests. + const id = typeof parsed.id === 'number' ? parsed.id : null; + if (parsed.error !== undefined) { + if (!isJsonObject(parsed.error)) { + return { kind: 'ignore', reason: 'error_not_object', raw }; + } + return { kind: 'error', id, error: parsed.error as unknown as RtErrorObject }; + } + if (parsed.result !== undefined && id !== null) { + return { kind: 'success', id, result: parsed.result }; + } + return { kind: 'ignore', reason: 'malformed_response', raw }; +} + +function isJsonObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index e4d9056f..3b4cf19b 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -52,6 +52,8 @@ type GroupByDef as RLGroupByDef } from '$lib/components/ResourceList.svelte'; import { lazyComponent } from '$lib/composables/lazyComponent.svelte'; + import { useFolderTopic } from '$lib/composables/useFolderTopic.svelte'; + import log from 'loglevel'; import { t } from '$lib/i18n/index.svelte'; import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte'; import { drives as drivesStore } from '$lib/stores/drives.svelte'; @@ -61,6 +63,11 @@ import { dateBucket, sizeBucket, typeLabel } from '$lib/stores/files.svelte'; import { replaceSet } from '$lib/utils/sets'; + // Message-bus logger. Users can tune with + // oxi.setLogLevel('oxi:message-bus', 'debug') + // See `frontend/AGENTS.md § Logging`. + const busLog = log.getLogger('oxi:message-bus'); + // File preview and the WOPI editor are heavy and only appear on demand, so // their modules load the first time the user opens one (see the effects that // call `.load()` when `viewerOpen` / `wopiOpen` flip true). @@ -430,6 +437,56 @@ } } + // ── Live folder updates (message bus) ──────────────────────────── + // Subscribe to `folder:{currentId}` and refresh when another tab — + // or another user with a share — mutates something in this folder. + // The refresh call is coalesced through `#reloadScheduled` so a + // burst of events (e.g. a multi-file upload) collapses to a single + // fetch. Local mutations trigger `reload()` themselves, so events + // authored by this same user are dropped as echo (the `actor` on + // the event is the caller UUID from the server). + // + // See `docs/plan/message-bus.md § D` and the `useFolderTopic` + // composable for the wiring. + let reloadScheduled = false; + function scheduleLiveReload(actor: string): void { + // Actor echo: this same session's mutations already updated the + // listing through their own success path, so a re-fetch would + // only cost a round-trip. Other tabs of the same user still see + // the change (they render from their own state, not this one). + if (session.user?.id && actor === session.user.id) return; + if (reloadScheduled) return; + reloadScheduled = true; + // Coalesce a burst; 100 ms is enough for the tail of a multi- + // event upload without feeling laggy. + setTimeout(() => { + reloadScheduled = false; + void reload(); + }, 100); + } + useFolderTopic(() => currentId, { + onFileCreated: (d) => scheduleLiveReload(d.actor), + onFileRenamed: (d) => scheduleLiveReload(d.actor), + onFileMoved: (d) => scheduleLiveReload(d.actor), + onFileDeleted: (d) => scheduleLiveReload(d.actor), + onFolderCreated: (d) => scheduleLiveReload(d.actor), + onFolderRenamed: (d) => scheduleLiveReload(d.actor), + onFolderMoved: (d) => scheduleLiveReload(d.actor), + onFolderDeleted: (d) => scheduleLiveReload(d.actor), + onRevoked: (params) => { + // The subscription is already gone server-side. Notify the + // user and send them back to their home so they don't sit + // on a stale folder view with no way to know why updates + // stopped. + ui.notify( + t('files.folder_access_revoked', 'Your access to this folder was revoked.'), + 'warning' + ); + busLog.warn('folder access revoked', { topic: params.topic, reason: params.reason }); + void goto(resolve('/files')); + } + }); + function openFolder(folder: FolderItem) { // Canonical single-id URL. Legacy `/files/A/B/C` still resolves // (canonicalize-on-load rewrites it inside `load()`), but new From 75a123ae6c7cb314195f60a0ce98ae831dc63eee Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 11 Sep 2026 03:05:45 +0200 Subject: [PATCH 12/21] feat(msg-bus): add DPoP support, fix floow from client, correct deletion --- frontend/src/lib/message-bus/client.svelte.ts | 194 ++++++++++++--- frontend/vite.config.ts | 11 +- src/application/services/trash_service.rs | 69 ++++++ src/bin/generate-asyncapi.rs | 46 +++- src/bin/rt-hurl-helper.rs | 96 ++++++-- src/common/di.rs | 55 +++-- .../services/in_process_message_bus.rs | 42 ++++ src/infrastructure/services/mod.rs | 1 + .../services/rt_ticket_store.rs | 229 ++++++++++++++++++ src/interfaces/api/handlers/mod.rs | 1 + .../api/handlers/rt_ticket_handler.rs | 80 ++++++ src/interfaces/api/handlers/rt_ws.rs | 139 +++++++++-- src/interfaces/api/routes.rs | 22 +- src/main.rs | 16 ++ tests/api/rt_bus_check.sh | 142 +++++++++-- 15 files changed, 1025 insertions(+), 118 deletions(-) create mode 100644 src/infrastructure/services/rt_ticket_store.rs create mode 100644 src/interfaces/api/handlers/rt_ticket_handler.rs diff --git a/frontend/src/lib/message-bus/client.svelte.ts b/frontend/src/lib/message-bus/client.svelte.ts index 58d1045c..a381ae30 100644 --- a/frontend/src/lib/message-bus/client.svelte.ts +++ b/frontend/src/lib/message-bus/client.svelte.ts @@ -18,7 +18,10 @@ // and the console logger so users can diagnose without a redeploy. import log from 'loglevel'; +import { untrack } from 'svelte'; +import { apiJson } from '$lib/api/client'; +import { getCsrfHeaders } from '$lib/api/csrf'; import { RtErrorCode } from './error-codes'; import { parseIncoming, @@ -30,6 +33,20 @@ import { import type RtEventParams from '$lib/generated/message-bus/RtEventParams'; import type RtRevokedParams from '$lib/generated/message-bus/RtRevokedParams'; +/** Response body from `POST /api/rt/ticket`. Matches the Rust + * `RtTicketResponse` shape — see `handlers/rt_ticket_handler.rs`. */ +interface RtTicketResponse { + /** Opaque ticket UUID. Redeemed once server-side. */ + ticket: string; + /** Seconds until server-side expiry (informational; the client + * should open the WS immediately). */ + expires_in_seconds: number; + /** Full `Sec-WebSocket-Protocol` value the client MUST pass on + * the upgrade — assembled server-side so a FE bug can't emit + * the wrong prefix. */ + subprotocol: string; +} + /** Logger namespace — matches `frontend/AGENTS.md § Logging`. Users * tune with `oxi.setLogLevel('oxi:message-bus', 'debug')`. */ const busLog = log.getLogger('oxi:message-bus'); @@ -73,6 +90,16 @@ export interface MessageBusError { const RECONNECT_MIN_MS = 250; const RECONNECT_MAX_MS = 30_000; +/** Circuit breaker — after N consecutive failed attempts (either a + * ticket-exchange rejection or a WS close before `onopen` fires), + * give up and stay `disconnected` until the caller explicitly asks + * to `reconnect()`. Prevents an unrecoverable auth state (revoked + * session, wrong CSRF cookie, missing DPoP nonce) from flooding + * logs. Ten attempts × exponential-backoff-with-jitter is roughly a + * minute of trying — long enough for a transient blip, short enough + * to stop before it's noise. */ +const MAX_CONSECUTIVE_FAILURES = 10; + interface SubEntry { count: number; handlers: Set; @@ -102,6 +129,11 @@ export class MessageBusClient { /** setTimeout handle for a scheduled reconnect. Cleared on * explicit `close()` so we don't reconnect after teardown. */ #reconnectTimer: ReturnType | null = null; + /** Consecutive failures — incremented on every attempt that dies + * before `#onOpen()` gets to reset it. Once it hits + * `MAX_CONSECUTIVE_FAILURES` the client stops reconnecting and + * requires an explicit `reconnect()` from the caller. */ + #consecutiveFailures = 0; /** `topic` → `{count, handlers, revokedHandlers, acked}`. Refcount * drives the wire: first refcount ⇒ send `rt.subscribe`; last drop @@ -139,49 +171,68 @@ export class MessageBusClient { * to the local handlers for `topic`, sends `rt.subscribe` on the * wire only for the first ref, and returns an unsubscribe fn that * drops that same ref (last ref out sends `rt.unsubscribe`). + * + * Wrapped in `untrack` because `this.state` is `$state`. Without + * this, a caller invoking `subscribe` from a Svelte `$effect` + * (which `useTopic` does) would take a reactive dep on `state`. + * Every `state` transition (idle → connecting → disconnected → + * connecting → …) would then re-fire the caller's `$effect`, + * which re-calls `subscribe`, which flips `state`, which re-fires + * the effect — a 1000+/s runaway loop, observed on server-down + * (2026-09-11). `subscribe` is a mutation entry point; its reads + * of internal state MUST NOT contaminate reactive callers. */ subscribe(topic: string, onEvent: EventHandler, onRevoked?: RevokedHandler): UnsubscribeHandle { - let entry = this.#subs.get(topic); - if (!entry) { - // Plain Sets: internal callback registries, not reactive. Same - // rationale as `#subs` / `#pending` — see the doc there. - entry = { - count: 0, - // eslint-disable-next-line svelte/prefer-svelte-reactivity - handlers: new Set(), - // eslint-disable-next-line svelte/prefer-svelte-reactivity - revokedHandlers: new Set(), - acked: false + return untrack(() => { + let entry = this.#subs.get(topic); + if (!entry) { + // Plain Sets: internal callback registries, not reactive. + // Same rationale as `#subs` / `#pending` — see the doc + // there. + entry = { + count: 0, + // eslint-disable-next-line svelte/prefer-svelte-reactivity + handlers: new Set(), + // eslint-disable-next-line svelte/prefer-svelte-reactivity + revokedHandlers: new Set(), + acked: false + }; + this.#subs.set(topic, entry); + } + entry.count += 1; + entry.handlers.add(onEvent); + if (onRevoked) entry.revokedHandlers.add(onRevoked); + + // Kick the connection if nothing is holding it yet, otherwise + // send `rt.subscribe` if this is the first ref on this topic. + if (this.state === 'idle' || this.state === 'disconnected') { + this.#connect(); + } else if (entry.count === 1 && this.state === 'connected') { + this.#sendSubscribe(topic).catch((err) => + busLog.warn('subscribe failed', { topic, error: err }) + ); + } + + let released = false; + return () => { + if (released) return; + released = true; + // Cleanup path — Svelte `$effect` cleanup doesn't track + // anyway, but stay defensive: untrack around the + // internal state reads inside #releaseOne. + untrack(() => this.#releaseOne(topic, onEvent, onRevoked)); }; - this.#subs.set(topic, entry); - } - entry.count += 1; - entry.handlers.add(onEvent); - if (onRevoked) entry.revokedHandlers.add(onRevoked); - - // Kick the connection if nothing is holding it yet, otherwise - // send `rt.subscribe` if this is the first ref on this topic. - if (this.state === 'idle' || this.state === 'disconnected') { - this.#connect(); - } else if (entry.count === 1 && this.state === 'connected') { - this.#sendSubscribe(topic).catch((err) => - busLog.warn('subscribe failed', { topic, error: err }) - ); - } - - let released = false; - return () => { - if (released) return; - released = true; - this.#releaseOne(topic, onEvent, onRevoked); - }; + }); } /** Force a fresh reconnect — for a live-updates toggle or a manual - * "reconnect" button. Rare; not part of the normal flow. */ + * "reconnect" button. Rare; not part of the normal flow. Also the + * escape hatch after the circuit breaker trips: zeroes the + * consecutive-failure counter so the next attempt actually fires. */ reconnect(): void { if (this.#ws) this.#ws.close(); this.#backoffMs = RECONNECT_MIN_MS; + this.#consecutiveFailures = 0; this.#scheduleReconnect(0); } @@ -207,9 +258,40 @@ export class MessageBusClient { if (this.state === 'connecting' || this.state === 'connected') return; this.state = 'connecting'; busLog.debug('connecting', { url: this.#url }); + // Ticket exchange runs off a Promise; the connection is + // finalised inside its `.then`. Errors during exchange land in + // `#onTicketFailure`, which mirrors the WS-close reconnect path + // so a transient auth blip retries with backoff. + void this.#exchangeAndOpen(); + } + + /** POST `/api/rt/ticket`, then open the WS with the returned + * subprotocol. The POST runs through `apiFetch` — DPoP proof + * and session cookie handled by the interceptor — and we attach + * the CSRF header ourselves per every state-changing endpoint's + * convention (see `endpoints/shares.ts` for the pattern). */ + async #exchangeAndOpen(): Promise { + let subprotocol: string; + try { + const res = await apiJson('/api/rt/ticket', { + method: 'POST', + headers: getCsrfHeaders() + }); + subprotocol = res.subprotocol; + busLog.debug('ticket issued', { expires_in_seconds: res.expires_in_seconds }); + } catch (err) { + this.#onTicketFailure(err); + return; + } + // A close/reconnect could have raced this in-flight exchange; + // bail if we lost the "connecting" role in the meantime. + if (this.state !== 'connecting') { + busLog.debug('ticket exchange raced with close — discarding', { state: this.state }); + return; + } let ws: WebSocket; try { - ws = new this.#WebSocketCtor(this.#url); + ws = new this.#WebSocketCtor(this.#url, [subprotocol]); } catch (err) { busLog.warn('WebSocket ctor threw — reconnect scheduled', { error: err }); this.state = 'disconnected'; @@ -223,10 +305,21 @@ export class MessageBusClient { ws.onclose = (ev) => this.#onClose(ev); } + /** Handle a failed ticket exchange. Same shape as a WS close — + * we're not going to retry inline (a bad auth state won't fix + * itself in 250 ms), so schedule the next attempt through the + * standard reconnect path. */ + #onTicketFailure(err: unknown): void { + busLog.warn('ticket exchange failed — reconnect scheduled', { error: err }); + this.state = 'disconnected'; + if (this.#subs.size > 0) this.#scheduleReconnect(); + } + #onOpen(): void { busLog.debug('connected'); this.state = 'connected'; this.#backoffMs = RECONNECT_MIN_MS; + this.#consecutiveFailures = 0; // Replay every already-known topic. `entry.acked` is reset here // because the fresh connection has no server-side memory of // prior subscriptions. @@ -258,6 +351,16 @@ export class MessageBusClient { busLog.debug('event for unknown topic', { topic: frame.params.topic }); return; } + // Trace each delivered event so devs can watch the bus + // live in the console. Level `debug` — silent under the + // default `warn`. See `frontend/AGENTS.md § Logging` + // for the tune knob (`oxi.setLogLevel('oxi:message-bus', + // 'debug')`). + busLog.debug('event received', { + topic: frame.params.topic, + kind: frame.params.event, + actor: (frame.params.data as { actor?: string })?.actor + }); for (const handler of entry.handlers) { try { handler(frame.params); @@ -330,11 +433,30 @@ export class MessageBusClient { #scheduleReconnect(overrideMs?: number): void { if (this.#reconnectTimer !== null) return; + this.#consecutiveFailures += 1; + // Circuit breaker: after too many failures in a row, stop + // retrying and require an explicit `reconnect()` call from + // the caller. Prevents a bad auth state (session revoked, + // CSRF cookie stripped, DPoP nonce mismatch) from flooding + // server logs with the same 401/403 forever. `reconnect()` + // zeroes the counter and re-arms. + if (this.#consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + busLog.error('circuit breaker tripped — reconnect suspended after too many failures', { + consecutiveFailures: this.#consecutiveFailures, + max: MAX_CONSECUTIVE_FAILURES, + remedy: 'call messageBus.reconnect() to retry, or refresh the page' + }); + return; + } const delay = overrideMs ?? this.#backoffMs; // Full jitter — random in [0, backoff]. Prevents thundering // herd if the server was momentarily overloaded. const jittered = Math.floor(Math.random() * (delay + 1)); - busLog.warn('reconnect scheduled', { attemptBackoffMs: delay, jitteredMs: jittered }); + busLog.warn('reconnect scheduled', { + attemptBackoffMs: delay, + jitteredMs: jittered, + consecutiveFailures: this.#consecutiveFailures + }); this.#reconnectTimer = setTimeout(() => { this.#reconnectTimer = null; this.#backoffMs = Math.min(this.#backoffMs * 2, RECONNECT_MAX_MS); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index fe16ba9b..68398657 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -34,8 +34,17 @@ const DEV_ORIGIN_HEADERS = { }; const p = (target: string) => ({ target, changeOrigin: true, headers: DEV_ORIGIN_HEADERS }); +// Same as `p()` but with WebSocket upgrade forwarding enabled. Vite's +// `http-proxy-middleware` treats HTTP and WS as two separate transports — +// without `ws: true` the upgrade request is silently dropped and the +// browser hangs in `readyState = CONNECTING` until Chrome's ~30 s +// handshake timeout fires. Needed for `/api/rt/ws` (message bus). Kept +// as a separate helper so paths that don't upgrade don't pay the extra +// listener setup. +const pWs = (target: string) => ({ ...p(target), ws: true }); + const proxy = { - '/api': p(BACKEND), + '/api': pWs(BACKEND), '/locales': p(BACKEND), '/.well-known': p(BACKEND), '/remote.php': p(BACKEND), diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index 0294ed16..576c71b9 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -14,6 +14,7 @@ use crate::application::dtos::trash_dto::{ }; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::file_lifecycle::FileLifecycleHook; +use crate::application::ports::message_bus_ports::{MessageBus, MessageBusEvent, Topic}; use crate::application::ports::storage_ports::FileWritePort; use crate::application::ports::trash_ports::TrashUseCase; use crate::common::errors::{DomainError, ErrorKind, Result}; @@ -71,6 +72,16 @@ pub struct TrashService { /// so trash listings filter by drive membership instead of the legacy /// per-user scope. drive_repo: Arc, + + /// Message bus — publishes `FolderDeleted` on `Topic::Folder(parent)` + /// after a folder is trashed, so subscribers of the parent folder's + /// live-view refresh. `None` when the bus isn't wired (tests / stubs). + /// File trash is intentionally NOT published here: the FE hits + /// `DELETE /api/files/{id}` directly (bypasses the trash service) + /// and `FileManagementService::delete_and_cleanup_with_perms` + /// publishes on that path. If a future endpoint routes file delete + /// through this service, add the file-arm publish here too. + bus: Option>, } impl TrashService { @@ -93,6 +104,7 @@ impl TrashService { content_cache, authz, drive_repo, + bus: None, } } @@ -102,6 +114,15 @@ impl TrashService { self } + /// Wire the message bus. Enables the `FolderDeleted` publish on + /// `Topic::Folder(parent)` after a folder is trashed — folder-live + /// views subscribe to the parent topic and refresh on receipt. + /// Silent no-op if never called (unit tests skip this). + pub fn with_message_bus(mut self, bus: Arc) -> Self { + self.bus = Some(bus); + self + } + /// Converts a TrashedItem entity to a DTO fn to_dto(&self, item: TrashedItem) -> TrashedItemDto { // Calculate days_until_deletion before moving item fields @@ -236,6 +257,28 @@ impl TrashUseCase for TrashService { ) .await?; + // Snapshot the parent BEFORE the trash UPDATE — the row + // still exists at this point (soft-delete flips + // `is_trashed`, keeps the parent_id). We need parent_id + // to publish `FolderDeleted` on `Topic::Folder(parent)` + // after commit, so subscribers of the folder view refresh. + // If the bus isn't wired, skip the read to save a query. + let parent_snapshot = if self.bus.is_some() { + match self.folder_storage_port.get_folder(item_id).await { + Ok(folder) => folder.parent_id().and_then(|s| Uuid::parse_str(s).ok()), + Err(e) => { + debug!("trash-folder parent lookup failed: {}", e); + None + } + } + } else { + None + }; + debug!( + "trash-folder parent snapshot for {}: {:?}", + item_id, parent_snapshot + ); + // Soft-delete model — same as the file branch above: the // cascade UPDATE below is the whole operation; no folder // fetch or trash-index write needed. @@ -253,6 +296,32 @@ impl TrashUseCase for TrashService { })?; debug!("Folder moved to trash: {}", item_id); + + // Bus publish AFTER the trash commits. Root folders + // have `parent_id = None`; the trash endpoint refuses + // those via the mount / drive-root guards, but keep + // the `Some` gate anyway so a future permissive path + // doesn't panic here. + if let (Some(bus), Some(parent_uuid)) = (&self.bus, parent_snapshot) { + debug!( + "publishing FolderDeleted folder={} parent={} actor={}", + folder_id, parent_uuid, user_id + ); + bus.publish( + &Topic::Folder(parent_uuid), + MessageBusEvent::FolderDeleted { + folder_id, + parent_id: parent_uuid, + actor: user_id, + }, + ); + } else { + debug!( + "trash-folder publish skipped: bus={} parent={:?}", + self.bus.is_some(), + parent_snapshot + ); + } Ok(()) } _ => Err(DomainError::validation_error(format!( diff --git a/src/bin/generate-asyncapi.rs b/src/bin/generate-asyncapi.rs index 769a62d6..45c5f75d 100644 --- a/src/bin/generate-asyncapi.rs +++ b/src/bin/generate-asyncapi.rs @@ -76,21 +76,32 @@ Phase C (sync-client push, album live) extend the same channels — see }, }, "protocolVersion": "13", - // Subprotocol advertised in the WS handshake. Handler - // accepts `oxi.rt.v1` and the optional bearer element - // `authorization.bearer.` alongside it. + // Subprotocol advertised in the WS handshake. The handler + // accepts one of two shapes: + // * `oxi.ticket.` — the browser path. Redeems a + // one-shot 30 s ticket minted by + // `POST /api/rt/ticket` (that endpoint runs under the + // full auth + DPoP stack, so the ticket effectively + // inherits the proofed session). + // * (no subprotocol) — falls back to + // `Authorization: Bearer `, used by programmatic + // clients that can set headers (e.g. rt-hurl-helper). "bindings": { - "ws": { "subProtocol": "oxi.rt.v1" } + "ws": { "subProtocol": "oxi.ticket.{ticket}" } }, - // Every request MUST be authenticated. Programmatic - // clients set `Authorization: Bearer ` on the WS - // upgrade (same header the REST API uses); browser - // clients — which can't set headers on `new WebSocket()` - // — will use the deferred ticket flow (a plain HTTP - // POST issues a short-lived one-shot ticket bound to - // the WS URL, see the plan's DPoP-gap section). + // Every request MUST be authenticated. Two paths: + // * `bearerAuth` — programmatic clients set + // `Authorization: Bearer ` on the WS upgrade + // (same header the REST API uses). + // * `ticketAuth` — browser clients POST + // `/api/rt/ticket` with full auth + DPoP, receive + // an opaque one-shot token, and pass it via + // `Sec-WebSocket-Protocol: oxi.ticket.` + // (browsers cannot set arbitrary headers on + // `new WebSocket()`). See `docs/plan/message-bus.md § F`. "security": [ - { "$ref": "#/components/securitySchemes/bearerAuth" } + { "$ref": "#/components/securitySchemes/bearerAuth" }, + { "$ref": "#/components/securitySchemes/ticketAuth" } ], } }, @@ -299,7 +310,16 @@ fn components() -> Value { "type": "http", "scheme": "bearer", "bearerFormat": "JWT", - "description": "OxiCloud JWT — same access_token minted by `POST /api/auth/login` (or the OPAQUE handshake). Programmatic clients set `Authorization: Bearer ` on the WS upgrade request. Browsers, which cannot set headers on `new WebSocket()`, will use the deferred ticket flow (`POST /api/rt/ticket` → short-lived one-shot ticket in the WS URL); see the plan's DPoP-gap section.", + "description": "OxiCloud JWT — same access_token minted by `POST /api/auth/login` (or the OPAQUE handshake). Programmatic clients set `Authorization: Bearer ` on the WS upgrade request. DPoP-bound tokens are refused on this path (the WS handshake cannot carry a DPoP proof); browsers use `ticketAuth` instead.", + }, + // `httpApiKey` (not bare `apiKey`) — AsyncAPI 3.0 reserves + // `apiKey` for server-variable-based schemes; a header- + // scoped key is `httpApiKey` with `in: header`. + "ticketAuth": { + "type": "httpApiKey", + "in": "header", + "name": "Sec-WebSocket-Protocol", + "description": "Browser path — the FE first calls `POST /api/rt/ticket` under the full REST middleware stack (auth + DPoP-proofed request), receives an opaque one-shot UUID with a 30 s TTL, then sets `Sec-WebSocket-Protocol: oxi.ticket.` on the WS upgrade. The server redeems the ticket (single-use — a second attempt fails) and treats the WS session as authenticated for the caller who issued it. See `docs/plan/message-bus.md § F` and `handlers/rt_ticket_handler.rs`.", } } }); diff --git a/src/bin/rt-hurl-helper.rs b/src/bin/rt-hurl-helper.rs index 136efbbf..db9d2170 100644 --- a/src/bin/rt-hurl-helper.rs +++ b/src/bin/rt-hurl-helper.rs @@ -62,12 +62,29 @@ use tokio_tungstenite::tungstenite::http::HeaderValue; struct Args { mode: Mode, url: String, - token: String, + /// Either `--token ` (Authorization: Bearer path — the original + /// helper flow) or `--ticket ` (Sec-WebSocket-Protocol path + /// — exercises F). Exactly one MUST be set; parse_args enforces. + auth: WsAuth, subscribe: Vec, expect_events: Option, reason: Option, timeout: Duration, output: Option, + /// Optional path the helper `touch`es the instant EVERY requested + /// `--subscribe` topic has been ack'd by the server. Shell tests + /// wait on this file before firing the upload that publishes to + /// the topic, closing the "sleep 0.4 hoping the subscribe landed + /// in time" race that occasionally dropped events on slow / + /// cold-cache runs. Off by default; only used by the smoke test. + ready_file: Option, +} + +/// How the helper authenticates the WS upgrade. Mirrors the two paths +/// `rt_ws_handler::authenticate_upgrade` accepts. +enum WsAuth { + Bearer(String), + Ticket(String), } enum Mode { @@ -105,11 +122,13 @@ fn parse_args() -> Result { let mut url = None; let mut token = None; + let mut ticket = None; let mut subscribe = Vec::new(); let mut expect_events = None; let mut reason = None; let mut timeout = Duration::from_secs(3); let mut output = None; + let mut ready_file = None; while let Some(flag) = it.next() { let value = it @@ -118,6 +137,7 @@ fn parse_args() -> Result { match flag.as_str() { "--url" => url = Some(value), "--token" => token = Some(value), + "--ticket" => ticket = Some(value), "--subscribe" => subscribe.push(value), "--expect-events" => { expect_events = Some( @@ -129,19 +149,31 @@ fn parse_args() -> Result { "--reason" => reason = Some(value), "--timeout" => timeout = parse_duration(&value)?, "--output" => output = Some(value), + "--ready-file" => ready_file = Some(value), other => return Err(format!("unknown flag: {other}")), } } + // Exactly one credential MUST be set. Emitting a specific error + // makes shell-script drift ("forgot to swap --token for --ticket") + // debuggable at a glance. + let auth = match (token, ticket) { + (Some(_), Some(_)) => return Err("pass exactly one of --token or --ticket".into()), + (Some(t), None) => WsAuth::Bearer(t), + (None, Some(t)) => WsAuth::Ticket(t), + (None, None) => return Err("--token or --ticket required".into()), + }; + Ok(Args { mode, url: url.ok_or("--url required")?, - token: token.ok_or("--token required")?, + auth, subscribe, expect_events, reason, timeout, output, + ready_file, }) } @@ -202,14 +234,20 @@ impl From for HelperError { // WS connection // ════════════════════════════════════════════════════════════════════════════ -/// Open a WS connection to `url` with the given bearer token attached -/// via `Authorization: Bearer `. Programmatic client — this is the -/// path native clients (this helper, future sync-client integrations) -/// take. Browser clients that can't set the header will use the -/// `Sec-WebSocket-Protocol` subprotocol fallback (Phase A follow-up). +/// Open a WS connection to `url` with the given [`WsAuth`] applied. +/// +/// - `Bearer(jwt)` sets `Authorization: Bearer ` on the upgrade +/// — the programmatic-client path. +/// - `Ticket(uuid)` sets `Sec-WebSocket-Protocol: oxi.ticket.` +/// — the browser-equivalent path used by F's smoke scenarios. +/// +/// The subprotocol prefix matches +/// `infrastructure::services::rt_ticket_store::SUBPROTOCOL_PREFIX`; kept +/// as a literal here so the test binary has no dependency on the +/// application crate. async fn connect_ws( url: &str, - token: &str, + auth: &WsAuth, ) -> Result< tokio_tungstenite::WebSocketStream>, HelperError, @@ -217,12 +255,24 @@ async fn connect_ws( let mut req = url .into_client_request() .map_err(|e| HelperError::Protocol(format!("bad url: {e}")))?; - let bearer = format!("Bearer {token}"); - req.headers_mut().insert( - "Authorization", - HeaderValue::from_str(&bearer) - .map_err(|e| HelperError::Protocol(format!("bad token: {e}")))?, - ); + match auth { + WsAuth::Bearer(token) => { + let bearer = format!("Bearer {token}"); + req.headers_mut().insert( + "Authorization", + HeaderValue::from_str(&bearer) + .map_err(|e| HelperError::Protocol(format!("bad token: {e}")))?, + ); + } + WsAuth::Ticket(ticket) => { + let subprotocol = format!("oxi.ticket.{ticket}"); + req.headers_mut().insert( + "Sec-WebSocket-Protocol", + HeaderValue::from_str(&subprotocol) + .map_err(|e| HelperError::Protocol(format!("bad ticket: {e}")))?, + ); + } + } let (ws, _resp) = tokio_tungstenite::connect_async(req) .await .map_err(|e| HelperError::Protocol(format!("connect failed: {e}")))?; @@ -241,7 +291,7 @@ async fn subscribe_and_collect(args: Args) -> Result<(), HelperError> { } let expect_events = args.expect_events.unwrap_or(0); - let mut ws = connect_ws(&args.url, &args.token).await?; + let mut ws = connect_ws(&args.url, &args.auth).await?; // Subscribe to every requested topic; track pending request ids so // we know when all acks have arrived before we start counting @@ -327,6 +377,20 @@ async fn subscribe_and_collect(args: Args) -> Result<(), HelperError> { if let Some(topic) = topic { subscribed.push(topic); } + // Every requested subscribe is now ack'd — signal the + // orchestrator that publishes targeted at these topics + // will land on a live subscriber. See `Args::ready_file` + // for the race this closes. Empty content is fine; the + // shell only checks existence, not payload. Errors are + // logged to stderr but not fatal: the smoke test's + // `wait_ready` timeout will surface the failure with + // more context than a mid-run panic here. + if pending_subs.is_empty() + && let Some(path) = args.ready_file.as_deref() + && let Err(e) = std::fs::write(path, b"") + { + eprintln!("rt-hurl-helper: could not touch --ready-file {path}: {e}"); + } continue; } @@ -391,7 +455,7 @@ async fn expect_denied(args: Args) -> Result<(), HelperError> { .ok_or_else(|| HelperError::Protocol("--subscribe required for expect-denied".into()))? .clone(); - let mut ws = connect_ws(&args.url, &args.token).await?; + let mut ws = connect_ws(&args.url, &args.auth).await?; let req_id: u64 = 1; let frame = json!({ diff --git a/src/common/di.rs b/src/common/di.rs index 78b62c2f..11a24558 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -1041,6 +1041,7 @@ impl AppServiceFactory { core: &CoreServices, authz: &Arc, drive_repo: &Arc, + bus: &Arc, ) -> Option> { if !self.config.features.enable_trash { tracing::info!("Trash service is disabled in configuration"); @@ -1049,7 +1050,12 @@ impl AppServiceFactory { let trash_repo = repos.trash_repository.as_ref()?; - // Wire ports directly to TrashService — no adapter layer needed + // Wire ports directly to TrashService — no adapter layer needed. + // Bus upcast to the trait object so the service takes the port, + // not the concrete impl — mirrors the pattern in + // `create_application_services`. + let bus_trait: Arc = + bus.clone(); let service = Arc::new( TrashService::new( trash_repo.clone(), @@ -1060,7 +1066,8 @@ impl AppServiceFactory { authz.clone(), drive_repo.clone(), ) - .with_file_deleted_hook(core.file_lifecycle.clone()), + .with_file_deleted_hook(core.file_lifecycle.clone()) + .with_message_bus(bus_trait), ); // Initialize cleanup service (bulk-deletes expired items in 2 SQL @@ -1742,9 +1749,29 @@ impl AppServiceFactory { let drive_repo = Arc::new(crate::infrastructure::repositories::pg::DrivePgRepository::new(pool.clone())); + // Message bus: constructed BEFORE the trash service so trash-first + // deletes can publish `FolderDeleted` on the parent folder's + // topic (folder-view live refresh). Wired with a no-op replicator + // — multi-instance broker is a follow-up per + // `docs/plan/message-bus.md § Roadmap`. Spawns its own GC task in + // `with_replicator`; no supervisor setup required. + let bus = crate::infrastructure::services::in_process_message_bus::InProcessMessageBus::with_replicator( + Arc::new(crate::application::ports::message_bus_ports::NoopReplicator), + ); + + // WebSocket ticket store — see `rt_ticket_store` module doc for + // why this exists (DPoP-bound sessions can't be re-proofed on + // a browser-issued WS upgrade). Reaper task runs for the app + // lifetime; its handle is dropped intentionally — the task + // survives on the runtime, and cancellation is handled by + // graceful shutdown killing the runtime. + let rt_ticket_store = + crate::infrastructure::services::rt_ticket_store::RtTicketStore::new(); + let _reaper = Arc::clone(&rt_ticket_store).spawn_reaper(); + // 3b. Trash service (needed before application services) let trash_service = self - .create_trash_service(&repos, &core, &authorization, &drive_repo) + .create_trash_service(&repos, &core, &authorization, &drive_repo, &bus) .await; // 3c. Storage usage / quota service (needed by the instant-upload @@ -1794,17 +1821,6 @@ impl AppServiceFactory { crate::application::services::external_mount_router::MountRouter::new(mount_registry), ); - // Message bus: single instance for the app lifetime, wired - // with a no-op replicator (multi-instance broker is a follow-up - // per `docs/plan/message-bus.md § Roadmap`). Constructed here - // so `create_application_services` can hand it to services that - // publish after their DB commits (`FolderService`, - // `FileUploadService`, …). Spawns its own GC task in - // `with_replicator` — no supervisor setup required. - let bus = crate::infrastructure::services::in_process_message_bus::InProcessMessageBus::with_replicator( - Arc::new(crate::application::ports::message_bus_ports::NoopReplicator), - ); - let mut apps = self.create_application_services( &core, &repos, @@ -2316,6 +2332,7 @@ impl AppServiceFactory { maintenance_pool: Some(maintenance_pool), mount_router, bus, + rt_ticket_store, auth_service: auth_services, opaque_service, opaque_repo, @@ -3223,6 +3240,16 @@ pub struct AppState { pub bus: Arc< crate::infrastructure::services::in_process_message_bus::InProcessMessageBus, >, + /// Short-lived tickets that authenticate a WebSocket upgrade + /// without the browser needing to attach a DPoP proof (which + /// `new WebSocket()` cannot set — only `Sec-WebSocket-Protocol` + /// is settable). FE POSTs `/api/rt/ticket` with a normal + /// DPoP-signed request, receives an opaque one-shot token, and + /// hands it to the WS upgrade via subprotocol. Always populated; + /// see `rt_ticket_store` module doc. + pub rt_ticket_store: Arc< + crate::infrastructure::services::rt_ticket_store::RtTicketStore, + >, pub auth_service: Option, /// OPAQUE aPAKE substrate (RFC 9807). Populated only when /// [`OpaqueConfig::effective_mode`] is not `Off` — that method diff --git a/src/infrastructure/services/in_process_message_bus.rs b/src/infrastructure/services/in_process_message_bus.rs index e92200d1..324f3665 100644 --- a/src/infrastructure/services/in_process_message_bus.rs +++ b/src/infrastructure/services/in_process_message_bus.rs @@ -127,6 +127,27 @@ impl MessageBus for InProcessMessageBus { // replicators must background their I/O themselves. self.replicator.on_local_publish(topic, &event); + // Structured trace of every publish so operators can watch the + // bus with `RUST_LOG=oxicloud::message_bus=debug`. Cheap: + // shows the wire-form topic (uses the same Display we return + // to WS clients), the event's discriminator (via serde), and + // whether anyone was listening at publish time. Payload bodies + // are NOT emitted here to keep the log line short and stable + // across variant additions — `debug_span` or a per-service + // publish site can log the payload if needed. + let sub_count = self + .topics + .get(topic) + .map(|s| s.receiver_count()) + .unwrap_or(0); + tracing::debug!( + target: "oxicloud::message_bus", + topic = %topic.to_wire_key(), + kind = event_kind(&event), + subscribers = sub_count, + "📤 bus publish", + ); + // If nobody is subscribed, don't allocate a sender just to drop // its message. `broadcast::Sender::send` returns Err when there // are no receivers — cheaper still to short-circuit here. @@ -155,6 +176,27 @@ impl MessageBus for InProcessMessageBus { } } +/// Snake-case discriminator string for the event, matching the wire +/// `event` field. Lifted out of the `publish` hot path so the debug +/// log stays a one-liner. Kept in sync with the `#[serde(tag = +/// "event", rename_all = "snake_case")]` shape in +/// `MessageBusEvent` — new variants get a new arm here to render +/// nicely in the trace log; adding one that lands in the default is +/// harmless (still readable), just less specific. +fn event_kind(event: &MessageBusEvent) -> &'static str { + match event { + MessageBusEvent::FileCreated { .. } => "file_created", + MessageBusEvent::FileRenamed { .. } => "file_renamed", + MessageBusEvent::FileMoved { .. } => "file_moved", + MessageBusEvent::FileDeleted { .. } => "file_deleted", + MessageBusEvent::FolderCreated { .. } => "folder_created", + MessageBusEvent::FolderRenamed { .. } => "folder_renamed", + MessageBusEvent::FolderMoved { .. } => "folder_moved", + MessageBusEvent::FolderDeleted { .. } => "folder_deleted", + MessageBusEvent::AuthzChanged { .. } => "authz_changed", + } +} + // ════════════════════════════════════════════════════════════════════════════ // Tests // ════════════════════════════════════════════════════════════════════════════ diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index bb17125c..e3ed53c9 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -52,6 +52,7 @@ pub mod pg_acl_engine; pub mod plugins; pub mod recent_recording_hook; pub mod retry_blob_backend; +pub mod rt_ticket_store; pub mod s3_blob_backend; pub mod satellites_consistency_service; pub mod search_index; diff --git a/src/infrastructure/services/rt_ticket_store.rs b/src/infrastructure/services/rt_ticket_store.rs new file mode 100644 index 00000000..ef21e446 --- /dev/null +++ b/src/infrastructure/services/rt_ticket_store.rs @@ -0,0 +1,229 @@ +//! Short-lived tickets for authenticating a WebSocket upgrade. +//! +//! # Problem +//! +//! A DPoP-bound session must carry a fresh `DPoP:` header on every +//! request. `new WebSocket(url)` in browsers cannot set arbitrary +//! headers — only `Sec-WebSocket-Protocol` — so the upgrade GET +//! arrives without a DPoP proof and `require_dpop_layer` refuses with +//! 401 `proof_missing_on_bound_session`. See +//! `docs/plan/message-bus.md § F`. +//! +//! # Solution +//! +//! Ticket exchange. The FE first `POST /api/rt/ticket` — a normal +//! HTTP request, so `apiFetch` attaches the DPoP proof and every other +//! middleware runs. The server mints an opaque one-shot ticket, tied +//! to the caller_id and a 30 s expiry. The FE then opens the WS with +//! `Sec-WebSocket-Protocol: oxi.ticket.`; the WS handler +//! redeems the ticket via this store to recover the caller_id, then +//! runs the session with zero auth-middleware involvement. +//! +//! # Invariants +//! +//! - **Single-use** — `redeem` removes the entry atomically, so a +//! captured ticket can be replayed at most once (the race is decided +//! by the first successful `remove`; every other caller gets `None`). +//! - **Short-lived** — 30 s TTL. A captured ticket that isn't burned +//! inside that window is inert. +//! - **Opaque** — the token carries no user identity itself. All the +//! auth data lives in the store keyed by the token. Losing the store +//! invalidates every issued ticket; that's the correct failure mode. +//! - **In-process** — one store per process. Multi-instance +//! deployments will need a shared backend (Redis, PG); calling it +//! out here so the seam is visible when the day comes. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use dashmap::DashMap; +use tokio::task::JoinHandle; +use uuid::Uuid; + +/// TTL for a freshly-minted ticket. 30 s covers the round-trip from +/// `/api/rt/ticket` response to `new WebSocket()` handshake on any +/// realistic network — well under the shortest sensible clock skew +/// budget, well above the 100–500 ms actually needed on localhost or +/// LAN. Kept as a compile-time constant; if operators ever want to +/// tune it, promote to config. +pub const TICKET_TTL: Duration = Duration::from_secs(30); + +/// Reaper cadence. Every N seconds the store walks its entries and +/// drops expired ones. Redemption also lazily short-circuits on +/// expiry, so the reaper is a memory-hygiene backstop rather than a +/// correctness gate — a ticket that expires and is never redeemed +/// stays around for up to `TICKET_TTL + REAPER_INTERVAL` before its +/// row is freed. +pub const REAPER_INTERVAL: Duration = Duration::from_secs(60); + +/// Wire prefix identifying our tickets in `Sec-WebSocket-Protocol`. +/// The full value on the wire is `oxi.ticket.` — one +/// subprotocol string, opaque to intermediaries. Kept short so +/// stripping proxies don't hit an arbitrary length limit. +pub const SUBPROTOCOL_PREFIX: &str = "oxi.ticket."; + +struct Entry { + caller_id: Uuid, + expires_at: Instant, +} + +/// In-process ticket store. Cheap to construct; the reaper task is +/// spawned by DI when the store is wired. +pub struct RtTicketStore { + entries: DashMap, +} + +impl RtTicketStore { + pub fn new() -> Arc { + Arc::new(Self { + entries: DashMap::new(), + }) + } + + /// Issue a fresh ticket for `caller_id`. Returns the opaque token + /// (a UUIDv4 string) — the FE puts this on the wire as + /// `Sec-WebSocket-Protocol: oxi.ticket.`. + /// + /// Ticket IDs are v4 (random) — 122 bits of entropy, well above + /// the "unguessable-token" bar even without server-side rate + /// limiting. A serial or timestamped id would leak issue-order + /// signal to anyone with a wire tap. + pub fn issue(&self, caller_id: Uuid) -> Uuid { + let ticket = Uuid::new_v4(); + self.entries.insert( + ticket, + Entry { + caller_id, + expires_at: Instant::now() + TICKET_TTL, + }, + ); + ticket + } + + /// Redeem `ticket` if it exists AND has not expired. Removes the + /// entry regardless of outcome — a valid ticket returns the + /// caller_id, an expired ticket is silently freed and returns + /// `None`. Single-use invariant holds by construction: only one + /// caller wins the `remove`, everyone else sees `None`. + pub fn redeem(&self, ticket: Uuid) -> Option { + let (_, entry) = self.entries.remove(&ticket)?; + if entry.expires_at < Instant::now() { + return None; + } + Some(entry.caller_id) + } + + /// Background reaper. Walks the map on the configured cadence and + /// removes expired entries. Runs until the returned handle is + /// dropped or `cancel` is notified (per the standard shutdown + /// contract used across the crate). + pub fn spawn_reaper(self: Arc) -> JoinHandle<()> { + tokio::spawn(async move { + let mut ticker = tokio::time::interval(REAPER_INTERVAL); + // First tick fires immediately; skip it so the store has + // at least one TTL window's worth of entries before the + // first sweep. + ticker.tick().await; + loop { + ticker.tick().await; + let now = Instant::now(); + self.entries.retain(|_, entry| entry.expires_at >= now); + } + }) + } + + /// Present count. Test-only. Not exposed to handlers — no + /// operational reason to peek at the queue depth from a request + /// path. + #[cfg(test)] + fn len(&self) -> usize { + self.entries.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn issue_then_redeem_returns_caller_id() { + let store = RtTicketStore::new(); + let caller = Uuid::new_v4(); + let ticket = store.issue(caller); + assert_eq!(store.redeem(ticket), Some(caller)); + } + + #[test] + fn redeem_is_single_use() { + let store = RtTicketStore::new(); + let caller = Uuid::new_v4(); + let ticket = store.issue(caller); + assert_eq!(store.redeem(ticket), Some(caller)); + // Second redeem finds nothing — replay protection. + assert_eq!(store.redeem(ticket), None); + } + + #[test] + fn redeem_unknown_returns_none() { + let store = RtTicketStore::new(); + assert_eq!(store.redeem(Uuid::new_v4()), None); + } + + #[test] + fn issued_ticket_is_present_in_store() { + let store = RtTicketStore::new(); + let caller = Uuid::new_v4(); + assert_eq!(store.len(), 0); + let _ticket = store.issue(caller); + assert_eq!(store.len(), 1); + } + + #[test] + fn redeem_after_expiry_returns_none_and_frees_entry() { + // Use a synthetic entry with `expires_at` in the past so the + // test doesn't have to sleep 30 s. + let store = RtTicketStore::new(); + let caller = Uuid::new_v4(); + let ticket = Uuid::new_v4(); + store.entries.insert( + ticket, + Entry { + caller_id: caller, + expires_at: Instant::now() - Duration::from_secs(1), + }, + ); + assert_eq!(store.len(), 1); + // Expired redeem returns None… + assert_eq!(store.redeem(ticket), None); + // …and the entry is gone. + assert_eq!(store.len(), 0); + } + + #[test] + fn distinct_tickets_for_the_same_caller() { + // Two issues in a row must produce distinct token ids — the + // FE will issue one per WS reconnect, and a collision would + // mean the second issue clobbers the first's expiry map row. + let store = RtTicketStore::new(); + let caller = Uuid::new_v4(); + let t1 = store.issue(caller); + let t2 = store.issue(caller); + assert_ne!(t1, t2); + } + + // Wall-clock testing of the reaper's timer needs the tokio + // `test-util` feature; not enabled crate-wide. The reaper body is + // a straight `entries.retain(|_, e| e.expires_at >= now)` and the + // redemption path already lazily short-circuits on expiry (see + // `redeem_after_expiry_returns_none_and_frees_entry`), which + // exercises the same expiry decision without waiting on a real + // clock. + + #[test] + fn is_send_sync_arc_shareable() { + // Mirrors the actual usage in `AppState` — an + // `Arc` shared across the axum-served tasks. + fn assert_send_sync() {} + assert_send_sync::>(); + } +} diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index 8177ee35..19e33984 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -23,6 +23,7 @@ pub mod opaque_auth_handler; pub mod people_handler; pub mod photos_handler; pub mod recent_handler; +pub mod rt_ticket_handler; pub mod rt_ws; pub mod search_handler; pub mod share_handler; diff --git a/src/interfaces/api/handlers/rt_ticket_handler.rs b/src/interfaces/api/handlers/rt_ticket_handler.rs new file mode 100644 index 00000000..320b10ed --- /dev/null +++ b/src/interfaces/api/handlers/rt_ticket_handler.rs @@ -0,0 +1,80 @@ +//! Ticket issuance for browser WebSocket authentication. +//! +//! `POST /api/rt/ticket` — issues a one-shot 30 s ticket for the +//! authenticated caller. Runs under the full `/api/*` middleware +//! stack (auth + DPoP), so the caller proves possession of the +//! session AND (when the session is DPoP-bound) the DPoP key on the +//! same request. The ticket then substitutes for that proof on the +//! next WS upgrade. +//! +//! See `src/infrastructure/services/rt_ticket_store.rs` for the +//! store semantics and `docs/plan/message-bus.md § F` for the +//! architectural context. + +use std::sync::Arc; + +use axum::{Json, extract::State}; +use serde::Serialize; + +use crate::common::di::AppState; +use crate::infrastructure::services::rt_ticket_store::{SUBPROTOCOL_PREFIX, TICKET_TTL}; +use crate::interfaces::middleware::auth::CurrentUserId; + +/// Response body for `POST /api/rt/ticket`. Deliberately minimal — +/// callers only need the token string; the TTL is echoed so the FE +/// doesn't hard-code the 30 s constant on its side. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct RtTicketResponse { + /// Opaque single-use token. Present in the WS upgrade as + /// `Sec-WebSocket-Protocol: oxi.ticket.` (the prefix is + /// baked in by both sides — see [`SUBPROTOCOL_PREFIX`]). + pub ticket: String, + + /// Seconds until this ticket expires server-side. Consumers should + /// open the WS immediately; a 30 s bound leaves generous headroom + /// for the handshake without letting a captured ticket live long. + pub expires_in_seconds: u64, + + /// Full `Sec-WebSocket-Protocol` value the client MUST pass on the + /// upgrade. Included pre-assembled so a FE bug can't emit the + /// wrong prefix and blow the handshake in a way that looks like a + /// server-side denial. + pub subprotocol: String, +} + +/// Issue a fresh ticket for the authenticated caller. Idempotent from +/// the caller's perspective — each call mints a new token — but +/// each ticket is single-use once redeemed by the WS handler. +/// +/// No rate limiting today: even a mildly abusive client would just +/// fill the ticket store with entries that reap in 30 s. If ever +/// necessary, add a per-caller_id token bucket alongside the auth +/// middleware limits. +#[utoipa::path( + post, + path = "/api/rt/ticket", + tag = "message-bus", + responses( + (status = 200, description = "Ticket issued", body = RtTicketResponse), + (status = 401, description = "Unauthenticated"), + ), + security(("bearerAuth" = [])) +)] +pub async fn issue_rt_ticket( + CurrentUserId(caller_id): CurrentUserId, + State(state): State>, +) -> Json { + let ticket = state.rt_ticket_store.issue(caller_id); + let ticket_str = ticket.to_string(); + tracing::debug!( + target: "oxicloud::message_bus", + event = "message_bus.ticket_issued", + caller_id = %caller_id, + "🎫 rt.ticket issued", + ); + Json(RtTicketResponse { + subprotocol: format!("{SUBPROTOCOL_PREFIX}{ticket_str}"), + ticket: ticket_str, + expires_in_seconds: TICKET_TTL.as_secs(), + }) +} diff --git a/src/interfaces/api/handlers/rt_ws.rs b/src/interfaces/api/handlers/rt_ws.rs index fb8329a5..2b1944e8 100644 --- a/src/interfaces/api/handlers/rt_ws.rs +++ b/src/interfaces/api/handlers/rt_ws.rs @@ -18,13 +18,28 @@ //! //! # Auth //! -//! Route sits under `protected_api` (see `src/interfaces/api/routes.rs`) -//! so `auth_middleware` runs first. Cookie AND `Authorization: Bearer` -//! paths both produce a `CurrentUserId` extension the handler extracts. -//! Browser-side subprotocol bearer (`Sec-WebSocket-Protocol: -//! authorization.bearer.`) is a Phase-A follow-up — the MVP relies -//! on the Authorization header, which programmatic clients (the -//! `rt-hurl-helper` smoke test) set directly. +//! Route is mounted at `/api/rt/ws` OUTSIDE the standard +//! `auth_middleware` + `require_dpop_layer` stack — a browser can't +//! attach a `DPoP:` header to `new WebSocket()` (RFC 6455 gives us +//! only `Sec-WebSocket-Protocol`), and the standard chain would 401 +//! on every DPoP-bound session. This handler self-authenticates +//! from two accepted sources: +//! +//! 1. **Ticket subprotocol** (`Sec-WebSocket-Protocol: +//! oxi.ticket.`) — the primary path for browser clients. +//! The FE first `POST /api/rt/ticket` under the full middleware +//! chain (auth + DPoP proofed), receives an opaque one-shot +//! token, and passes it here. Verified by redeeming through +//! [`AppState::rt_ticket_store`]. See +//! `docs/plan/message-bus.md § F`. +//! 2. **Bearer token** (`Authorization: Bearer `) — the +//! programmatic-client path used by `rt-hurl-helper` in api-test. +//! Verified against `AuthServices::token_service`. DPoP-bound +//! tokens are rejected on this path to preserve the substrate's +//! proof-of-possession invariant. +//! +//! Neither → 401. Order matters: ticket first (short-lived, tied to +//! a proofed HTTP round-trip), bearer second. //! //! # Limits //! @@ -40,7 +55,8 @@ use std::time::Duration; use axum::body::Bytes; use axum::extract::State; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; -use axum::response::Response; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; use futures::StreamExt; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -49,13 +65,14 @@ use tokio::task::JoinHandle; use tokio::time::MissedTickBehavior; use uuid::Uuid; +use crate::application::ports::auth_ports::TokenServicePort; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::message_bus_ports::{ AuthzCheck, BusResource, MessageBus, MessageBusEvent, ParseTopicErr, Topic, error_code, }; use crate::common::di::AppState; use crate::domain::services::authorization::{Permission, Resource, Subject}; -use crate::interfaces::middleware::auth::CurrentUserId; +use crate::infrastructure::services::rt_ticket_store::SUBPROTOCOL_PREFIX; /// Max simultaneous subscriptions on a single WS session. Beyond this the /// server responds `-32005 sub_limit` and the client is expected to @@ -151,20 +168,112 @@ struct RpcNotification<'a> { // Handler entrypoint // ════════════════════════════════════════════════════════════════════════════ -/// `GET /api/rt/ws` — WS upgrade handler. Sits under `protected_api` so -/// [`CurrentUserId`] resolves against a valid session before we reach -/// `on_upgrade`. +/// `GET /api/rt/ws` — WS upgrade handler. Mounted outside the standard +/// `/api/*` middleware stack; self-authenticates via ticket +/// subprotocol OR bearer token (see the module doc). /// -/// Returns whatever `WebSocketUpgrade::on_upgrade` produces (an HTTP 101 -/// Switching Protocols with the WebSocket handshake headers). +/// Returns 101 Switching Protocols on success; 401 with an audit +/// entry on any auth failure. The response is deliberately terse — +/// browsers surface the status code via the `close` event's code +/// field (1006 on a rejected upgrade), so a longer body wouldn't +/// reach the FE anyway. pub async fn rt_ws_handler( ws: WebSocketUpgrade, - CurrentUserId(caller_id): CurrentUserId, State(state): State>, + headers: HeaderMap, ) -> Response { + let auth = match authenticate_upgrade(&headers, &state).await { + Ok(auth) => auth, + Err(reason) => { + tracing::info!( + target: "audit", + event = "message_bus.upgrade_rejected", + reason = %reason, + "👮🏻‍♂️ WS upgrade rejected", + ); + return (StatusCode::UNAUTHORIZED, "ws_auth_failed").into_response(); + } + }; + let caller_id = auth.caller_id; + // If the caller reached us via the ticket path, echo the exact + // subprotocol they sent back on the 101 response — RFC 6455 §4.2.2 + // requires this or the client fails the connection. + let ws = match auth.accepted_subprotocol { + Some(sub) => ws.protocols([sub]), + None => ws, + }; ws.on_upgrade(move |socket| handle_session(socket, caller_id, state)) } +/// Successful upgrade credentials — the resolved caller and (when the +/// ticket path was used) the subprotocol to echo on the 101 response. +struct UpgradeAuth { + caller_id: Uuid, + accepted_subprotocol: Option, +} + +/// Extract `Sec-WebSocket-Protocol` and match a ticket subprotocol +/// first; fall back to `Authorization: Bearer`. Returns a stable +/// `reason` key on failure so the audit log stays filterable. +async fn authenticate_upgrade( + headers: &HeaderMap, + state: &Arc, +) -> Result { + if let Some(ticket_sub) = extract_ticket_subprotocol(headers) { + // Redeem parses the UUID; a malformed subprotocol is a + // structural failure ("bad_ticket_format"), an unknown-or- + // expired UUID is a redemption failure ("ticket_invalid"). + let Some(ticket_str) = ticket_sub.strip_prefix(SUBPROTOCOL_PREFIX) else { + return Err("bad_ticket_format"); + }; + let Ok(ticket_uuid) = Uuid::parse_str(ticket_str) else { + return Err("bad_ticket_uuid"); + }; + let Some(caller_id) = state.rt_ticket_store.redeem(ticket_uuid) else { + return Err("ticket_invalid"); + }; + return Ok(UpgradeAuth { + caller_id, + accepted_subprotocol: Some(ticket_sub), + }); + } + if let Some(bearer) = extract_bearer(headers) { + let Some(auth_service) = state.auth_service.as_ref() else { + return Err("auth_service_unavailable"); + }; + let claims = auth_service + .token_service + .validate_token(bearer) + .map_err(|_| "bearer_invalid")?; + if claims.sub_id.is_nil() { + return Err("bearer_bad_subject"); + } + return Ok(UpgradeAuth { + caller_id: claims.sub_id, + accepted_subprotocol: None, + }); + } + Err("no_credentials") +} + +/// Find the first subprotocol value that looks like a ticket. Browsers +/// send `Sec-WebSocket-Protocol` as a comma-separated list per RFC 6455. +fn extract_ticket_subprotocol(headers: &HeaderMap) -> Option { + let raw = headers.get("sec-websocket-protocol")?.to_str().ok()?; + raw.split(',') + .map(str::trim) + .find(|s| s.starts_with(SUBPROTOCOL_PREFIX)) + .map(|s| s.to_string()) +} + +/// Extract `Authorization: Bearer ` if present. Returns the raw +/// token string (never empty). +fn extract_bearer(headers: &HeaderMap) -> Option<&str> { + let value = headers.get("authorization")?.to_str().ok()?; + let token = value.strip_prefix("Bearer ")?.trim(); + (!token.is_empty()).then_some(token) +} + // ════════════════════════════════════════════════════════════════════════════ // Session loop // ════════════════════════════════════════════════════════════════════════════ diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 5cc5967d..5d9aba3b 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -674,16 +674,24 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { .with_state(app_state.clone()); router = router.nest("/users", users_router); - // Message bus WebSocket. Auth (session cookie or bearer JWT) via - // the same `auth_middleware` the rest of `/api/*` gets; the handler - // extracts `CurrentUserId` from the extension the middleware - // installs. See `docs/plan/message-bus.md` and the module doc on - // `rt_ws` for the JSON-RPC 2.0 wire. + // Message bus — ticket issuance (`POST /api/rt/ticket`). Stays in + // the protected router (auth + DPoP), so the caller proves session + // + DPoP-key possession before a ticket is minted. See + // `handlers/rt_ticket_handler.rs` and `docs/plan/message-bus.md § F`. router = router.route( - "/rt/ws", - get(crate::interfaces::api::handlers::rt_ws::rt_ws_handler).with_state(app_state.clone()), + "/rt/ticket", + post(crate::interfaces::api::handlers::rt_ticket_handler::issue_rt_ticket) + .with_state(app_state.clone()), ); + // The WS upgrade (`GET /api/rt/ws`) is registered OUTSIDE the + // protected-api middleware stack — a browser cannot attach a + // `DPoP:` header to `new WebSocket()`, so the standard stack + // 401s on every DPoP-bound session. See the `rt_ws` module doc + // for the self-auth logic (ticket subprotocol or bearer token). + // Registration happens in `main.rs` where the outer router owns + // the middleware layering. + // Collector for any unknown `/api/*` path. Without this, an // unmatched API URL falls through Axum's matcher to the // ServeDir fallback and is logged under `http::web` — wrong diff --git a/src/main.rs b/src/main.rs index 792cfa12..c10105c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1042,6 +1042,22 @@ async fn run() -> Result<(), Box> { ) // Public API routes (share access, i18n) — no auth required .nest("/api", public_api_routes.layer(access_log!("http::api"))) + // Message-bus WebSocket. Registered OUTSIDE `protected_api` + // because a browser cannot attach a `DPoP:` header to + // `new WebSocket()` (RFC 6455 only lets us set + // `Sec-WebSocket-Protocol`), so the standard auth + DPoP + // stack would 401 every DPoP-bound session. The handler + // self-authenticates from either a ticket subprotocol + // (minted by `POST /api/rt/ticket` under the full chain) + // or a bearer token (`rt-hurl-helper` test path). + // See `handlers/rt_ws.rs` module doc and + // `docs/plan/message-bus.md § F`. + .route( + "/api/rt/ws", + axum::routing::get(oxicloud::interfaces::api::handlers::rt_ws::rt_ws_handler) + .with_state(app_state.clone()) + .layer(access_log!("http::api")), + ) // All other API routes are protected by auth middleware .nest("/api", protected_api.layer(access_log!("http::api"))) // RFC 6764 well-known discovery (public, no auth — just redirects) diff --git a/tests/api/rt_bus_check.sh b/tests/api/rt_bus_check.sh index 137046c3..58ca2d55 100755 --- a/tests/api/rt_bus_check.sh +++ b/tests/api/rt_bus_check.sh @@ -42,6 +42,16 @@ # shape as an unknown topic — anti-enumeration). # Guards the strict-privacy Class-2 AuthZ gate: # no admin bypass, direct UUID equality only. +# S10 Ticket happy path — user1 POSTs `/api/rt/ticket`, receives a +# short-lived opaque token, opens the WS with +# `Sec-WebSocket-Protocol: oxi.ticket.` +# and successfully subscribes + delivers an +# event. Exercises the ticket path — the only +# path a DPoP-required browser can take. +# S11 Ticket single-use — a ticket redeemed once cannot be redeemed +# again. Guards replay: a captured token +# outside its 30 s TTL, or one already +# consumed, MUST fail the upgrade with 401. # # Exit non-zero on any failure — run.sh treats that as a suite failure. # ───────────────────────────────────────────────────────────────────────────── @@ -69,14 +79,19 @@ esac log() { printf '\033[1;36m[rt_bus_check]\033[0m %s\n' "$*"; } die() { printf '\033[1;31m[rt_bus_check FAIL]\033[0m %s\n' "$*" >&2; exit 1; } -# ── Build the helper on demand (matches opaque/dpop helper convention) ────── -if [[ ! -x "$HELPER_BIN" ]]; then - log "Building rt-hurl-helper ($BUILD_TARGET)..." - case "$BUILD_TARGET" in - debug) (cd "$REPO_ROOT" && cargo build --features test_utils --bin rt-hurl-helper 2>&1 | tail -n 20) || die "rt-hurl-helper build failed" ;; - release) (cd "$REPO_ROOT" && cargo build --release --features test_utils --bin rt-hurl-helper 2>&1 | tail -n 20) || die "rt-hurl-helper build failed" ;; - esac -fi +# ── Rebuild the helper every run ──────────────────────────────────────────── +# Deliberately unconditional — the previous `[[ ! -x $HELPER_BIN ]]` guard +# silently reused a stale binary whenever the helper's source changed +# without touching the caller shell script, producing "unknown flag" +# exits that looked like test bugs (see the S10/S11 --ticket rollout). +# Cargo incremental short-circuits in ~50 ms when nothing changed, so +# the cost of the always-build is negligible; the cost of a stale binary +# is a wild-goose chase. +log "Building rt-hurl-helper ($BUILD_TARGET)..." +case "$BUILD_TARGET" in + debug) (cd "$REPO_ROOT" && cargo build --features test_utils --bin rt-hurl-helper 2>&1 | tail -n 20) || die "rt-hurl-helper build failed" ;; + release) (cd "$REPO_ROOT" && cargo build --release --features test_utils --bin rt-hurl-helper 2>&1 | tail -n 20) || die "rt-hurl-helper build failed" ;; +esac # ── curl wrappers ─────────────────────────────────────────────────────────── c_post() { @@ -93,6 +108,22 @@ c_get() { "$url" } +# Block until the helper writes `--ready-file ` (touched the +# moment every requested subscribe is ack'd) or `$timeout` seconds +# elapse. Replaces the older `sleep 0.4` heuristic that flaked on +# cold-cache runs where the helper's fork/tokio-init/connect chain +# crossed 400 ms and the shell's mkfile_in publish arrived at an +# empty topic. See `Args::ready_file` in rt-hurl-helper.rs. +wait_ready() { + local path="$1" timeout="${2:-5}" + local waited=0 + while [[ ! -f "$path" && "$waited" -lt "$((timeout * 20))" ]]; do + sleep 0.05 + waited=$((waited + 1)) + done + [[ -f "$path" ]] || die "wait_ready: $path never appeared within ${timeout}s (subscribe likely never ack'd)" +} + # ── Setup: register fresh users; the test.env admin may be OPAQUE- # migrated and the legacy password-login path refuses those accounts, # so we don't use it at all — same pattern as `dedup_admin_gate.hurl` @@ -178,17 +209,21 @@ mkfile_in() { # ── Scenario 1 — Positive delivery ────────────────────────────────────────── log "S1: subscribe to folder A, upload into A, expect one file_created event." out_s1="$(mktemp -t rtbus_s1.XXXXXX)" +ready_s1="$(mktemp -t rtbus_s1_ready.XXXXXX)" +rm -f "$ready_s1" # mktemp creates it; ready-file semantics need "appears when subscribed" "$HELPER_BIN" subscribe-and-collect \ --url "$ws_url" \ --token "$user1_token" \ --subscribe "folder:$folder_a" \ --expect-events 1 \ --timeout 5s \ + --ready-file "$ready_s1" \ --output "$out_s1" & helper_pid=$! -# Give the ack a moment to install so the upload's post-commit publish -# lands on a live receiver, not an orphaned map entry. -sleep 0.4 +# Block on the helper's ready-file signal, not a wall-clock sleep — +# see wait_ready doc. Closes the "publish before subscribe installed" +# race that flaked S1 on cold-cache runs. +wait_ready "$ready_s1" mkfile_in "$folder_a" "s1.txt" "$user1_token" if ! wait "$helper_pid"; then cat "$out_s1" >&2 || true @@ -206,15 +241,17 @@ log "S1 OK" # ── Scenario 2 — Topic isolation ──────────────────────────────────────────── log "S2: subscribe to folder A, upload into B (must be silent) and A (triggers exit)." out_s2="$(mktemp -t rtbus_s2.XXXXXX)" +ready_s2="$(mktemp -t rtbus_s2_ready.XXXXXX)"; rm -f "$ready_s2" "$HELPER_BIN" subscribe-and-collect \ --url "$ws_url" \ --token "$user1_token" \ --subscribe "folder:$folder_a" \ --expect-events 1 \ --timeout 5s \ + --ready-file "$ready_s2" \ --output "$out_s2" & helper_pid=$! -sleep 0.4 +wait_ready "$ready_s2" # B first — should be dropped for the A subscriber. mkfile_in "$folder_b" "s2_in_B.txt" "$user1_token" # Small settle so if isolation is BROKEN, the B event has time to arrive @@ -271,14 +308,20 @@ log "S4 OK" # (b) trips (event never arrives after idle). log "S5: server-initiated keepalive fires on idle; session still delivers." out_s5="$(mktemp -t rtbus_s5.XXXXXX)" +ready_s5="$(mktemp -t rtbus_s5_ready.XXXXXX)"; rm -f "$ready_s5" "$HELPER_BIN" subscribe-and-collect \ --url "$ws_url" \ --token "$user1_token" \ --subscribe "folder:$folder_a" \ --expect-events 1 \ --timeout 6s \ + --ready-file "$ready_s5" \ --output "$out_s5" & helper_pid=$! +# Wait for the subscribe to install BEFORE starting the idle window — +# otherwise slow helper startup eats into the 3 s and we observe +# fewer pings than the assertion below tolerates. +wait_ready "$ready_s5" # 3 s of pure idle — with 1 s keepalive on the server, that's ~3 Pings. sleep 3 mkfile_in "$folder_a" "s5.txt" "$user1_token" @@ -320,15 +363,17 @@ s6_file_id=$(printf '%s' "$s6_upload" | jq -r '.id') || die "S6: pre-upload failed: $s6_upload" out_s6="$(mktemp -t rtbus_s6.XXXXXX)" +ready_s6="$(mktemp -t rtbus_s6_ready.XXXXXX)"; rm -f "$ready_s6" "$HELPER_BIN" subscribe-and-collect \ --url "$ws_url" \ --token "$user1_token" \ --subscribe "folder:$folder_a" \ --expect-events 1 \ --timeout 5s \ + --ready-file "$ready_s6" \ --output "$out_s6" & helper_pid=$! -sleep 0.4 +wait_ready "$ready_s6" # `DELETE /api/files/{id}` routes to `delete_and_cleanup_with_perms` — # the trash-first path. Publish fires on BOTH the trash and the # permanent-delete branch, so this covers whichever the test hits. @@ -369,6 +414,7 @@ s7_file_id=$(printf '%s' "$s7_upload" | jq -r '.id') || die "S7: pre-upload failed: $s7_upload" out_s7="$(mktemp -t rtbus_s7.XXXXXX)" +ready_s7="$(mktemp -t rtbus_s7_ready.XXXXXX)"; rm -f "$ready_s7" "$HELPER_BIN" subscribe-and-collect \ --url "$ws_url" \ --token "$user1_token" \ @@ -376,9 +422,10 @@ out_s7="$(mktemp -t rtbus_s7.XXXXXX)" --subscribe "folder:$folder_b" \ --expect-events 2 \ --timeout 5s \ + --ready-file "$ready_s7" \ --output "$out_s7" & helper_pid=$! -sleep 0.4 +wait_ready "$ready_s7" # `PUT /api/files/{id}/move` — MoveFilePayload = { folder_id: }. curl -sS -X PUT \ -H "Authorization: Bearer $user1_token" \ @@ -447,6 +494,7 @@ grant_b_id=$(printf '%s' "$grant_b" | jq -r '.grants[0].id') # 8.2 user2 subscribes to BOTH folder topics; --expect-events 1 exits # when the post-revoke upload lands on the SURVIVING sub. out_s8="$(mktemp -t rtbus_s8.XXXXXX)" +ready_s8="$(mktemp -t rtbus_s8_ready.XXXXXX)"; rm -f "$ready_s8" "$HELPER_BIN" subscribe-and-collect \ --url "$ws_url" \ --token "$user2_token" \ @@ -454,9 +502,10 @@ out_s8="$(mktemp -t rtbus_s8.XXXXXX)" --subscribe "folder:$folder_b" \ --expect-events 1 \ --timeout 6s \ + --ready-file "$ready_s8" \ --output "$out_s8" & helper_pid=$! -sleep 0.4 # let both subscribes install +wait_ready "$ready_s8" # both subscribes installed before we revoke/upload # 8.3 user1 revokes only the folder-A grant. curl -sS -X DELETE \ @@ -531,4 +580,65 @@ if ! "$HELPER_BIN" expect-denied \ fi log "S9 OK" -log "All nine message-bus scenarios passed." +# ── Scenario 10 — Ticket happy path ───────────────────────────────────────── +# The browser flow: POST /api/rt/ticket under the full middleware stack +# (auth + DPoP proofed), then open the WS with `oxi.ticket.` in +# Sec-WebSocket-Protocol. Same delivery guarantees as the bearer path. +# `curl` mints the ticket; `rt-hurl-helper --ticket` redeems it on the +# upgrade. +log "S10: issue rt ticket, open WS with subprotocol, subscribe + deliver." +# c_post takes the raw JWT as its second arg (not the full +# `Authorization:` line); it assembles the header itself. +tkt_resp=$(c_post "$base_url/api/rt/ticket" "$user1_token" "") +ticket=$(printf '%s' "$tkt_resp" | jq -r '.ticket') +[[ -n "$ticket" && "$ticket" != "null" ]] \ + || die "S10: no ticket in POST /api/rt/ticket response: $tkt_resp" +out_s10="$(mktemp -t rtbus_s10.XXXXXX)" +ready_s10="$(mktemp -t rtbus_s10_ready.XXXXXX)"; rm -f "$ready_s10" +"$HELPER_BIN" subscribe-and-collect \ + --url "$ws_url" \ + --ticket "$ticket" \ + --subscribe "folder:$folder_a" \ + --expect-events 1 \ + --timeout 3s \ + --ready-file "$ready_s10" \ + --output "$out_s10" & +helper_pid=$! +wait_ready "$ready_s10" +mkfile_in "$folder_a" "s10.txt" "$user1_token" +if ! wait "$helper_pid"; then + cat "$out_s10" >&2 || true + die "S10: helper did not observe event on ticket-authenticated WS" +fi +[[ "$(jq -r '.events | length' "$out_s10")" == "1" ]] \ + || { cat "$out_s10"; die "S10: expected 1 event, got $(jq -r '.events | length' "$out_s10")"; } +log "S10 OK" + +# ── Scenario 11 — Ticket single-use ───────────────────────────────────────── +# S10 already redeemed the ticket. A second connection with the SAME +# token MUST be refused at the upgrade with 401 (`ticket_invalid` +# audit reason). Proves replay protection — the store removes entries +# on first successful redeem, even if the caller reconnects before +# the 30 s TTL would have expired anyway. +# +# The helper distinguishes "expectation failure" (exit 1 — WS opened +# and then something was off) from "protocol/connect failure" (exit 2 +# — connect_ws itself refused). Ticket rejection lands in the second +# bucket, so we assert on exit code 2. Bash's `!` inverter treats any +# non-zero as success, so we capture the exact code. +log "S11: reuse the redeemed ticket, expect upgrade rejected." +set +e +"$HELPER_BIN" subscribe-and-collect \ + --url "$ws_url" \ + --ticket "$ticket" \ + --subscribe "folder:$folder_a" \ + --expect-events 1 \ + --timeout 2s \ + --output /dev/null +reuse_exit=$? +set -e +[[ "$reuse_exit" -eq 2 ]] \ + || die "S11: expected exit 2 (connect refused), got $reuse_exit" +log "S11 OK" + +log "All eleven message-bus scenarios passed." From 41d25d3a3e755323631def30716bb467a1bac17b Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 11 Sep 2026 03:12:48 +0200 Subject: [PATCH 13/21] feat(msg-bus): resubscribe topics on reconnect --- .../lib/composables/useFolderTopic.svelte.ts | 9 +++ .../lib/composables/useReconnect.svelte.ts | 27 +++++++ frontend/src/lib/message-bus/client.svelte.ts | 71 +++++++++++++++++++ .../src/routes/files/[...path]/+page.svelte | 15 ++++ 4 files changed, 122 insertions(+) create mode 100644 frontend/src/lib/composables/useReconnect.svelte.ts diff --git a/frontend/src/lib/composables/useFolderTopic.svelte.ts b/frontend/src/lib/composables/useFolderTopic.svelte.ts index 747bec37..9193fb56 100644 --- a/frontend/src/lib/composables/useFolderTopic.svelte.ts +++ b/frontend/src/lib/composables/useFolderTopic.svelte.ts @@ -6,6 +6,7 @@ // `RtEventKind` — the switch below fails to type-check until every // arm is handled, keeping the FE exhaustive. +import { useReconnect } from './useReconnect.svelte'; import { useTopic } from './useTopic.svelte'; import type RtEventParams from '$lib/generated/message-bus/RtEventParams'; import type RtRevokedParams from '$lib/generated/message-bus/RtRevokedParams'; @@ -41,6 +42,13 @@ export interface FolderTopicHandlers { /** Grant revoked or folder deleted — the subscription is gone * server-side. Reasonable UX: toast + navigate away. */ onRevoked?: (params: RtRevokedParams) => void; + /** WS reconnected after a prior disconnect. Bus events published + * during the outage window are lost (in-memory bus, no replay), + * so the folder view has to refetch to catch up with the server. + * Typical wiring: `onReconnect: () => reload()`. Not called on + * the initial connect — the caller's own load path handles that. + * See `project_message_bus_reconnect_gap` memory. */ + onReconnect?: () => void; } /** @@ -61,6 +69,7 @@ export function useFolderTopic( return id ? `folder:${id}` : null; }; useTopic(topic, (params) => dispatch(params, handlers), handlers.onRevoked); + useReconnect(handlers.onReconnect); } function dispatch(params: RtEventParams, handlers: FolderTopicHandlers): void { diff --git a/frontend/src/lib/composables/useReconnect.svelte.ts b/frontend/src/lib/composables/useReconnect.svelte.ts new file mode 100644 index 00000000..476b761a --- /dev/null +++ b/frontend/src/lib/composables/useReconnect.svelte.ts @@ -0,0 +1,27 @@ +// Svelte 5 rune wrapper around `messageBus.onReconnect`. +// +// Fires the given callback the first time the WS reconnects after a +// prior disconnect (server restart, network blip, sleep/wake). +// **Not** called on the initial connect — the caller's own load path +// is already fetching then. Bridges the "events published during the +// disconnect window are lost" gap; consumers typically pass +// `reload()` so the view catches up with the server after the outage. +// +// See `client.svelte.ts::onReconnect` for lifecycle details and +// `project_message_bus_reconnect_gap` memory for the gap it closes. + +import { messageBus } from '$lib/message-bus/client.svelte'; + +/** + * Register `cb` as a reconnect handler for the lifetime of the + * calling component. Auto-unregisters on destroy via `$effect` + * cleanup. Passing `null`/`undefined` is a no-op — convenient for + * conditional wiring (`useReconnect(handlers.onReconnect)`). + */ +export function useReconnect(cb: (() => void) | null | undefined): void { + $effect(() => { + if (!cb) return; + const release = messageBus.onReconnect(cb); + return () => release(); + }); +} diff --git a/frontend/src/lib/message-bus/client.svelte.ts b/frontend/src/lib/message-bus/client.svelte.ts index a381ae30..fcc8982a 100644 --- a/frontend/src/lib/message-bus/client.svelte.ts +++ b/frontend/src/lib/message-bus/client.svelte.ts @@ -66,6 +66,15 @@ export type EventHandler = (params: RtEventParams) => void; * fires this so the consumer can toast / redirect / whatever. */ export type RevokedHandler = (params: RtRevokedParams) => void; +/** Callback invoked when the WS reconnects AFTER a prior disconnect — + * never on the first connect. Fires after client-side sub replay has + * been kicked off (`#sendSubscribe` for every known topic), so the + * handler can safely call `reload()`-style refetches knowing the + * post-reconnect event stream is armed. Bridges the "events published + * during the disconnect window are lost" gap — see + * `project_message_bus_reconnect_gap` memory. */ +export type ReconnectHandler = () => void; + /** Handle returned by `subscribe`. Call to release one refcount on the * topic; the client unsubscribes over the wire only when the last * refcount drops. Idempotent — calling twice from the same subscriber @@ -134,6 +143,17 @@ export class MessageBusClient { * `MAX_CONSECUTIVE_FAILURES` the client stops reconnecting and * requires an explicit `reconnect()` from the caller. */ #consecutiveFailures = 0; + /** True once we've observed at least one successful `#onOpen`. + * Used to distinguish "initial connect" (don't fire onReconnect + * handlers — the initial load path is doing the fetch already) + * from "reconnect" (do fire — events during the outage window + * were lost, consumers must refetch). */ + #hasConnectedBefore = false; + /** Reconnect handlers, invoked from `#onOpen` on the SECOND-onwards + * successful connect. Plain Set — internal registry, not + * reactive. Same rationale as `#subs` / `#pending`. */ + // eslint-disable-next-line svelte/prefer-svelte-reactivity + #reconnectHandlers = new Set(); /** `topic` → `{count, handlers, revokedHandlers, acked}`. Refcount * drives the wire: first refcount ⇒ send `rt.subscribe`; last drop @@ -225,6 +245,35 @@ export class MessageBusClient { }); } + /** + * Register a handler that fires when the WS reconnects AFTER a + * prior disconnect (server restart, network blip, sleep/wake). + * NOT called on the initial connect — that path is already + * handled by the consumer's own load logic. Returns an + * unsubscribe fn. + * + * Wrapped in `untrack` for the same reason `subscribe` is — + * reading `#hasConnectedBefore` etc. inside a caller's `$effect` + * would leak a reactive dep. Callers reach for this via the + * `useReconnect` composable, which manages the lifecycle. + * + * Bridges the "events lost during outage window" gap: consumers + * refetch on reconnect to bring their view back in line with the + * server, since bus publishes during the disconnect never reached + * this session. See `project_message_bus_reconnect_gap` memory. + */ + onReconnect(cb: ReconnectHandler): () => void { + return untrack(() => { + this.#reconnectHandlers.add(cb); + let released = false; + return () => { + if (released) return; + released = true; + this.#reconnectHandlers.delete(cb); + }; + }); + } + /** Force a fresh reconnect — for a live-updates toggle or a manual * "reconnect" button. Rare; not part of the normal flow. Also the * escape hatch after the circuit breaker trips: zeroes the @@ -320,6 +369,10 @@ export class MessageBusClient { this.state = 'connected'; this.#backoffMs = RECONNECT_MIN_MS; this.#consecutiveFailures = 0; + // Snapshot whether this is a reconnect BEFORE we flip the + // `hasConnectedBefore` bit, so handlers only fire on 2nd+ open. + const isReconnect = this.#hasConnectedBefore; + this.#hasConnectedBefore = true; // Replay every already-known topic. `entry.acked` is reset here // because the fresh connection has no server-side memory of // prior subscriptions. @@ -329,6 +382,24 @@ export class MessageBusClient { busLog.warn('resubscribe failed', { topic, error: err }) ); } + // Fire reconnect handlers AFTER sub replay is kicked (the + // `rt.subscribe` frames are on the socket; ack may be + // in-flight). Handlers refetching state via REST will see a + // consistent post-reconnect view; any events published between + // resubscribe and the handler's refetch race safely — a stale + // event just means one extra `reload()` on the next tick. + if (isReconnect && this.#reconnectHandlers.size > 0) { + busLog.debug('firing reconnect handlers', { + count: this.#reconnectHandlers.size + }); + for (const cb of this.#reconnectHandlers) { + try { + cb(); + } catch (err) { + busLog.warn('reconnect handler threw', { error: err }); + } + } + } } #onMessage(ev: MessageEvent): void { diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 3b4cf19b..9db4cf26 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -484,6 +484,21 @@ ); busLog.warn('folder access revoked', { topic: params.topic, reason: params.reason }); void goto(resolve('/files')); + }, + onReconnect: () => { + // WS reconnected after a prior disconnect — any bus events + // published during the outage window were dropped by the + // in-memory bus (no replay). Force a refetch so the listing + // catches up with the server-authoritative state. Goes + // through the same `scheduleLiveReload` coalescer as event- + // driven refreshes so a burst of reconnects (rare, but the + // circuit breaker can produce one) collapses to a single + // fetch. Passing an actor of `null`-equivalent — use an + // empty string so the echo-skip's `actor === user.id` + // check never matches. See + // `project_message_bus_reconnect_gap` memory. + busLog.warn('reconnected — refetching folder'); + scheduleLiveReload(''); } }); From 899bbd13a676539429682fdd7f00c8367683623e Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 11 Sep 2026 03:20:32 +0200 Subject: [PATCH 14/21] fix(msg-bus): prevent race on reconnect and update plan --- docs/plan/message-bus.md | 194 ++++++++++++++---- frontend/src/lib/message-bus/client.svelte.ts | 9 +- .../src/routes/files/[...path]/+page.svelte | 37 ++-- 3 files changed, 183 insertions(+), 57 deletions(-) diff --git a/docs/plan/message-bus.md b/docs/plan/message-bus.md index c58250ae..c28c96b7 100644 --- a/docs/plan/message-bus.md +++ b/docs/plan/message-bus.md @@ -16,6 +16,78 @@ editing is one consumer on top; folder-live updates, notifications, job progress, presence, and sync-client push invalidation follow with almost no extra scaffolding. +## Status — 2026-09-11 + +The `feat/message-bus` branch delivers **D + F + follow-ups shipped +end-to-end** on the FE and BE, verified by S1–S11 in the api-test +smoke suite plus manual multi-user E2E. Live today: + +- **Bus core** — `MessageBus` port + `InProcessMessageBus` + + `NoopReplicator`. `📤 bus publish` trace under + `RUST_LOG=oxicloud::message_bus=debug`. +- **WS handler** (`/api/rt/ws`) — JSON-RPC 2.0, `rt.subscribe / + unsubscribe / event / revoked / ping / pong`, server-initiated RFC + 6455 keepalive Ping. +- **Auth for the WS upgrade** — **F ticket flow shipped**. + `POST /api/rt/ticket` mints a one-shot 30 s ticket under the full + auth+DPoP+CSRF chain; the browser passes it via + `Sec-WebSocket-Protocol: oxi.ticket.`. Also accepts + `Authorization: Bearer ` for programmatic clients + (`rt-hurl-helper`). Route mounted OUTSIDE `protected_api` so the + standard DPoP-required middleware doesn't 401 browsers that can't + attach a `DPoP:` header to `new WebSocket()`. See + `handlers/rt_ws.rs` module doc. +- **Events firing end-to-end** — every `MessageBusEvent` variant + except CRDT-flavoured ones: + - `FileCreated / Renamed / Moved / Deleted` (via + `FileUploadService` + `FileManagementService`) + - `FolderCreated / Renamed / Moved / Deleted` (via `FolderService` + for direct paths; `TrashService` publishes `FolderDeleted` on the + trash-first path — the FE hits that path via + `DELETE /api/folders/{id}`) + - `AuthzChanged` (via `ShareService::revoke_grant`) drives the + grant-revocation eviction cascade. +- **Grant-revocation eviction (Slice C)** — WS handler + auto-subscribes each session to `user:{caller}:authz`; on + `AuthzChanged` the reader translates to `SessionOut::EvictFolders` + and emits `rt.revoked` per evicted topic. Scope is per-topic; the + session itself and unrelated subscriptions survive. +- **FE composables** — `useTopic` (generic), `useFolderTopic` + (folder-view sugar with per-verb + `onRevoked` + `onReconnect` + handlers), `useReconnect` (session-level, fires after 2nd+ open). + Types generated from AsyncAPI via `@asyncapi/modelina` in + `frontend/src/lib/generated/message-bus/`; `check-message-bus-spec` + CI + `pre-pull-request` block on drift. +- **Folder-view live refresh** — `+page.svelte` wires every + `onFile*`/`onFolder*` handler to `scheduleLiveReload`, 100 ms + coalesce. Revocation → toast + `goto('/files')`. Reconnect → + refetch via `onReconnect` (bridges the "events lost during outage + window" gap; see `project_message_bus_reconnect_gap` memory). + Actor-echo skip was REMOVED for multi-tab correctness — refetch is + idempotent, ~30 ms per self-mutation. +- **Client-side resilience** — jittered exponential backoff + (250 ms → 30 s cap), circuit breaker at 20 consecutive failures + (~5 minutes of retry — covers a cargo-release restart), `untrack` + in every mutation entry point so `$state` reads don't leak into + caller `$effect` deps. +- **AuthZ tested** — S3 (folder no_read), S4 (nonexistent folder = + anti-enum parity), S9 (cross-user identity topic → `topic_forbidden`). +- **Ticket tested** — S10 (happy path), S11 (single-use replay + rejected). + +Deferred and still open — see the Roadmap section and the +`project_message_bus_reconnect_gap` memory: + +- **Notifications table + bell** (E) — topic + producer + auto-sub + land here. Same pattern as `:authz`. +- **Presence** (Phase B) — `folder:{id}:presence` topic + awareness + frames. +- **Yjs collab** — `docs/plan/markdown-collab.md`, depends on the + binary-frame routing this plan sketches but doesn't ship. +- **Broker replicator** (Postgres LISTEN/NOTIFY or Redis) — for + multi-instance and durable event log. `BusReplicator` port + declared, `NoopReplicator` wired today. + ## Non-goals - Persistent event log with "you missed these" replay. Durable state @@ -352,25 +424,61 @@ everywhere. ## Frontend components -### 1. Singleton client (`lib/stores/message-bus.svelte.ts`) +### 1. Singleton client (`lib/message-bus/client.svelte.ts`) -- Fetches a ticket via `POST /api/rt/ticket` (through `apiFetch`, so - DPoP is applied). -- Opens `wss:///api/rt/ws?ticket=…`. +Location is `lib/message-bus/` (subsystem dir, mirrors `lib/auth/` and +`lib/upload/` — see `frontend/AGENTS.md`), NOT `lib/stores/` — the +client is subsystem-scoped plumbing, not global reactive state that +routes read from. + +- **Fetches a ticket** via `POST /api/rt/ticket` (through `apiFetch`, + so DPoP is applied; `getCsrfHeaders()` merged in for the state- + changing POST). Ticket then passes on the WS upgrade via + `Sec-WebSocket-Protocol: oxi.ticket.` (NOT a query param — + keeps the token off access logs and out of Referer / URL bar). +- Opens `wss:///api/rt/ws` with the subprotocol. - **Refcounted subscriptions**: - `subs: Map }>`. -- On subscribe by first component: send frame; on last unsubscribe: - send frame. -- On reconnect: reissue ticket, re-establish WS, re-send `subscribe` - for every live topic — components don't care. -- Backoff: exponential (250 ms → 30 s), full-jitter. -- Health: `$state({ connected, latencyMs, subscribedTopics })` - exposed for a debug indicator. + `#subs: Map`. +- On first refcount of a topic: send `rt.subscribe`; on last drop: + send `rt.unsubscribe`. +- **On reconnect**: replay every already-known topic (client-side + state survives the disconnect); fire `onReconnect` handlers so + consumers refetch and catch up on events dropped during the + outage window. +- **Backoff**: exponential (250 ms → 30 s), full-jitter. Circuit + breaker at 20 consecutive failures (~5 minutes of retry — + comfortably covers a cargo-release restart); trip logs one `error` + line and stops until `messageBus.reconnect()` is called or the + page reloads. +- **Reactive-safety rule**: every mutation entry point + (`subscribe`, `onReconnect`, `reconnect`, `close`, `#call`) wraps + its `$state` reads in `untrack(() => …)`. Without this a caller's + `$effect` inherits a hidden dep on `state`, and each transition + (idle → connecting → connected → disconnected → …) re-fires the + effect — an observed 1000+/s loop on server-down. See the + `feedback-svelte5-untrack-mutation-methods` memory for the + general rule and the docstring on `subscribe` for the concrete + case. +- **Health**: `state = $state` + + `latencyMs = $state` exposed for a future debug + indicator (no UI consumes them yet — silent MVP). -### 2. Composable (`lib/composables/useTopic.ts`) +### 2. Composables ```ts -useTopic(`folder:${folderId}`, (evt) => { /* mutate local $state */ }); +// Generic — subscribe to any topic. +useTopic(topic, onEvent, onRevoked?) + +// Folder-view sugar — per-verb handlers + reconnect hook. +useFolderTopic(() => folderId, { + onFileCreated, onFileRenamed, onFileMoved, onFileDeleted, + onFolderCreated, onFolderRenamed, onFolderMoved, onFolderDeleted, + onRevoked, // grant revoked, subscription evicted server-side + onReconnect, // WS came back; consumers refetch to catch up +}) + +// Session-level reconnect (fires on 2nd+ open, never initial). +useReconnect(cb) ``` Handles `$effect` lifecycle (subscribe on mount, unsubscribe on @@ -1047,28 +1155,44 @@ this baseline once the baseline is green. Ships the infrastructure and the two most visible consumers together. -- Bus port + `InProcessMessageBus` + `NoopReplicator` + WS handler - + ticket endpoint. -- Frontend singleton + `useTopic` composable. -- Topics live: `folder:{id}`, `user:{u}:notifications`, `job:{id}`, - `collab:{file_id}`, `collab:{file_id}:awareness`. -- **Folder-live updates**: `FolderService` / `FileManagementService` - publish `file.created` / `file.deleted` / `file.renamed` / - `file.moved` after commit; FE folder view subscribes and mutates - local state — no manual refresh. -- **Job dashboard live**: `JobRegistry` publishes step progress and - terminal state; FE job dashboard subscribes and replaces the - current polling. -- **Notifications table + bell**: new `notifications` table + - `NotificationService` port; initial ingesters for `share-granted`, - `new-login-from-new-device`, `job-completed-for-you`, - `storage-quota-threshold`. FE bell with unread count, slide-out - panel, toast pop on receive. -- **MD collab editor**: see companion plan - `docs/plan/markdown-collab.md` — depends on this phase's WS - handler + binary frame routing. +- **✅ Bus port** + `InProcessMessageBus` + `NoopReplicator` + WS + handler + **ticket endpoint (F)**. +- **✅ Frontend singleton** + `useTopic` + `useFolderTopic` + + `useReconnect` composables. `oxi:message-bus` logger namespace. +- **Topics live today**: `folder:{id}`, `user:{u}:authz`. +- **Topics reserved but not producing**: `user:{u}:notifications`, + `job:{id}`, `collab:{file_id}`, `collab:{file_id}:awareness` — + land with their consumers below. +- **✅ Folder-live updates**: `FolderService` / `FileUploadService` / + `FileManagementService` / `TrashService` publish `file_created / + renamed / moved / deleted` and `folder_created / renamed / moved / + deleted` after commit; FE folder view refetches on receipt (100 ms + coalesce, idempotent). Multi-user + multi-tab verified. +- **✅ Grant-revocation eviction** (Slice C): `AuthzChanged` → + per-topic `rt.revoked`; folder view toasts + navigates to + `/files`. Session survives; unrelated subs unaffected. +- **✅ Refetch-on-reconnect**: `messageBus.onReconnect(cb)` → + `useReconnect` composable → folder view refetches after WS comes + back. Bridges the in-memory-bus "events lost during outage" gap + (see `project_message_bus_reconnect_gap` memory). +- **Job dashboard live** — TODO. `JobRegistry` publishes step + progress and terminal state; FE job dashboard subscribes and + replaces polling. +- **Notifications table + bell** — TODO (Slice E). New + `notifications` table + `NotificationService` port; initial + ingesters for `share-granted`, `new-login-from-new-device`, + `job-completed-for-you`, `storage-quota-threshold`. FE bell with + unread count, slide-out panel, toast pop on receive. Auto-subscribe + to `user:{u}:notifications` server-side, same pattern as + `user:{u}:authz` today. +- **MD collab editor** — TODO. See companion plan + `docs/plan/markdown-collab.md`. Depends on binary-frame routing + which this plan sketches but doesn't ship (`rt_ws.rs` today drops + binary frames with a debug log). -Deliverables sized ~4 weeks end-to-end. +Deliverables sized ~4 weeks end-to-end. Slice D (folder-live) and +Slice F (ticket flow) landed 2026-09-11. Slices E + collab are the +open work in Phase A. ### Phase B — Presence + comments diff --git a/frontend/src/lib/message-bus/client.svelte.ts b/frontend/src/lib/message-bus/client.svelte.ts index fcc8982a..d3343487 100644 --- a/frontend/src/lib/message-bus/client.svelte.ts +++ b/frontend/src/lib/message-bus/client.svelte.ts @@ -104,10 +104,11 @@ const RECONNECT_MAX_MS = 30_000; * give up and stay `disconnected` until the caller explicitly asks * to `reconnect()`. Prevents an unrecoverable auth state (revoked * session, wrong CSRF cookie, missing DPoP nonce) from flooding - * logs. Ten attempts × exponential-backoff-with-jitter is roughly a - * minute of trying — long enough for a transient blip, short enough - * to stop before it's noise. */ -const MAX_CONSECUTIVE_FAILURES = 10; + * logs. Twenty attempts × exponential-backoff-with-jitter caps + * around 5 minutes of retrying — comfortably covers a cargo-release + * server restart on a hot machine while still short-circuiting a + * genuine permanent failure before it becomes noise. */ +const MAX_CONSECUTIVE_FAILURES = 20; interface SubEntry { count: number; diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 9db4cf26..b7e11afa 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -438,23 +438,27 @@ } // ── Live folder updates (message bus) ──────────────────────────── - // Subscribe to `folder:{currentId}` and refresh when another tab — - // or another user with a share — mutates something in this folder. - // The refresh call is coalesced through `#reloadScheduled` so a - // burst of events (e.g. a multi-file upload) collapses to a single - // fetch. Local mutations trigger `reload()` themselves, so events - // authored by this same user are dropped as echo (the `actor` on - // the event is the caller UUID from the server). + // Subscribe to `folder:{currentId}` and refresh when THIS session's + // tabs, another tab of the same user, or another user with a share + // mutates something in this folder. Refetch is coalesced through + // `reloadScheduled` so a burst of events (multi-file upload) collapses + // to a single fetch. + // + // Actor-echo skip was REMOVED: previously we skipped events whose + // `actor` equalled `session.user.id`, on the assumption "this tab + // already updated its state via the local mutation path". That is + // true for the ACTIVE tab, but it also silenced updates from OTHER + // TABS of the same user. Since `reload()` is idempotent (replaces + // `listing.files` with the same server state) the extra fetch on + // self-authored events costs one round-trip (~30 ms locally, never + // visible) and gains multi-tab correctness. The `reloadScheduled` + // coalescer already prevents redundant work when the local mutation + // path and the bus event race. // // See `docs/plan/message-bus.md § D` and the `useFolderTopic` // composable for the wiring. let reloadScheduled = false; - function scheduleLiveReload(actor: string): void { - // Actor echo: this same session's mutations already updated the - // listing through their own success path, so a re-fetch would - // only cost a round-trip. Other tabs of the same user still see - // the change (they render from their own state, not this one). - if (session.user?.id && actor === session.user.id) return; + function scheduleLiveReload(_actor: string): void { if (reloadScheduled) return; reloadScheduled = true; // Coalesce a burst; 100 ms is enough for the tail of a multi- @@ -493,12 +497,9 @@ // through the same `scheduleLiveReload` coalescer as event- // driven refreshes so a burst of reconnects (rare, but the // circuit breaker can produce one) collapses to a single - // fetch. Passing an actor of `null`-equivalent — use an - // empty string so the echo-skip's `actor === user.id` - // check never matches. See - // `project_message_bus_reconnect_gap` memory. + // fetch. See `project_message_bus_reconnect_gap` memory. busLog.warn('reconnected — refetching folder'); - scheduleLiveReload(''); + scheduleLiveReload('reconnect'); } }); From 758b1e0d6ea70ab2e86eb1f1200c04a8f5b6a7d5 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 11 Sep 2026 03:31:27 +0200 Subject: [PATCH 15/21] feat(msg-bus): notify the deleted folder himself cas where a client is browsing a folder being deleted --- docs/plan/message-bus.md | 52 +++++++++++++++++-- .../src/routes/files/[...path]/+page.svelte | 22 +++++++- src/application/services/trash_service.rs | 34 ++++++++---- 3 files changed, 94 insertions(+), 14 deletions(-) diff --git a/docs/plan/message-bus.md b/docs/plan/message-bus.md index c28c96b7..04a361da 100644 --- a/docs/plan/message-bus.md +++ b/docs/plan/message-bus.md @@ -992,11 +992,55 @@ Output JSON schema (for post-mortem assertions in shell): } ``` -### Coverage — four scenarios, each in the same test file +### Coverage — eleven scenarios, all green -Orchestrated by a single `tests/api/rt_bus_check.sh` invoked from -`tests/api/run.sh` after the main hurl block. Follows the -`refcount_cascade` / `thumb_import_check` patterns already in place. +The four MVP scenarios sketched below expanded to **S1–S11** as +Slice C, D, and F shipped. All orchestrated by +`tests/api/rt_bus_check.sh` invoked from `tests/api/run.sh` after the +main hurl block. Follows the `refcount_cascade` / +`thumb_import_check` patterns already in place. + +Scenarios live today: + +- **S1** — Positive delivery: subscribe A, upload into A, one + `file_created`. +- **S2** — Topic isolation: subscribe A, upload into B then A; + observe A's event only. +- **S3** — AuthZ denial: user2 subscribes to A without a grant → + `no_read`. +- **S4** — Anti-enumeration parity: subscribe to a nonexistent + folder returns the SAME `no_read` as S3. +- **S5** — Server keepalive: 3 s idle surfaces multiple RFC 6455 + Pings; session still delivers afterwards. +- **S6** — `file_deleted`: DELETE fires the publish hook. +- **S7** — Move fan-out: subscribe A+B, MOVE A→B, observe two + `file_moved` (one per topic). +- **S8** — Grant-revoke eviction (Slice C): user2 subscribes to + A+B (both granted); user1 revokes only A → `rt.revoked` for A, + upload to B still delivers. Session survives. +- **S9** — Cross-user identity gate: user1 subscribes to + `user:{user2_id}:authz` → `topic_forbidden` (identity mismatch; + audit reason `identity_mismatch`; wire response indistinguishable + from unknown topic per anti-enum). +- **S10** — Ticket happy path (Slice F): `POST /api/rt/ticket`, + open WS with `oxi.ticket.` subprotocol, subscribe + + deliver. +- **S11** — Ticket single-use (Slice F): reusing a redeemed + ticket fails the upgrade with 401 + audit + `message_bus.upgrade_rejected reason=ticket_invalid`. + +**Ready-file race fix**: the shell script uses a `wait_ready` +function that blocks on the helper's `--ready-file` (touched the +instant every requested subscribe is ack'd) instead of a +`sleep 0.4` heuristic that flaked on cold-cache runs. See +`rt-hurl-helper::Args::ready_file` and the wait_ready doc in the +shell script. + +**Always rebuild the helper** — the guard `[[ ! -x $HELPER_BIN ]]` +was removed 2026-09-11 because it silently reused stale binaries +whenever the helper's source changed without touching the caller +shell. Cargo incremental short-circuits in ~50 ms; the cost is +negligible, the trap-free experience is worth it. **Scenario 1 — Positive delivery** (fan-out works) diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index b7e11afa..c68e1750 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -476,7 +476,27 @@ onFolderCreated: (d) => scheduleLiveReload(d.actor), onFolderRenamed: (d) => scheduleLiveReload(d.actor), onFolderMoved: (d) => scheduleLiveReload(d.actor), - onFolderDeleted: (d) => scheduleLiveReload(d.actor), + onFolderDeleted: (d) => { + // Two cases fanned out from the server-side publish: + // * `d.folder_id !== currentId` — a SUBFOLDER of the + // current view was deleted. Refetch the listing so + // the row disappears (existing behavior). + // * `d.folder_id === currentId` — the VIEWED folder + // itself just got trashed. The FolderService trashes + // the subtree (soft-delete cascade); staying here + // would show a zombie view. Toast + navigate to + // `/files`, same UX as `onRevoked` for grant + // eviction. See `TrashService::move_to_trash` and + // `docs/plan/message-bus.md § Status` for the + // dual-topic publish rationale. + if (d.folder_id === currentId) { + ui.notify(t('files.folder_was_deleted', 'This folder was moved to trash.'), 'warning'); + busLog.warn('viewed folder was deleted', { folder_id: d.folder_id }); + void goto(resolve('/files')); + return; + } + scheduleLiveReload(d.actor); + }, onRevoked: (params) => { // The subscription is already gone server-side. Notify the // user and send them back to their home so they don't sit diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index 576c71b9..0a62cb51 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -304,17 +304,33 @@ impl TrashUseCase for TrashService { // doesn't panic here. if let (Some(bus), Some(parent_uuid)) = (&self.bus, parent_snapshot) { debug!( - "publishing FolderDeleted folder={} parent={} actor={}", + "publishing FolderDeleted folder={} parent={} actor={} (2 topics)", folder_id, parent_uuid, user_id ); - bus.publish( - &Topic::Folder(parent_uuid), - MessageBusEvent::FolderDeleted { - folder_id, - parent_id: parent_uuid, - actor: user_id, - }, - ); + let event = MessageBusEvent::FolderDeleted { + folder_id, + parent_id: parent_uuid, + actor: user_id, + }; + // Publish on BOTH the parent's topic AND the deleted + // folder's own topic: + // + // * Parent topic — viewers of the parent see the + // child disappear from their listing (existing + // behavior, verified by tests). + // * Deleted-folder topic — viewers INSIDE the + // folder that just got trashed are stranded on a + // folder that no longer exists. Delivering the + // same `folder_deleted` event on this topic lets + // the FE `onFolderDeleted` handler detect + // `data.folder_id === currentId` and navigate + // away with a toast (same UX as `onRevoked` + // surfaces for grant-revocation eviction). + // Otherwise the sub would silently stop + // receiving events and the tab would sit on a + // zombie view. + bus.publish(&Topic::Folder(parent_uuid), event.clone()); + bus.publish(&Topic::Folder(folder_id), event); } else { debug!( "trash-folder publish skipped: bus={} parent={:?}", From 5083eaeaba80b4d2a5ed42ae7b74530f908c9fb3 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 11 Sep 2026 12:10:56 +0200 Subject: [PATCH 16/21] feat(config): add server config + can disable message-bus - server now provide it's config via /api/config (possibility to feature flag) - client use /api/config to enable / disable some features - capability to disable the message bus, somme OPS may not want this feature and consume persistent connections from server (websocket): OXICLOUD_MESSAGEBUS_ENABLE (true by default) --- docs/config/env.md | 9 +- example.env | 23 ++- frontend/src/hooks.client.ts | 13 +- frontend/src/lib/api/endpoints/config.ts | 19 +++ frontend/src/lib/api/types.ts | 53 +++++++ .../lib/composables/useReconnect.svelte.ts | 6 +- .../src/lib/composables/useTopic.svelte.ts | 8 ++ .../src/lib/stores/serverConfig.svelte.ts | 92 ++++++++++++ src/bin/generate-asyncapi.rs | 2 +- src/common/config.rs | 29 ++++ src/interfaces/api/handlers/config_handler.rs | 134 ++++++++++++++++++ src/interfaces/api/handlers/mod.rs | 1 + src/interfaces/api/handlers/rt_ws.rs | 4 +- src/interfaces/api/mod.rs | 9 ++ src/interfaces/api/routes.rs | 26 +++- src/interfaces/middleware/server_status.rs | 67 +++++++-- src/main.rs | 47 +++--- tests/api/rt_bus_check.sh | 2 +- tests/common/server.env | 2 +- 19 files changed, 499 insertions(+), 47 deletions(-) create mode 100644 frontend/src/lib/api/endpoints/config.ts create mode 100644 frontend/src/lib/stores/serverConfig.svelte.ts create mode 100644 src/interfaces/api/handlers/config_handler.rs diff --git a/docs/config/env.md b/docs/config/env.md index d9b4c385..9e7df501 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -355,13 +355,16 @@ Today's shipped locales: `ar, de, en, es, fa, fr, hi, it, ja, ko, nl, pl, pt, ru Example: `OXICLOUD_TRUST_PROXY_CIDR=127.0.0.1/32,10.0.0.0/8,172.16.0.0/12` -## Message bus WebSocket +## Message bus | Variable | Default | Description | |---|---|---| -| `OXICLOUD_RT_WS_KEEPALIVE_SECONDS` | `30` | Server-initiated protocol Ping interval on `/api/rt/ws`. Prevents intermediate proxies (Traefik, nginx, Cloudflare) and NAT boxes from reaping the TCP session as idle. Read at each WS connect — a change takes effect on new connections, no restart needed. Set `0` or any non-positive value to fall back to the default. | +| `OXICLOUD_MESSAGEBUS_ENABLE` | `true` | Master switch for the message bus. When `false`, the routes `/api/rt/ws` and `POST /api/rt/ticket` are **not registered** at boot — Axum returns `404 Not Found` for both, keeping monitoring dashboards free of 5xx noise. Publish sites in the services stay unchanged (the in-process bus still runs, publishes to nobody are cheap no-ops), so no service code path branches on this flag — the toggle is purely at the API surface. Clients discover this via `GET /api/config.features.message_bus` and skip WS setup entirely (no reconnect flood, no wasted round-trips). **Why an operator might turn it off**: each logged-in browser holds a persistent WebSocket connection while a folder view is open. `N` users × `M` tabs = `N × M` sustained TCP + TLS + WS sessions on the server, each consuming an fd, ~a few KB of tokio task state, and any tuple your L4/L7 load balancer keeps for the flow. On tightly-provisioned VPS deployments (low fd ulimit, tight memory), behind WebSocket-hostile reverse proxies that can't be reconfigured, or during an operational triage where you want to shed WS load, set this to `false` — the SPA transparently falls back to its pre-message-bus behavior (updates land on the next navigation / refresh instead of live). | +| `OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS` | `30` | Server-initiated protocol Ping interval on `/api/rt/ws`. Prevents intermediate proxies (Traefik, nginx, Cloudflare) and NAT boxes from reaping the TCP session as idle. Read at each WS connect — a change takes effect on new connections, no restart needed. Set `0` or any non-positive value to fall back to the default. | -Tuning: 30 s is comfortably under nginx's 60 s `proxy_read_timeout` default and Cloudflare's 100 s hard limit. Behind Traefik with `respondingTimeouts.idleTimeout` bumped to `3600s` (as documented in the reverse-proxy setup), you can leave this at 30 s or raise it — the interval should sit at most half the smallest hop's idle timeout so a single missed Ping doesn't reap the connection. +Tuning the keepalive interval: 30 s is comfortably under nginx's 60 s `proxy_read_timeout` default and Cloudflare's 100 s hard limit. Behind Traefik with `respondingTimeouts.idleTimeout` bumped to `3600s` (as documented in the reverse-proxy setup), you can leave this at 30 s or raise it — the interval should sit at most half the smallest hop's idle timeout so a single missed Ping doesn't reap the connection. + +**Rename note (feat/message-bus branch)**: `OXICLOUD_RT_WS_KEEPALIVE_SECONDS` was renamed to `OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS` — hard cutover, no fallback. Update any `.env` file that set the old name. ## Allocator Tuning diff --git a/example.env b/example.env index d67ecc9f..bffef96d 100644 --- a/example.env +++ b/example.env @@ -1112,7 +1112,28 @@ OXICLOUD_WOPI_ENABLED=false # * nginx `proxy_read_timeout` default 60s → ping ≤ 30s # * Cloudflare hard limit 100s → ping ≤ 45s # * Traefik with idleTimeout bumped to 3600s → 30s is safely under -#OXICLOUD_RT_WS_KEEPALIVE_SECONDS=30 +#OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS=30 + +# Message bus master switch. When `false`, /api/rt/ws and +# POST /api/rt/ticket are NOT registered at boot — Axum returns 404 +# for both, keeping monitoring dashboards free of 5xx noise. Clients +# discover this via GET /api/config.features.message_bus and skip WS +# setup entirely. Publish sites in the services stay unchanged (bus +# still runs internally; publishes to no subscribers are cheap no-ops). +# +# Why an operator might turn this off: each logged-in browser holds a +# persistent WebSocket connection while a folder view is open. Total +# sustained sessions on the server = users × open tabs, each consuming +# an fd, a few KB of tokio task state, and whatever tuple your L4/L7 +# load balancer keeps for the flow. On tightly-provisioned VPS +# deployments (low fd ulimit, tight memory), behind WebSocket-hostile +# reverse proxies that can't be reconfigured, or during an operational +# triage where you want to shed WS load fast, set this to false — the +# SPA falls back to its pre-message-bus behavior transparently +# (updates land on the next nav / refresh instead of live). +# +# Default: true. +#OXICLOUD_MESSAGEBUS_ENABLE=true # ----------------------------------------------------------------------------- # MEMORY ALLOCATOR TUNING (IMPORTANT FOR RAM USAGE) diff --git a/frontend/src/hooks.client.ts b/frontend/src/hooks.client.ts index c8c35418..e98c55df 100644 --- a/frontend/src/hooks.client.ts +++ b/frontend/src/hooks.client.ts @@ -6,6 +6,7 @@ import log from 'loglevel'; import { setSessionExpiredHandler } from '$lib/api/client'; import { initI18n } from '$lib/i18n/index.svelte'; +import { serverConfig } from '$lib/stores/serverConfig.svelte'; import { session } from '$lib/stores/session.svelte'; import { seedNonceFromCookie } from '$lib/auth/dpop-proof'; @@ -14,7 +15,8 @@ import { seedNonceFromCookie } from '$lib/auth/dpop-proof'; // needing to import anything. // // Log levels — namespaces used today: `oxi:upload` (delta + direct -// upload pipeline), `oxi:message-bus` (WebSocket client + `useTopic`). +// upload pipeline), `oxi:message-bus` (WebSocket client + `useTopic`), +// `oxi:config` (server-config boot fetch). // Levels: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent'. // Choices persist to `localStorage['loglevel:']` via loglevel. // @@ -119,5 +121,12 @@ export async function init(): Promise { // bound request and eat a `use_dpop_nonce` 401 → retry cycle. seedNonceFromCookie(); - await initI18n(); + // Boot in parallel: translations and server-config discovery are + // independent of each other, and both must resolve before any route + // mounts. `serverConfig.load()` primes the reactive feature-flag + // store; `useTopic` / `useFolderTopic` / `useReconnect` read from + // it to decide whether to open a WebSocket at all. See + // `stores/serverConfig.svelte.ts` for the failure semantics + // (defaults preserved on fetch error). + await Promise.all([initI18n(), serverConfig.load()]); } diff --git a/frontend/src/lib/api/endpoints/config.ts b/frontend/src/lib/api/endpoints/config.ts new file mode 100644 index 00000000..e71fe024 --- /dev/null +++ b/frontend/src/lib/api/endpoints/config.ts @@ -0,0 +1,19 @@ +/** + * `GET /api/config` — public server-configuration discovery. + * + * Called once at SPA boot from `hooks.client.ts` to hydrate the + * `serverConfig` reactive store. Feature flags and server-status live + * side-by-side on the response so a single round-trip primes the FE + * for the whole session. Subsequent live status changes propagate + * through the `X-Server-Status` response header (same shape). + * + * Unauthenticated — no session cookie required. Nothing on this + * endpoint is per-user or privacy-sensitive. + */ + +import { apiJson } from '$lib/api/client'; +import type { ServerConfig } from '$lib/api/types'; + +export function fetchServerConfig(): Promise { + return apiJson('/api/config'); +} diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 4758bf7c..9432e3df 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -878,3 +878,56 @@ export interface AdminSessionsPage { * but any in-flight JWT stays valid until its `exp`. */ access_token_expiry_secs: number; } + +// ── /api/config — public server-configuration discovery ──────────────────── + +/** Boolean matrix of enabled optional subsystems. Mirrors the server's + * `FeaturesConfig`; adding a field is additive (clients ignore unknown + * fields, no field is ever repurposed — same discipline as JSON-RPC + * error codes on the message bus). */ +export interface ServerFeatures { + /** Message bus over WebSocket. When `false`, `/api/rt/ws` and + * `/api/rt/ticket` are unmounted server-side — clients skip WS setup + * entirely (see `$lib/message-bus/client.svelte.ts`). */ + message_bus: boolean; + trash: boolean; + search: boolean; + sharing: boolean; + quotas: boolean; + music: boolean; + places: boolean; + faces: boolean; + video_thumbnails: boolean; + external_mounts: boolean; +} + +/** One row in `ServerStatus.migration` / `ServerStatus.rotation` — a + * server-side long-running operation surfacing its progress to the SPA + * banner. Same JSON shape both fields share. */ +export interface ServerStatusProgress { + /** Short target name (e.g. `"backend_migration"`, `"rotation_v2"`). */ + target: string; + migrated: number; + total: number; + /** Integer 0-100. */ + percent: number; +} + +/** Live server-status snapshot. Same shape and field names as the + * `X-Server-Status` header stamped on every response — the boot fetch + * from `/api/config` and the per-request header both share this wire + * vocabulary. Field-level absence means "nothing running"; the client + * can safely assume `readonly === false && !migration && !rotation` is + * the normal case. */ +export interface ServerStatus { + readonly: boolean; + migration?: ServerStatusProgress; + rotation?: ServerStatusProgress; +} + +/** Response of `GET /api/config`. Public, unauthenticated. */ +export interface ServerConfig { + version: string; + features: ServerFeatures; + server_status: ServerStatus; +} diff --git a/frontend/src/lib/composables/useReconnect.svelte.ts b/frontend/src/lib/composables/useReconnect.svelte.ts index 476b761a..2d0eb200 100644 --- a/frontend/src/lib/composables/useReconnect.svelte.ts +++ b/frontend/src/lib/composables/useReconnect.svelte.ts @@ -11,16 +11,20 @@ // `project_message_bus_reconnect_gap` memory for the gap it closes. import { messageBus } from '$lib/message-bus/client.svelte'; +import { serverConfig } from '$lib/stores/serverConfig.svelte'; /** * Register `cb` as a reconnect handler for the lifetime of the * calling component. Auto-unregisters on destroy via `$effect` * cleanup. Passing `null`/`undefined` is a no-op — convenient for * conditional wiring (`useReconnect(handlers.onReconnect)`). + * + * Also a no-op when the server has the message bus disabled — the + * WS never opens, so a reconnect callback can never fire. */ export function useReconnect(cb: (() => void) | null | undefined): void { $effect(() => { - if (!cb) return; + if (!cb || !serverConfig.features.message_bus) return; const release = messageBus.onReconnect(cb); return () => release(); }); diff --git a/frontend/src/lib/composables/useTopic.svelte.ts b/frontend/src/lib/composables/useTopic.svelte.ts index cf5fe4a9..cf0167ef 100644 --- a/frontend/src/lib/composables/useTopic.svelte.ts +++ b/frontend/src/lib/composables/useTopic.svelte.ts @@ -10,6 +10,7 @@ // re-subscribes when it changes. Static `topic`: pass a plain string. import { messageBus } from '$lib/message-bus/client.svelte'; +import { serverConfig } from '$lib/stores/serverConfig.svelte'; import type RtEventParams from '$lib/generated/message-bus/RtEventParams'; import type RtRevokedParams from '$lib/generated/message-bus/RtRevokedParams'; @@ -32,6 +33,13 @@ export function useTopic( onRevoked?: (params: RtRevokedParams) => void ): void { $effect(() => { + // Server may have the message bus disabled (`/api/rt/ws` route + // unmounted → 404). Skip the subscribe entirely to avoid a + // pointless connect + circuit-breaker cycle. `serverConfig` is + // loaded before any route mounts (`hooks.client.ts` awaits it), + // so this read reflects the real server value, not the + // pre-load default. + if (!serverConfig.features.message_bus) return; const resolved = typeof topic === 'function' ? topic() : topic; if (!resolved) return; const release = messageBus.subscribe(resolved, onEvent, onRevoked); diff --git a/frontend/src/lib/stores/serverConfig.svelte.ts b/frontend/src/lib/stores/serverConfig.svelte.ts new file mode 100644 index 00000000..9c71791a --- /dev/null +++ b/frontend/src/lib/stores/serverConfig.svelte.ts @@ -0,0 +1,92 @@ +/** + * Server-configuration store — hydrated once at SPA boot from + * `GET /api/config`. + * + * Exposes feature-flag and server-status snapshots that the rest of + * the app reads reactively to enable/disable optional UI. The most + * consequential consumer today is the message bus: `useTopic`, + * `useFolderTopic`, and `useReconnect` all return early when + * `serverConfig.features.message_bus === false`, so a deployment + * with the bus disabled produces zero WS traffic from the client. + * + * Boot order (see `hooks.client.ts`): this store's `load()` runs + * alongside `initI18n()` before any route mounts, guaranteeing every + * composable reads a real value (never the pre-load defaults). + * + * Failure to load `/api/config` (network error, 5xx) leaves the + * defaults in place — every feature `true`, `readonly: false`. That's + * the pre-flag behavior; downstream WS setup then hits its own + * failure paths (503 for the endpoint if truly disabled, circuit + * breaker after 20 retries) instead of crashing boot. A warn line is + * logged either way so operators can spot the failure. + */ + +import log from 'loglevel'; + +import { fetchServerConfig } from '$lib/api/endpoints/config'; +import type { ServerConfig, ServerFeatures, ServerStatus } from '$lib/api/types'; + +/** Sensible defaults for every field. Used before `load()` resolves + * and as the fallback if the fetch fails — every feature enabled, + * server status nominal. Matches the pre-`OXICLOUD_MESSAGEBUS_ENABLE` + * behavior so an SPA that can't reach the endpoint still tries the + * same code paths it always did. */ +const DEFAULT_FEATURES: ServerFeatures = { + message_bus: true, + trash: true, + search: true, + sharing: true, + quotas: false, + music: true, + places: true, + faces: false, + video_thumbnails: true, + external_mounts: false +}; + +const DEFAULT_STATUS: ServerStatus = { + readonly: false +}; + +const cfgLog = log.getLogger('oxi:config'); + +class ServerConfigStore { + /** Server version — populated after `load()`. `null` before. */ + version = $state(null); + /** Feature flags. Defaults are all-enabled so pre-load code paths + * don't accidentally hide UI while the fetch is in flight. */ + features = $state({ ...DEFAULT_FEATURES }); + /** Server-status snapshot. Live changes after `load()` propagate + * through the `X-Server-Status` header (see + * `stores/serverStatus.svelte.ts` — separate store, updated by + * `apiFetch`). This store's `server_status` reflects only the + * boot snapshot; consumers that need live status should read + * the other store. */ + serverStatus = $state({ ...DEFAULT_STATUS }); + /** `true` once `load()` has resolved (success OR failure). Guards + * callers that want to skip work until the boot snapshot is in. */ + loaded = $state(false); + + async load(): Promise { + try { + const cfg: ServerConfig = await fetchServerConfig(); + this.version = cfg.version; + this.features = cfg.features; + this.serverStatus = cfg.server_status; + cfgLog.debug('server config loaded', { + version: cfg.version, + message_bus: cfg.features.message_bus + }); + } catch (err) { + // Fall through to defaults — SPA still boots. Any feature + // actually disabled server-side will surface as a 404 at + // call time (which is fine — that's how the guards are + // designed to be observable). + cfgLog.warn('server config fetch failed — using defaults', { error: err }); + } finally { + this.loaded = true; + } + } +} + +export const serverConfig = new ServerConfigStore(); diff --git a/src/bin/generate-asyncapi.rs b/src/bin/generate-asyncapi.rs index 45c5f75d..3df67207 100644 --- a/src/bin/generate-asyncapi.rs +++ b/src/bin/generate-asyncapi.rs @@ -186,7 +186,7 @@ fn operations() -> Value { ] }, // Application-layer keepalive. Separate from the RFC 6455 Ping - // control frame the server sends on `OXICLOUD_RT_WS_KEEPALIVE_SECONDS` + // control frame the server sends on `OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS` // (which is transport-level and not modelled in AsyncAPI). This // operation lets a client actively confirm the socket is // end-to-end alive when transport-level Pings alone can't rule diff --git a/src/common/config.rs b/src/common/config.rs index f006fb99..6d5c9105 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -2284,6 +2284,21 @@ pub struct FeaturesConfig { /// Env: `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`. pub webdav_drive_listing_prefix: String, + /// Message-bus master switch. When `false`, the WS route + /// `/api/rt/ws` and the ticket endpoint `POST /api/rt/ticket` + /// are **not registered** at boot — Axum returns 404 for both, + /// no 5xx alerts, no ambiguity. Publish sites in the services + /// stay unchanged (the in-process bus still runs, publishes to + /// nobody are cheap no-ops), so no service code paths branch on + /// this flag — the toggle is purely at the API surface. + /// + /// Clients discover this via `GET /api/config.features.message_bus` + /// and skip WS setup entirely when false — no reconnect flood, + /// no wasted round-trips. + /// + /// Env: `OXICLOUD_MESSAGEBUS_ENABLE` (default `true`). + pub enable_message_bus: bool, + /// Background purge of expired `storage.role_grants` rows. /// /// The AuthZ engine already filters expired grants out of every @@ -2483,6 +2498,7 @@ impl Default for FeaturesConfig { // maps to the caller's default drive; drive listing is // reachable at `/webdav/@drive/`. webdav_drive_listing_prefix: "@drive".to_string(), + enable_message_bus: true, // Message bus (WS + ticket) on by default grant_cleanup: GrantCleanupConfig::default(), } } @@ -3357,6 +3373,19 @@ impl AppConfig { config.features.enable_trash = val; } + // Message bus (WS + ticket endpoints). Follows the + // `OXICLOUD_MESSAGEBUS_*` naming rather than + // `OXICLOUD_ENABLE_MESSAGEBUS` — the `MESSAGEBUS` prefix groups + // this with `OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS` at the env + // level. Internal struct field keeps the codebase-wide + // `enable_*` convention. + if let Ok(enable_message_bus) = + env::var("OXICLOUD_MESSAGEBUS_ENABLE").map(|v| v.parse::()) + && let Ok(val) = enable_message_bus + { + config.features.enable_message_bus = val; + } + if let Ok(enable_search) = env::var("OXICLOUD_ENABLE_SEARCH").map(|v| v.parse::()) && let Ok(val) = enable_search { diff --git a/src/interfaces/api/handlers/config_handler.rs b/src/interfaces/api/handlers/config_handler.rs new file mode 100644 index 00000000..56eafc6b --- /dev/null +++ b/src/interfaces/api/handlers/config_handler.rs @@ -0,0 +1,134 @@ +//! `GET /api/config` — public server-configuration discovery. +//! +//! Advertises the subset of `AppState` a client needs to know at boot: +//! feature flags (which optional systems are enabled), server version, +//! and the current server-status snapshot (matches whatever the +//! `X-Server-Status` header carries live). Everything auth-related +//! stays under `GET /api/auth/oidc/providers` — the two endpoints are +//! sibling capability advertisements, not one canonical thing. +//! +//! # Scope +//! +//! Only fields with **no privacy implications**: +//! +//! - `features.*` — boolean matrix of enabled subsystems (message bus, +//! trash, search, sharing, quotas, plugins, WOPI). Same information +//! any logged-in caller could infer from probing endpoints; giving +//! it up front is a UX win. +//! - `version` — same string the `/api/version` endpoint returns +//! (CARGO_PKG_VERSION + git SHA). Public build metadata. +//! - `server_status` — a snapshot of the mutable server-status state +//! (maintenance mode, degraded mode, etc.). Same shape the +//! `X-Server-Status` header stamps on every response; this endpoint +//! just lets the FE hydrate the store at boot without waiting for +//! the first authenticated response. +//! +//! Anything requiring auth (per-user preferences, admin-visible +//! deployment secrets, session state) does NOT go here — those live +//! on `/api/auth/me` or `/api/admin/*`. + +use std::sync::Arc; + +use axum::{Json, extract::State}; +use serde::Serialize; + +use crate::common::di::AppState; +use crate::interfaces::middleware::server_status::{HeaderPayload, build_header_payload}; + +/// Server-configuration DTO. Additive over time — clients ignore +/// unknown fields, and no field is ever repurposed (same discipline +/// as JSON-RPC error codes on the message bus). +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct ServerConfigDto { + /// Server version — `CARGO_PKG_VERSION` from `Cargo.toml`. Matches + /// what `GET /api/version` returns. + pub version: &'static str, + + /// Feature flags — which subsystems the server has enabled. + /// Clients gate optional UI on these (e.g. hide the notification + /// bell if `features.message_bus` is false, since the bell would + /// have no delivery channel). + pub features: FeaturesDto, + + /// Live server-status snapshot — exact same shape and field + /// names as the `X-Server-Status` response header. Clients use + /// this to hydrate their reactive store at boot; subsequent live + /// changes propagate through the header on every other request + /// (the middleware and this endpoint share `build_header_payload` + /// so drift is impossible). Non-optional so the client always + /// has a definite value; `readonly: false` with no `migration` + /// or `rotation` is the "everything nominal" case. + pub server_status: HeaderPayload, +} + +/// Feature-flag block within [`ServerConfigDto`]. One boolean per +/// optional subsystem. Adding a new feature: append a field with a +/// default that matches the server-side default; NEVER remove a field +/// (client code may depend on the absence of a `false` value to mean +/// "unknown"). +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct FeaturesDto { + /// Message bus over WebSocket. When `false`, `/api/rt/ws` and + /// `/api/rt/ticket` are not registered — clients skip WS setup + /// entirely. See `FeaturesConfig::enable_message_bus`. + pub message_bus: bool, + /// Recycle bin / soft-delete flow. When `false`, deletes are + /// permanent — no `/api/trash` endpoint. See + /// `FeaturesConfig::enable_trash`. + pub trash: bool, + /// Full-text and metadata search (`/api/search/*`). See + /// `FeaturesConfig::enable_search`. + pub search: bool, + /// File sharing (public share links + user-to-user grants). See + /// `FeaturesConfig::enable_file_sharing`. + pub sharing: bool, + /// Per-user storage-quota enforcement on the upload path. See + /// `FeaturesConfig::enable_user_storage_quotas`. + pub quotas: bool, + /// Music player + playlists. See `FeaturesConfig::enable_music`. + pub music: bool, + /// Photo-map ("Places") tab. See `FeaturesConfig::enable_places`. + pub places: bool, + /// Face detection + identity clustering ("People"). Biometric — + /// OFF by default. See `FeaturesConfig::enable_faces`. + pub faces: bool, + /// Server-side video-thumbnail generation via ffmpeg. See + /// `FeaturesConfig::enable_video_thumbnails`. + pub video_thumbnails: bool, + /// Admin-configured external filesystem mounts. See + /// `FeaturesConfig::enable_external_mounts`. + pub external_mounts: bool, +} + +/// `GET /api/config` — return the public server-configuration +/// snapshot. Unauthenticated. No cache header — values change on +/// server-restart / feature-toggle / status flip, and the endpoint +/// is called at most once per SPA boot per client. Adding a short +/// `Cache-Control` TTL later is safe if load ever becomes a concern. +#[utoipa::path( + get, + path = "/api/config", + tag = "config", + responses( + (status = 200, description = "Public server configuration", body = ServerConfigDto), + ), +)] +pub async fn get_config(State(state): State>) -> Json { + let f = &state.core.config.features; + Json(ServerConfigDto { + version: env!("CARGO_PKG_VERSION"), + features: FeaturesDto { + message_bus: f.enable_message_bus, + trash: f.enable_trash, + search: f.enable_search, + sharing: f.enable_file_sharing, + quotas: f.enable_user_storage_quotas, + music: f.enable_music, + places: f.enable_places, + faces: f.enable_faces, + video_thumbnails: f.enable_video_thumbnails, + external_mounts: f.enable_external_mounts, + }, + server_status: build_header_payload(&state), + }) +} diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index 19e33984..c3b3e7b0 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -7,6 +7,7 @@ pub mod caldav_handler; pub mod caller_flags; pub mod carddav_handler; pub mod chunked_upload_handler; +pub mod config_handler; pub mod contacts_handler; pub mod dedup_handler; pub mod delta_upload_handler; diff --git a/src/interfaces/api/handlers/rt_ws.rs b/src/interfaces/api/handlers/rt_ws.rs index 2b1944e8..57524013 100644 --- a/src/interfaces/api/handlers/rt_ws.rs +++ b/src/interfaces/api/handlers/rt_ws.rs @@ -90,7 +90,7 @@ const OUTBOUND_CHANNEL_CAPACITY: usize = 512; /// default and Cloudflare's 100 s hard limit; behind Traefik we /// document a much longer `idleTimeout` anyway. /// -/// Overridable at server start via `OXICLOUD_RT_WS_KEEPALIVE_SECONDS` +/// Overridable at server start via `OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS` /// — test suites drop it to a low value to exercise the keepalive path /// within a bounded wall-clock. const DEFAULT_KEEPALIVE_SECONDS: u64 = 30; @@ -101,7 +101,7 @@ const DEFAULT_KEEPALIVE_SECONDS: u64 = 30; /// useful for smoke tests that toggle the value on the fly. fn keepalive_interval() -> Duration { Duration::from_secs( - std::env::var("OXICLOUD_RT_WS_KEEPALIVE_SECONDS") + std::env::var("OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS") .ok() .and_then(|s| s.parse().ok()) .filter(|&n: &u64| n > 0) diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index 9bc4cfcc..4376d02e 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -55,12 +55,14 @@ use crate::interfaces::api::handlers::auth_handler::SystemStatus; use crate::interfaces::api::handlers::chunked_upload_handler::{ CompleteUploadResponse, CreateUploadRequest, }; +use crate::interfaces::api::handlers::config_handler::{FeaturesDto, ServerConfigDto}; use crate::interfaces::api::handlers::contacts_handler::{ AddMemberRequest, AddressBookResponse, CreateAddressBookRequest, CreateContactRequest, GroupNameRequest, UpdateAddressBookRequest, UpdateContactRequest, }; use crate::interfaces::api::handlers::dedup_handler::{HashCheckResponse, StatsResponse}; use crate::interfaces::api::handlers::file_handler::MoveFilePayload; +use crate::interfaces::middleware::server_status::{HeaderPayload, ProgressHeader}; #[derive(OpenApi)] #[openapi( @@ -332,6 +334,8 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::subject_group_handler::remove_user_member, handlers::subject_group_handler::remove_group_member, handlers::subject_group_handler::list_effective_members, + // Public server-config discovery. + handlers::config_handler::get_config, ), components( schemas( @@ -375,6 +379,11 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; ChangePasswordDto, RefreshTokenDto, SystemStatus, + // Public server-config discovery — `GET /api/config`. + ServerConfigDto, + FeaturesDto, + HeaderPayload, + ProgressHeader, OidcProviderInfoDto, OidcExchangeDto, // Admin sessions panel — wire shape for `/api/admin/sessions`. diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 5d9aba3b..fc092204 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -167,6 +167,16 @@ pub fn create_public_api_routes(app_state: &Arc) -> Router) -> Router> { // the protected router (auth + DPoP), so the caller proves session // + DPoP-key possession before a ticket is minted. See // `handlers/rt_ticket_handler.rs` and `docs/plan/message-bus.md § F`. - router = router.route( - "/rt/ticket", - post(crate::interfaces::api::handlers::rt_ticket_handler::issue_rt_ticket) - .with_state(app_state.clone()), - ); + // + // Gated by `enable_message_bus`: when disabled, the route is NOT + // registered — Axum returns 404 (no 5xx alerts, no ambiguous 403). + // The paired WS route in `main.rs` uses the same guard. + if app_state.core.config.features.enable_message_bus { + router = router.route( + "/rt/ticket", + post(crate::interfaces::api::handlers::rt_ticket_handler::issue_rt_ticket) + .with_state(app_state.clone()), + ); + } // The WS upgrade (`GET /api/rt/ws`) is registered OUTSIDE the // protected-api middleware stack — a browser cannot attach a diff --git a/src/interfaces/middleware/server_status.rs b/src/interfaces/middleware/server_status.rs index 80926376..49057a8e 100644 --- a/src/interfaces/middleware/server_status.rs +++ b/src/interfaces/middleware/server_status.rs @@ -45,34 +45,37 @@ pub const SERVER_STATUS_HEADER: &str = "x-server-status"; /// Compact JSON shape written into the header. Fields are documented /// in `common::migration_progress::MigrationProgress`. /// -/// Kept internal so the wire format can evolve. Frontend treats the -/// header as opaque JSON and pattern-matches on the fields it -/// currently understands. -#[derive(serde::Serialize)] -struct HeaderPayload { - readonly: bool, +/// Public because `GET /api/config` returns the same shape as the +/// initial hydration snapshot for FE stores — the endpoint mirrors +/// whatever the header carries so the client has a single wire +/// vocabulary to render. Frontend treats the value as opaque JSON +/// and pattern-matches on the fields it currently understands; +/// adding a field is additive. +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct HeaderPayload { + pub readonly: bool, #[serde(skip_serializing_if = "Option::is_none")] - migration: Option, + pub migration: Option, /// K3: independent of `readonly` — rotation does NOT engage the /// app-wide read-only flag, so the frontend needs a distinct /// signal to know "rotation is running, show the rotation /// banner instead of migration banner". #[serde(skip_serializing_if = "Option::is_none")] - rotation: Option, + pub rotation: Option, } /// Shared progress shape used by both `migration` and `rotation` /// header fields — same struct name, same JSON field names. Frontend /// treats them identically at the render layer. -#[derive(serde::Serialize)] -struct ProgressHeader { +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct ProgressHeader { // `target` is owned here — the RwLock guard is released before // serialisation, so a borrowed slice wouldn't survive. Names // are small (`[a-z0-9_-]{1,32}`) so the copy is trivial. - target: String, - migrated: u64, - total: u64, - percent: u8, + pub target: String, + pub migrated: u64, + pub total: u64, + pub percent: u8, } impl ProgressHeader { @@ -86,6 +89,42 @@ impl ProgressHeader { } } +/// Build the same [`HeaderPayload`] the middleware stamps into the +/// `X-Server-Status` header, without touching a response. Used by +/// `GET /api/config` so the client sees the exact shape the header +/// would carry at that moment — no drift, no dual serialisers. +/// +/// Cost model matches the middleware: +/// - Hot path (nothing active) returns `readonly: false` with no +/// allocations for the progress sub-objects. +/// - Cold path allocates the progress rows exactly once each. +pub fn build_header_payload(state: &AppState) -> HeaderPayload { + let readonly = state.migration_readonly.load(Ordering::Relaxed); + + let migration = if readonly { + state + .migration_progress + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .map(ProgressHeader::from_snapshot) + } else { + None + }; + let rotation = state + .rotation_progress + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .map(ProgressHeader::from_snapshot); + + HeaderPayload { + readonly, + migration, + rotation, + } +} + pub async fn server_status_middleware( State(state): State>, request: Request, diff --git a/src/main.rs b/src/main.rs index c10105c9..d5f4b041 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1042,22 +1042,6 @@ async fn run() -> Result<(), Box> { ) // Public API routes (share access, i18n) — no auth required .nest("/api", public_api_routes.layer(access_log!("http::api"))) - // Message-bus WebSocket. Registered OUTSIDE `protected_api` - // because a browser cannot attach a `DPoP:` header to - // `new WebSocket()` (RFC 6455 only lets us set - // `Sec-WebSocket-Protocol`), so the standard auth + DPoP - // stack would 401 every DPoP-bound session. The handler - // self-authenticates from either a ticket subprotocol - // (minted by `POST /api/rt/ticket` under the full chain) - // or a bearer token (`rt-hurl-helper` test path). - // See `handlers/rt_ws.rs` module doc and - // `docs/plan/message-bus.md § F`. - .route( - "/api/rt/ws", - axum::routing::get(oxicloud::interfaces::api::handlers::rt_ws::rt_ws_handler) - .with_state(app_state.clone()) - .layer(access_log!("http::api")), - ) // All other API routes are protected by auth middleware .nest("/api", protected_api.layer(access_log!("http::api"))) // RFC 6764 well-known discovery (public, no auth — just redirects) @@ -1072,6 +1056,37 @@ async fn run() -> Result<(), Box> { // the static surface is split into its own router. .merge(web_routes.layer(access_log!("http::web"))); + // Message-bus WebSocket. Registered OUTSIDE `protected_api` + // because a browser cannot attach a `DPoP:` header to + // `new WebSocket()` (RFC 6455 only lets us set + // `Sec-WebSocket-Protocol`), so the standard auth + DPoP + // stack would 401 every DPoP-bound session. The handler + // self-authenticates from either a ticket subprotocol + // (minted by `POST /api/rt/ticket` under the full chain) + // or a bearer token (`rt-hurl-helper` test path). + // See `handlers/rt_ws.rs` module doc and + // `docs/plan/message-bus.md § F`. + // + // Guarded by `enable_message_bus`: when false, the route is + // NOT registered → Axum returns 404 for `/api/rt/ws` and the + // ticket endpoint (already gated inside `create_api_routes`). + // Clients discover this via `/api/config` and skip WS setup. + if app_state.core.config.features.enable_message_bus { + app = app.route( + "/api/rt/ws", + axum::routing::get(oxicloud::interfaces::api::handlers::rt_ws::rt_ws_handler) + .with_state(app_state.clone()) + .layer(access_log!("http::api")), + ); + } else { + tracing::info!( + target: "audit", + event = "config.feature_disabled", + feature = "message_bus", + "message bus disabled — /api/rt/ws and /api/rt/ticket not registered (404)", + ); + } + // Mount Nextcloud routes (uses its own Basic Auth middleware). // **Merged BEFORE the trace + request-id layers** so NC requests // get the same `request_id` / `user_id` / `client_ip` span diff --git a/tests/api/rt_bus_check.sh b/tests/api/rt_bus_check.sh index 58ca2d55..a3a5880d 100755 --- a/tests/api/rt_bus_check.sh +++ b/tests/api/rt_bus_check.sh @@ -297,7 +297,7 @@ log "S4 OK" # ── Scenario 5 — Server-initiated keepalive ───────────────────────────────── # Verifies the WS handler sends RFC 6455 Ping control frames on the -# `OXICLOUD_RT_WS_KEEPALIVE_SECONDS` cadence (1 s in tests/common/server.env). +# `OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS` cadence (1 s in tests/common/server.env). # Two invariants: # (a) idling on a live subscription surfaces multiple Ping frames — the # keepalive interval genuinely fires, not just at connect and never again. diff --git a/tests/common/server.env b/tests/common/server.env index 3adc50ac..b2bf16f7 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -40,7 +40,7 @@ OXICLOUD_NEXTCLOUD_ENABLED=true # in `tests/api/rt_bus_check.sh` can observe multiple keepalive frames # arriving within a bounded (few-seconds) wall-clock. Only observed # by `rt_ws_handler`, which reads it at each WS connect time. -OXICLOUD_RT_WS_KEEPALIVE_SECONDS=1 +OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS=1 # Multi-entry storage config — see docs/plan/storage-multi-entry.md. # `local_main` is FIRST so the boot fallback picks it when no active From 8d1fde2747f677d702ee6833f5e305249f810a34 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 11 Sep 2026 13:02:33 +0200 Subject: [PATCH 17/21] feat(config + admin panel): handle features activated - review admin dashboard to reflect features enabled/disabled - hide mount option if feature is disabled - remove QUOTA option as it is not wired --- docs/config/deployment.md | 2 +- docs/config/env.md | 2 +- example.env | 62 +++++--- frontend/src/lib/api/endpoints/admin.ts | 8 +- frontend/src/lib/api/types.ts | 5 +- frontend/src/lib/components/AppShell.svelte | 136 ++++++++++-------- .../src/lib/stores/serverConfig.svelte.ts | 1 - .../src/routes/admin/[[tab]]/+page.svelte | 96 ++++++++++--- .../src/routes/admin/[[tab]]/page.test.ts | 3 +- src/application/dtos/settings_dto.rs | 10 +- src/common/config.rs | 11 +- src/common/di.rs | 10 ++ src/interfaces/api/handlers/admin_handler.rs | 54 +++++-- src/interfaces/api/handlers/config_handler.rs | 8 +- src/interfaces/api/handlers/rt_ws.rs | 27 ++++ src/interfaces/api/routes.rs | 2 +- 16 files changed, 303 insertions(+), 134 deletions(-) diff --git a/docs/config/deployment.md b/docs/config/deployment.md index ab287376..990136aa 100644 --- a/docs/config/deployment.md +++ b/docs/config/deployment.md @@ -104,7 +104,7 @@ kubectl logs statefulset/oxicloud -n oxicloud | grep "WOPI discovery loaded" | Trash | Yes | No | `OXICLOUD_ENABLE_TRASH` | | Search | Yes | No | `OXICLOUD_ENABLE_SEARCH` | | Favorites | Yes | Yes | Always on | -| Storage quotas | Yes | Yes | `OXICLOUD_ENABLE_USER_STORAGE_QUOTAS` | +| Storage quotas | Yes | Yes | Per-user via admin panel (no master switch) | | WebDAV | Yes | Optional | Always on | | CalDAV / CardDAV | Yes | Yes | Always on | | Deduplication | No | No | Always on | diff --git a/docs/config/env.md b/docs/config/env.md index 9e7df501..e7703a49 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -109,7 +109,6 @@ rather than as a visible error. | Variable | Default | Description | |---|---|---| | `OXICLOUD_ENABLE_AUTH` | `true` | Enable authentication | -| `OXICLOUD_ENABLE_USER_STORAGE_QUOTAS` | `false` | Per-user storage quotas | | `OXICLOUD_ENABLE_FILE_SHARING` | `true` | File/folder sharing | | `OXICLOUD_ENABLE_TRASH` | `true` | Trash / recycle bin | | `OXICLOUD_ENABLE_SEARCH` | `true` | Full-text and metadata search | @@ -117,6 +116,7 @@ rather than as a visible error. | `OXICLOUD_ENABLE_VIDEO_THUMBNAILS` | `true` | Server-side single-frame thumbnail extraction from uploaded videos (one frame → WebP). Requires `ffmpeg` on `PATH` (override with `OXICLOUD_FFMPEG_PATH`). When true and ffmpeg is missing at boot, a WARN log is emitted and videos fall back to a placeholder icon. Set to `false` to skip the ffmpeg lookup entirely — useful on hosts where ffmpeg can't be installed, or when the client uploads video previews itself (some desktop/mobile clients generate thumbnails locally and POST them alongside the video). | | `OXICLOUD_FFMPEG_PATH` | `ffmpeg` (on PATH) | Absolute path to the ffmpeg binary. Ignored when `OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false`. Useful for pinning a specific static build or when ffmpeg lives outside the default PATH. | | `OXICLOUD_EXPOSE_SYSTEM_USERS` | `true` | Expose other OxiCloud users as a read-only address book at `GET /api/address-books` | +| `OXICLOUD_ENABLE_EXTERNAL_MOUNTS` | `false` | External file mounts — surface host-filesystem paths (or a future S3/WebDAV/SFTP backend) as folders inside a user's drive. Admins configure mount rows via the "External Mounts" admin panel; each row picks a target drive, mount-root name, provider kind, and provider config. **When `false`**: admin CRUD (`/api/admin/external-mounts`) is not registered (404); the "External Mounts" tab is hidden from the admin sidebar (FE gates on `serverConfig.features.external_mounts`, same discovery path as `message_bus`); `MountRegistry` stays empty at boot even if mount rows exist in the DB, so mount-root folders resolve as empty native folders. **When `true`**: CRUD exposed, existing DB rows load at boot via `MountRegistry::reload`, users can browse mount contents. **Opt-in per deployment** because external mounts expose host filesystems (or credentialed remote backends) inside user drives — an admin misconfiguration can leak state that isn't part of OxiCloud's normal storage substrate. Keep off unless you have a concrete need. | | `OXICLOUD_GRANT_CLEANUP_ENABLED` | `true` | Background daemon that deletes expired rows from `storage.role_grants`. The authorization engine already filters expired grants out of every permission check at read time (`expires_at IS NULL OR expires_at > NOW()`), so leaving expired rows in place is a hygiene issue — not a security one. This daemon garbage-collects them daily. Set to `false` to keep every expired grant row forever (uncommon; a fresh install rarely wants this). | | `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` | `15` | Days past a grant's `expires_at` before the row is eligible for deletion. The grace window preserves the audit / support answer to "what happened to my access?" for a couple of weeks past expiration. Values below 1 are legal but discouraged — the recommendation is **≥ 15 days**. Values above the actual grant TTL used by clients waste index space; a few weeks is the sweet spot. | | `OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS` | `24` | How often the grant-cleanup daemon fires. Clamped to a minimum of 1 hour. Adjusting this doesn't change what gets deleted — only how promptly. Daily is fine for any realistic grant volume. | diff --git a/example.env b/example.env index bffef96d..f8373e6a 100644 --- a/example.env +++ b/example.env @@ -387,9 +387,6 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud # Enable/disable authentication system (default: true) #OXICLOUD_ENABLE_AUTH=true -# Enable per-user storage quotas (default: false) -#OXICLOUD_ENABLE_USER_STORAGE_QUOTAS=false - # Enable file/folder sharing (default: true) #OXICLOUD_ENABLE_FILE_SHARING=true @@ -464,6 +461,37 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud # Set to false to prevent users from browsing the user directory. #OXICLOUD_EXPOSE_SYSTEM_USERS=true +# External file mounts — surface host-filesystem paths (or a future +# S3/WebDAV/SFTP backend) as folders inside a user's drive. Admins +# configure mount rows via /api/admin/external-mounts (the "External +# Mounts" tab in the admin panel); each row picks a target drive, a +# name for the mount root, a provider kind, and a provider-specific +# config (e.g. host path for local). +# +# When `false` (default): +# - Admin CRUD routes (/api/admin/external-mounts) are NOT +# registered — Axum returns 404. The admin panel's "External +# Mounts" tab is hidden from the sidebar too (FE gates on +# `serverConfig.features.external_mounts` — same discovery path +# as `message_bus`). +# - At boot, `MountRegistry` stays empty even if mount rows exist +# in the DB — the runtime resolver has nothing to route to. +# Users navigating into a mount-root folder see an empty native +# folder (row exists, no children). +# +# When `true`: +# - Admin panel exposes the CRUD; existing DB rows load at boot +# via `MountRegistry::reload`; users can browse mount contents. +# +# Opt-in per deployment because external mounts expose host +# filesystems (or credentialed remote backends) inside user drives — +# an admin misconfiguration can leak state that isn't part of +# OxiCloud's normal storage substrate. Keep off unless you have a +# concrete need. +# +# Default: false. +#OXICLOUD_ENABLE_EXTERNAL_MOUNTS=false + # ── People (face recognition) ──────────────────────────────────────────── # Biometric data (GDPR Art. 9) — OFF by default, opt-in per deployment. # Detects faces and clusters them into people in the photo library. @@ -1100,20 +1128,6 @@ OXICLOUD_WOPI_ENABLED=false # collaborative editor. See docs/plan/message-bus.md for the JSON-RPC 2.0 # wire protocol. -# Server-initiated protocol Ping interval (seconds). Prevents intermediate -# proxies (Traefik, nginx, Cloudflare) and NAT boxes from reaping the TCP -# session as idle. Read at each WS connect — a change takes effect on new -# connections without restart. Set 0 (or any non-positive value) to fall -# back to the default. -# -# Tuning: the interval should sit at most half the smallest hop's idle -# timeout, so a single missed Ping doesn't kill the connection. Common -# floors: -# * nginx `proxy_read_timeout` default 60s → ping ≤ 30s -# * Cloudflare hard limit 100s → ping ≤ 45s -# * Traefik with idleTimeout bumped to 3600s → 30s is safely under -#OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS=30 - # Message bus master switch. When `false`, /api/rt/ws and # POST /api/rt/ticket are NOT registered at boot — Axum returns 404 # for both, keeping monitoring dashboards free of 5xx noise. Clients @@ -1135,6 +1149,20 @@ OXICLOUD_WOPI_ENABLED=false # Default: true. #OXICLOUD_MESSAGEBUS_ENABLE=true +# Server-initiated protocol Ping interval (seconds). Prevents intermediate +# proxies (Traefik, nginx, Cloudflare) and NAT boxes from reaping the TCP +# session as idle. Read at each WS connect — a change takes effect on new +# connections without restart. Set 0 (or any non-positive value) to fall +# back to the default. +# +# Tuning: the interval should sit at most half the smallest hop's idle +# timeout, so a single missed Ping doesn't kill the connection. Common +# floors: +# * nginx `proxy_read_timeout` default 60s → ping ≤ 30s +# * Cloudflare hard limit 100s → ping ≤ 45s +# * Traefik with idleTimeout bumped to 3600s → 30s is safely under +#OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS=30 + # ----------------------------------------------------------------------------- # MEMORY ALLOCATOR TUNING (IMPORTANT FOR RAM USAGE) # ----------------------------------------------------------------------------- diff --git a/frontend/src/lib/api/endpoints/admin.ts b/frontend/src/lib/api/endpoints/admin.ts index 9171616f..0c5c4807 100644 --- a/frontend/src/lib/api/endpoints/admin.ts +++ b/frontend/src/lib/api/endpoints/admin.ts @@ -416,11 +416,15 @@ export interface AdminDashboard { * `online_sessions / online_users` is the multi-device factor * (browser + desktop + phone). */ online_sessions: number; + /** Currently-connected message-bus WebSocket sessions — one per + * open browser tab that reached a folder view. Reported as `0` + * when `OXICLOUD_MESSAGEBUS_ENABLE=false` (no live sessions + * possible); the dashboard hides the card in that case since + * the value would be misleading. */ + active_ws_sessions: number; server_version: string; drive_usage: DriveKindUsage[]; - auth_enabled: boolean; oidc_configured: boolean; - quotas_enabled: boolean; registration_enabled?: boolean; users_over_80_percent: number; users_over_quota: number; diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 9432e3df..0f928a27 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -893,7 +893,10 @@ export interface ServerFeatures { trash: boolean; search: boolean; sharing: boolean; - quotas: boolean; + // NOTE: `quotas` was intentionally NOT exposed — see the Rust + // `FeaturesDto` doc for why (dormant server flag with zero + // consumers). Add it back once it actually gates FE-visible + // behavior. music: boolean; places: boolean; faces: boolean; diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index 74f21ad1..2e18ab21 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -17,6 +17,7 @@ import { dateTimeFormatFor, iconNameFromClass } from '$lib/utils/display'; import { userInitials, avatarColorIndex } from '$lib/utils/avatar'; import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte'; + import { serverConfig } from '$lib/stores/serverConfig.svelte'; import { serverStatus } from '$lib/stores/serverStatus.svelte'; import { apiFetch } from '$lib/api/client'; import { dialogs } from '$lib/stores/dialogs.svelte'; @@ -74,68 +75,81 @@ // strip; that was displaced here so the section navigation // scales past ~7 items and matches deep-link URLs from the // address bar. - const ADMIN_LINKS: NavLink[] = [ - { - href: '/admin', - label: t('admin.dashboard', 'Dashboard'), - icon: 'chart-pie', - section: 'admin-dashboard' - }, - { - href: '/admin/users', - label: t('admin.users', 'Users'), - icon: 'users', - section: 'admin-users' - }, - { - href: '/admin/sessions', - label: t('admin.sessions', 'Sessions'), - icon: 'key', - section: 'admin-sessions' - }, - { - href: '/admin/drives', - label: t('admin.drives', 'Drives'), - icon: 'hdd', - section: 'admin-drives' - }, - { - href: '/admin/mounts', - label: t('admin.mounts', 'External Mounts'), - icon: 'folder', - section: 'admin-mounts' - }, - { - href: '/admin/oidc', - label: t('admin.oidc', 'OIDC / SSO'), - icon: 'building-shield', - section: 'admin-oidc' - }, - { - href: '/admin/storage', - label: t('admin.storage_tab', 'Storage'), - icon: 'database', - section: 'admin-storage' - }, - { - href: '/admin/smtp', - label: t('admin.smtp', 'Email (SMTP)'), - icon: 'envelope', - section: 'admin-smtp' - }, - { - href: '/admin/plugins', - label: t('admin.plugins', 'Plugins'), - icon: 'layer-group', - section: 'admin-plugins' - }, - { - href: '/admin/jobs', - label: t('admin.jobs.tab', 'Background tasks'), - icon: 'cogs', - section: 'admin-jobs' + // `$derived` so feature-flag gating drops entries when a feature is + // disabled server-side. Server-side the admin CRUD routes are also + // gated (matching the message-bus pattern) — hiding the link here + // keeps the sidebar consistent with what the backend actually + // serves; a stale link would land on a 404. See + // `$lib/stores/serverConfig.svelte.ts`. + const ADMIN_LINKS = $derived.by(() => { + const links: NavLink[] = [ + { + href: '/admin', + label: t('admin.dashboard', 'Dashboard'), + icon: 'chart-pie', + section: 'admin-dashboard' + }, + { + href: '/admin/users', + label: t('admin.users', 'Users'), + icon: 'users', + section: 'admin-users' + }, + { + href: '/admin/sessions', + label: t('admin.sessions', 'Sessions'), + icon: 'key', + section: 'admin-sessions' + }, + { + href: '/admin/drives', + label: t('admin.drives', 'Drives'), + icon: 'hdd', + section: 'admin-drives' + } + ]; + if (serverConfig.features.external_mounts) { + links.push({ + href: '/admin/mounts', + label: t('admin.mounts', 'External Mounts'), + icon: 'folder', + section: 'admin-mounts' + }); } - ]; + links.push( + { + href: '/admin/oidc', + label: t('admin.oidc', 'OIDC / SSO'), + icon: 'building-shield', + section: 'admin-oidc' + }, + { + href: '/admin/storage', + label: t('admin.storage_tab', 'Storage'), + icon: 'database', + section: 'admin-storage' + }, + { + href: '/admin/smtp', + label: t('admin.smtp', 'Email (SMTP)'), + icon: 'envelope', + section: 'admin-smtp' + }, + { + href: '/admin/plugins', + label: t('admin.plugins', 'Plugins'), + icon: 'layer-group', + section: 'admin-plugins' + }, + { + href: '/admin/jobs', + label: t('admin.jobs.tab', 'Background tasks'), + icon: 'cogs', + section: 'admin-jobs' + } + ); + return links; + }); const isAdmin = $derived(session.user?.role === 'admin'); diff --git a/frontend/src/lib/stores/serverConfig.svelte.ts b/frontend/src/lib/stores/serverConfig.svelte.ts index 9c71791a..29362bc2 100644 --- a/frontend/src/lib/stores/serverConfig.svelte.ts +++ b/frontend/src/lib/stores/serverConfig.svelte.ts @@ -36,7 +36,6 @@ const DEFAULT_FEATURES: ServerFeatures = { trash: true, search: true, sharing: true, - quotas: false, music: true, places: true, faces: false, diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index e461888c..445d14fb 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -83,6 +83,7 @@ } from '$lib/api/types'; import { shortUserAgent } from '$lib/utils/userAgent'; import { triggerJob } from '$lib/api/endpoints/adminJobs'; + import { serverConfig } from '$lib/stores/serverConfig.svelte'; import { serverStatus } from '$lib/stores/serverStatus.svelte'; import AdminJobsPanel from '$lib/components/AdminJobsPanel.svelte'; import Icon from '$lib/icons/Icon.svelte'; @@ -224,6 +225,44 @@ // `$effect` loop is even possible. const tab = $derived(parseTab(page.params.tab)); + /** + * Feature-flag matrix for the dashboard "System" section. + * Data-driven from `serverConfig.features` (populated at boot from + * `GET /api/config`). Each entry becomes one card; adding a + * feature server-side flows through this list automatically — + * label lookup falls back to the raw key so a missing translation + * won't hide the card. + * + * Uses `unknown` bracket-key reads (rather than a rigid mapping + * over hard-coded keys) so the FE doesn't need a code change when + * the backend adds a new feature flag. The i18n key namespace + * `admin.features.` keeps translations discoverable. + */ + interface FeatureRow { + key: string; + label: string; + enabled: boolean; + } + const FEATURE_LABELS: Record = { + message_bus: 'Message bus', + trash: 'Trash', + search: 'Search', + sharing: 'Sharing', + music: 'Music', + places: 'Places (photo map)', + faces: 'People (faces)', + video_thumbnails: 'Video thumbnails', + external_mounts: 'External mounts' + }; + const featureRows = $derived.by(() => { + const raw = serverConfig.features as unknown as Record; + return Object.entries(raw).map(([key, enabled]) => ({ + key, + label: t(`admin.features.${key}`, FEATURE_LABELS[key] ?? key), + enabled + })); + }); + /** * Human-readable label for the current section — feeds the * page title (`Admin › Jobs · OxiCloud`) and the h1. Kept in @@ -1905,33 +1944,56 @@ {t('admin.online_sessions', 'Online sessions')} + + {#if serverConfig.features.message_bus} +
+ + + {dashboard.active_ws_sessions} + + {t('admin.active_ws_sessions', 'Live WS sessions')} +
+ {/if} - +

{t('admin.section_system', 'System')}

-
- - {dashboard.auth_enabled - ? t('admin.enabled', 'Enabled') - : t('admin.disabled', 'Disabled')} - - {t('admin.auth', 'Authentication')} -
{dashboard.oidc_configured ? t('admin.active', 'Active') : t('admin.off', 'Off')} {t('admin.oidc', 'OIDC / SSO')}
-
- - {dashboard.quotas_enabled - ? t('admin.enabled', 'Enabled') - : t('admin.disabled', 'Disabled')} - - {t('admin.quotas', 'Quotas')} -
+ {#each featureRows as row (row.key)} +
+ + {row.enabled ? t('admin.enabled', 'Enabled') : t('admin.disabled', 'Disabled')} + + {row.label} +
+ {/each} +
v{dashboard.server_version}{t('admin.version', 'Version')}
diff --git a/frontend/src/routes/admin/[[tab]]/page.test.ts b/frontend/src/routes/admin/[[tab]]/page.test.ts index 64ff709a..6b79d042 100644 --- a/frontend/src/routes/admin/[[tab]]/page.test.ts +++ b/frontend/src/routes/admin/[[tab]]/page.test.ts @@ -89,9 +89,8 @@ const dashboard = { total_used_bytes: 100, total_quota_bytes: 1000, storage_usage_percent: 10, - auth_enabled: true, oidc_configured: false, - quotas_enabled: true, + active_ws_sessions: 0, registration_enabled: true, users_over_80_percent: 0, users_over_quota: 0 diff --git a/src/application/dtos/settings_dto.rs b/src/application/dtos/settings_dto.rs index 7917d519..cd881ff4 100644 --- a/src/application/dtos/settings_dto.rs +++ b/src/application/dtos/settings_dto.rs @@ -161,9 +161,15 @@ pub struct DriveKindUsageDto { pub struct DashboardStatsDto { // System info pub server_version: String, - pub auth_enabled: bool, pub oidc_configured: bool, - pub quotas_enabled: bool, + /// Currently-connected message-bus WebSocket sessions. One per + /// browser tab that reached a folder view and hasn't closed the + /// tab yet. Zero when `OXICLOUD_MESSAGEBUS_ENABLE=false`. + /// Snapshot value — a subsequent request can see a different + /// number if a connection opened/closed in between. Renders on + /// the admin dashboard's "Live activity" section next to + /// `online_sessions` (HTTP-driven distinct-user count). + pub active_ws_sessions: u64, // ── User accounts (static breakdown of auth.users) ── // All four are counts of the SAME table under different // predicates. `active`, `admin`, `external` are all subsets of diff --git a/src/common/config.rs b/src/common/config.rs index 6d5c9105..c6b88781 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -2243,7 +2243,6 @@ impl MagicLinkConfig { #[derive(Debug, Clone)] pub struct FeaturesConfig { pub enable_auth: bool, - pub enable_user_storage_quotas: bool, pub enable_file_sharing: bool, pub enable_trash: bool, pub enable_search: bool, @@ -2483,8 +2482,7 @@ impl Default for GrantCleanupConfig { impl Default for FeaturesConfig { fn default() -> Self { Self { - enable_auth: true, // Enable authentication by default - enable_user_storage_quotas: false, + enable_auth: true, // Enable authentication by default enable_file_sharing: true, // Enable file sharing by default enable_trash: true, // Enable trash feature enable_search: true, // Enable search feature @@ -3353,13 +3351,6 @@ impl AppConfig { config.features.enable_auth = val; } - if let Ok(enable_user_storage_quotas) = - env::var("OXICLOUD_ENABLE_USER_STORAGE_QUOTAS").map(|v| v.parse::()) - && let Ok(val) = enable_user_storage_quotas - { - config.features.enable_user_storage_quotas = val; - } - if let Ok(enable_file_sharing) = env::var("OXICLOUD_ENABLE_FILE_SHARING").map(|v| v.parse::()) && let Ok(val) = enable_file_sharing diff --git a/src/common/di.rs b/src/common/di.rs index 11a24558..fec2e0ad 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -2333,6 +2333,7 @@ impl AppServiceFactory { mount_router, bus, rt_ticket_store, + active_ws_sessions: Arc::new(std::sync::atomic::AtomicUsize::new(0)), auth_service: auth_services, opaque_service, opaque_repo, @@ -3250,6 +3251,15 @@ pub struct AppState { pub rt_ticket_store: Arc< crate::infrastructure::services::rt_ticket_store::RtTicketStore, >, + /// Live count of currently-connected message-bus WS sessions. + /// Incremented on entry to `rt_ws::handle_session`, decremented + /// via a `Drop` guard on ANY exit (normal close, error, panic + /// unwind). Surfaced on the admin dashboard's "Live activity" + /// section so operators can gauge WS pressure at a glance — one + /// connection per open browser tab that reaches a folder view. + /// Zero-cost when idle: `Relaxed` atomic load/store on the fd + /// path, no allocation. + pub active_ws_sessions: Arc, pub auth_service: Option, /// OPAQUE aPAKE substrate (RFC 9807). Populated only when /// [`OpaqueConfig::effective_mode`] is not `Off` — that method diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index 20b03f45..f516f291 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -53,18 +53,38 @@ struct AdminUsersPageResponse { } /// Admin API routes — all require admin role. -pub fn admin_routes() -> Router> { +/// +/// Takes an `AppState` reference so feature-flag gating at route- +/// registration time is possible (external-mounts admin surface +/// mirrors the `OXICLOUD_ENABLE_EXTERNAL_MOUNTS` flag; when the flag +/// is off the runtime `MountRegistry` isn't loaded, so exposing the +/// CRUD would let admins configure mounts that silently don't work). +pub fn admin_routes(app_state: &Arc) -> Router> { use super::admin_external_mounts as ext_mounts; - Router::new() - // External file mounts - .route( - "/external-mounts", - get(ext_mounts::list_external_mounts).post(ext_mounts::create_external_mount), - ) - .route( - "/external-mounts/{id}", - delete(ext_mounts::delete_external_mount), - ) + let mut router = Router::new(); + + // External file mounts — CRUD registered only when the feature + // is enabled server-side. Matches the pattern used for the + // message bus (`/api/rt/ws` unmounted when + // `OXICLOUD_MESSAGEBUS_ENABLE=false`): a disabled feature stays + // fully hidden from the admin panel too. Without this guard the + // admin panel would load, editor would save DB rows, but the + // runtime `MountRegistry` (gated by the same flag in + // `common/di.rs`) wouldn't load them — a silently-broken UX. + // FE mirrors via `serverConfig.features.external_mounts`. + if app_state.core.config.features.enable_external_mounts { + router = router + .route( + "/external-mounts", + get(ext_mounts::list_external_mounts).post(ext_mounts::create_external_mount), + ) + .route( + "/external-mounts/{id}", + delete(ext_mounts::delete_external_mount), + ); + } + + router = router // OIDC settings .route("/settings/oidc", get(get_oidc_settings)) .route("/settings/oidc", put(save_oidc_settings)) @@ -207,7 +227,9 @@ pub fn admin_routes() -> Router> { .route( "/drives/{id}/members/{kind}/{sid}", axum::routing::patch(update_drive_member_admin).delete(remove_drive_member_admin), - ) + ); + + router } // Every route under `/api/admin/*` is gated by the @@ -1054,9 +1076,13 @@ pub async fn get_dashboard_stats( let stats = DashboardStatsDto { server_version: env!("CARGO_PKG_VERSION").to_string(), - auth_enabled: true, oidc_configured: auth_app.oidc_enabled(), - quotas_enabled: true, // Feature flag could be checked here + // Snapshot the current live-WS-session count. `Relaxed` because + // the counter itself uses `Relaxed`; slight staleness on the + // dashboard is fine — it's a UI gauge, not a control input. + active_ws_sessions: state + .active_ws_sessions + .load(std::sync::atomic::Ordering::Relaxed) as u64, total_users: stats_row.get("total_users"), active_users: stats_row.get("active_users"), admin_users: stats_row.get("admin_users"), diff --git a/src/interfaces/api/handlers/config_handler.rs b/src/interfaces/api/handlers/config_handler.rs index 56eafc6b..798f600e 100644 --- a/src/interfaces/api/handlers/config_handler.rs +++ b/src/interfaces/api/handlers/config_handler.rs @@ -82,9 +82,10 @@ pub struct FeaturesDto { /// File sharing (public share links + user-to-user grants). See /// `FeaturesConfig::enable_file_sharing`. pub sharing: bool, - /// Per-user storage-quota enforcement on the upload path. See - /// `FeaturesConfig::enable_user_storage_quotas`. - pub quotas: bool, + // NOTE: no `quotas` field. The former `enable_user_storage_quotas` + // flag was removed (dead config with zero consumers). Actual + // per-user quotas are set via the admin panel and resolved by + // `StorageUsageService` unconditionally. /// Music player + playlists. See `FeaturesConfig::enable_music`. pub music: bool, /// Photo-map ("Places") tab. See `FeaturesConfig::enable_places`. @@ -122,7 +123,6 @@ pub async fn get_config(State(state): State>) -> Json), } +/// RAII guard that decrements the live-session counter on ANY exit +/// path from `handle_session` — clean close, protocol error, panic +/// unwind, tokio task cancellation. Keeping the decrement in `Drop` +/// (not scattered inline before every `break;` / `return;`) means we +/// physically cannot leak a live count when a new exit branch is +/// added. `Arc` so it stays valid even if the task is aborted from +/// outside. +struct SessionCountGuard(Arc); + +impl Drop for SessionCountGuard { + fn drop(&mut self) { + self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + } +} + async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc) { + // Live-session counter — incremented here, decremented on ANY + // exit path via the `Drop` guard below (clean close, error, + // panic unwind, task abort). Feeds the admin dashboard's + // "Live activity" section. `Relaxed` because the counter is + // approximate-by-design — a slightly stale read on the + // dashboard is fine, and the atomic hop stays sub-nanosecond + // on the hot path (session open / close). + state + .active_ws_sessions + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let _session_count_guard = SessionCountGuard(Arc::clone(&state.active_ws_sessions)); + // Outbound queue — every path that produces a client-bound frame // enqueues here; the writer half of the select drains. Also // carries internal `EvictFolders` control signals from the diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index fc092204..84a4c488 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -662,7 +662,7 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { // gate automatically — implementors no longer have to remember // to call `require_admin(&state, &headers).await?` inline, and a // forgotten call can't silently expose a non-admin surface. - let admin_router = admin_handler::admin_routes() + let admin_router = admin_handler::admin_routes(app_state) .layer(axum::middleware::from_fn( crate::interfaces::middleware::auth::require_admin, )) From 84ea005b65badd60c342e858e0e77cdfd97eb512 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 11 Sep 2026 14:10:29 +0200 Subject: [PATCH 18/21] feat(msg-bus): free WS if tab is not active since 1min --- frontend/src/lib/message-bus/client.svelte.ts | 146 +++++++++++++++++- 1 file changed, 144 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/message-bus/client.svelte.ts b/frontend/src/lib/message-bus/client.svelte.ts index d3343487..c35f4f9d 100644 --- a/frontend/src/lib/message-bus/client.svelte.ts +++ b/frontend/src/lib/message-bus/client.svelte.ts @@ -110,6 +110,27 @@ const RECONNECT_MAX_MS = 30_000; * genuine permanent failure before it becomes noise. */ const MAX_CONSECUTIVE_FAILURES = 20; +/** How long a tab must stay hidden before the client proactively + * closes its WebSocket. Balances two costs: + * + * - Aggressive close (0 grace) churns on every alt-tab: users + * switch tabs dozens of times a day for quick lookups; a full + * ticket exchange + reconnect on every switch is wasteful. + * - No close leaves the WS holding an fd, a broadcast receiver + * slot, and the session's outbound `mpsc::Sender` server-side + * for as long as the tab is open — even if the user hasn't + * looked at it in hours. + * + * 60 s comfortably absorbs "alt-tab, check something, come back" + * and starts saving real state on tabs left in the background for + * real work. On return we run the same `onReconnect` handlers the + * server-restart path uses — no new code needed for state resync. + * + * The Page Visibility API (`document.visibilityState`) fires the + * same event whether the user switched tabs, minimised the window, + * or the screen locked. All three want the same treatment. */ +const HIDDEN_GRACE_MS = 60_000; + interface SubEntry { count: number; handlers: Set; @@ -155,6 +176,15 @@ export class MessageBusClient { * reactive. Same rationale as `#subs` / `#pending`. */ // eslint-disable-next-line svelte/prefer-svelte-reactivity #reconnectHandlers = new Set(); + /** setTimeout handle for the "close on hidden after grace" timer. + * `null` when the tab is visible OR the timer already fired. See + * `HIDDEN_GRACE_MS` for the design tradeoff. */ + #hiddenTimer: ReturnType | null = null; + /** Bound `visibilitychange` listener kept so `close()` can + * detach it. Not attached in SSR (`typeof document === + * "undefined"`); the client is lazy so this is just belt-and- + * braces against a caller doing something unusual. */ + #onVisibilityChange: (() => void) | null = null; /** `topic` → `{count, handlers, revokedHandlers, acked}`. Refcount * drives the wire: first refcount ⇒ send `rt.subscribe`; last drop @@ -185,6 +215,16 @@ export class MessageBusClient { }; this.#url = opts?.url ?? (typeof window !== 'undefined' ? defaultUrl() : ''); this.#WebSocketCtor = opts?.WebSocketCtor ?? WebSocket; + + // Wire the Page Visibility hook — closes the WS after + // `HIDDEN_GRACE_MS` when the tab goes hidden, reconnects on + // return. See the constant's doc for the tradeoff. Guarded by + // `typeof document !== 'undefined'` so SSR / non-browser + // harnesses (Vitest with a stubbed WebSocket) don't crash. + if (typeof document !== 'undefined') { + this.#onVisibilityChange = () => this.#handleVisibilityChange(); + document.addEventListener('visibilitychange', this.#onVisibilityChange); + } } /** @@ -293,6 +333,14 @@ export class MessageBusClient { clearTimeout(this.#reconnectTimer); this.#reconnectTimer = null; } + if (this.#hiddenTimer !== null) { + clearTimeout(this.#hiddenTimer); + this.#hiddenTimer = null; + } + if (this.#onVisibilityChange && typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', this.#onVisibilityChange); + this.#onVisibilityChange = null; + } if (this.#ws) { this.#ws.close(); this.#ws = null; @@ -302,6 +350,86 @@ export class MessageBusClient { this.#pending.clear(); } + // ─────────────────────── page visibility ───────────────────────── + + /** `visibilitychange` handler. Two transitions: + * + * - visible → hidden: start the grace timer (or reset it, if + * the timer was already running from a previous hide → visible + * → hide flip that didn't fire yet — clearing first is safe). + * - hidden → visible: cancel the timer if it hasn't fired; if + * the WS was already closed AND we still hold subscriptions, + * trigger a reconnect so the `onReconnect` handlers refetch + * and the state catches up. + * + * Wrapped in `untrack` because this method reads `this.state` + * (a `$state`); the caller is a DOM event listener, but + * defensively we don't want a future refactor that puts this + * behind an `$effect` to inherit a dep on `state`. Same + * pattern applied to every other class-method state read — + * see the `subscribe()` docstring for the general rule. */ + #handleVisibilityChange(): void { + untrack(() => { + if (typeof document === 'undefined') return; + if (document.visibilityState === 'hidden') { + if (this.#hiddenTimer !== null) clearTimeout(this.#hiddenTimer); + this.#hiddenTimer = setTimeout(() => this.#closeForHidden(), HIDDEN_GRACE_MS); + busLog.debug('tab hidden — WS close scheduled', { graceMs: HIDDEN_GRACE_MS }); + } else { + if (this.#hiddenTimer !== null) { + clearTimeout(this.#hiddenTimer); + this.#hiddenTimer = null; + busLog.debug('tab visible again — hidden-close cancelled (WS still open)'); + } + // If the WS was closed by the previous grace-timer fire, + // pop back up. `reconnect()` zeroes the circuit breaker + // and schedules an immediate attempt; `#onOpen` will + // then fire every registered `onReconnect` handler and + // consumers refetch to catch up on missed events. Skip + // if there are no live subscribers — no point opening + // a connection nobody's listening on. + if (this.state === 'disconnected' && this.#subs.size > 0) { + busLog.debug('tab visible again — reconnecting after grace close'); + this.reconnect(); + } + } + }); + } + + /** Grace timer fired — the tab has been hidden for `HIDDEN_GRACE_MS`. + * Close the WS, preserving the local `#subs` map so a return to + * visible can re-subscribe every topic through the normal + * `#onOpen` replay path. Nothing to do if we're already + * disconnected (server-restart flow, etc.). */ + #closeForHidden(): void { + untrack(() => { + this.#hiddenTimer = null; + if (this.state === 'idle' || this.state === 'disconnected') return; + busLog.warn('closing WS — tab hidden past grace window', { + subsPreserved: this.#subs.size + }); + if (this.#ws) { + this.#ws.close(); + this.#ws = null; + } + // Cancel any in-flight reconnect timer — the tab is asleep, + // no point scheduling more attempts until it's visible again. + if (this.#reconnectTimer !== null) { + clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = null; + } + this.state = 'disconnected'; + // Reject pending calls with a synthetic "hidden" error so + // callers don't hang. Matches the `#onClose` shape. + const closed: MessageBusError = { + code: RtErrorCode.INTERNAL_ERROR, + message: 'ws_closed_tab_hidden' + }; + for (const pending of this.#pending.values()) pending.reject(closed); + this.#pending.clear(); + }); + } + // ─────────────────────── connection lifecycle ──────────────────── #connect(): void { @@ -499,8 +627,22 @@ export class MessageBusClient { const closed: MessageBusError = { code: RtErrorCode.INTERNAL_ERROR, message: 'ws_closed' }; for (const pending of this.#pending.values()) pending.reject(closed); this.#pending.clear(); - // Only reconnect if we still have subscribers waiting. - if (this.#subs.size > 0) this.#scheduleReconnect(); + // Reconnect only when we still have subscribers AND the tab is + // currently visible. When hidden, `#closeForHidden` closes the + // WS on purpose to save resources — auto-reconnecting here + // would defeat the whole grace-close mechanism. The + // `visibilitychange` handler's hidden→visible transition takes + // care of the recovery via `this.reconnect()`. + if (this.#subs.size > 0 && !this.#tabIsHidden()) { + this.#scheduleReconnect(); + } + } + + /** Small helper — `true` if the Page Visibility API says the tab + * is hidden right now. Guards non-browser harnesses (SSR, + * Vitest without jsdom overrides) that lack `document`. */ + #tabIsHidden(): boolean { + return typeof document !== 'undefined' && document.visibilityState === 'hidden'; } #scheduleReconnect(overrideMs?: number): void { From a6138aa4d9c87441c484830a756969bd478a00ad Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 11 Sep 2026 19:41:18 +0200 Subject: [PATCH 19/21] feat(msg-bus): wire jobs follow up --- docs/plan/message-bus.md | 225 +++++++++++++----- .../src/lib/components/AdminJobsPanel.svelte | 64 +++++ .../src/lib/composables/useJobTopic.svelte.ts | 76 ++++++ .../generated/message-bus/JobRunEndedData.ts | 9 + .../message-bus/JobRunProgressData.ts | 9 + .../message-bus/JobRunStartedData.ts | 8 + .../lib/generated/message-bus/RtEventKind.ts | 5 +- .../generated/message-bus/RtEventParams.ts | 8 +- src/application/ports/message_bus_ports.rs | 136 ++++++++++- src/bin/generate-asyncapi.rs | 68 ++++++ src/common/di.rs | 11 + src/infrastructure/scheduler/engine.rs | 72 +++++- src/infrastructure/scheduler/registry.rs | 40 +++- .../services/in_process_message_bus.rs | 10 +- src/interfaces/api/handlers/rt_ws.rs | 67 +++++- tests/api/rt_bus_check.sh | 40 +++- 16 files changed, 766 insertions(+), 82 deletions(-) create mode 100644 frontend/src/lib/composables/useJobTopic.svelte.ts create mode 100644 frontend/src/lib/generated/message-bus/JobRunEndedData.ts create mode 100644 frontend/src/lib/generated/message-bus/JobRunProgressData.ts create mode 100644 frontend/src/lib/generated/message-bus/JobRunStartedData.ts diff --git a/docs/plan/message-bus.md b/docs/plan/message-bus.md index 04a361da..ded7f55f 100644 --- a/docs/plan/message-bus.md +++ b/docs/plan/message-bus.md @@ -75,18 +75,41 @@ smoke suite plus manual multi-user E2E. Live today: - **Ticket tested** — S10 (happy path), S11 (single-use replay rejected). -Deferred and still open — see the Roadmap section and the +Active — still under Phase A, ordered by priority: + +- **Job dashboard live** (next) — `JobRegistry` publishes step + progress + terminal state on `job:{id}`; the admin jobs view + subscribes and drops its polling. Small; same shape as folder-live. + Value: an operator who triggers a long-running job (backend + migration, thumbnail import, etc.) can navigate to another admin + page and come back without losing progress visibility. +- **Notifications table + bell** (E) — topic + producer + auto-sub + land here. Same pattern as `:authz`. Larger; unblocks Phase-B + `@mentions`. + +Deferred — see the Roadmap section's `## Deferred` block and the `project_message_bus_reconnect_gap` memory: -- **Notifications table + bell** (E) — topic + producer + auto-sub - land here. Same pattern as `:authz`. -- **Presence** (Phase B) — `folder:{id}:presence` topic + awareness - frames. +- **Workspace UX** (was Phase B) — presence, comments, reactions, + `@mentions`, `NotificationService` as bus subscriber. +- **Infrastructure payoff** (was Phase C) — sync-client push + invalidation, album live, slideshow sync. - **Yjs collab** — `docs/plan/markdown-collab.md`, depends on the binary-frame routing this plan sketches but doesn't ship. - **Broker replicator** (Postgres LISTEN/NOTIFY or Redis) — for multi-instance and durable event log. `BusReplicator` port declared, `NoopReplicator` wired today. +- **Session-resume tokens** — `rt.subscribe { since: N }` + a + server-side per-topic ring buffer with sequence numbers. + Replaces "full refetch on reconnect" with delta replay. Pairs + with the collab editor slice (Yjs) where refetch cost is high. +- **SharedWorker for multi-tab dedup** — one WS per user per + browser profile, shared across every same-origin tab. Turns "5 + tabs open" into 1 WS instead of 5. Ship when the "Live WS + sessions" admin card sits persistently at N × user count. +- **Web Push for offline delivery** — pairs with Slice E + (notifications bell). Delivers to closed browsers via FCM / + Mozilla autopush / Apple Push through a service worker. ## Non-goals @@ -1219,9 +1242,13 @@ Ships the infrastructure and the two most visible consumers together. `useReconnect` composable → folder view refetches after WS comes back. Bridges the in-memory-bus "events lost during outage" gap (see `project_message_bus_reconnect_gap` memory). -- **Job dashboard live** — TODO. `JobRegistry` publishes step - progress and terminal state; FE job dashboard subscribes and - replaces polling. +- **Job dashboard live** — TODO (next slice). `JobRegistry` + publishes step progress and terminal state on `job:{id}`; FE + job dashboard subscribes and replaces polling. Operator value: + once a long-running job is triggered (backend migration, thumb + import, blobs consistency…), the admin can navigate to another + page and come back without losing progress visibility — the WS + push keeps whatever component is subscribed up-to-date. - **Notifications table + bell** — TODO (Slice E). New `notifications` table + `NotificationService` port; initial ingesters for `share-granted`, `new-login-from-new-device`, @@ -1238,83 +1265,159 @@ Deliverables sized ~4 weeks end-to-end. Slice D (folder-live) and Slice F (ticket flow) landed 2026-09-11. Slices E + collab are the open work in Phase A. -### Phase B — Presence + comments +### Deferred — everything below is on the shelf -Everything that turns OxiCloud from a file store into a shared -workspace. +None of these ship on a fixed date; each is triggered by a concrete +consumer need. Grouped by theme (workspace UX, infrastructure +payoff, replicator) so the reader still sees the connective tissue, +but there is no commitment to sequencing. + +#### Workspace UX (formerly "Phase B") + +Turns OxiCloud from a file store into a shared workspace. Ship when +a specific feature here graduates from "would be nice" to "the +product needs it". - **Presence topics** — `folder:{id}:presence`, `file:{id}:presence`. Awareness-style: joined/left/cursor. Ephemeral, not persisted. -- **FE presence UI**: "N people viewing" badge in folder header; +- **FE presence UI** — "N people viewing" badge in folder header; avatar rail; hover to highlight; "someone is previewing this photo right now" in the lightbox. - **Comments on any file** — new `comments` table (threaded, per file, supports reactions), `CommentService` port, `file:{id}:comments` topic for live delivery. -- **@mentions**: mention autocomplete in the comment editor; +- **@mentions** — mention autocomplete in the comment editor; mention → notification into the mentioned user's `user:{u}:notifications` topic + `notifications` row + optional email (reuses existing `MagicLinkMailer`-style templating). -- **Reactions**: 👍❤️🎉 on comments and on files themselves; live - fan-out on the same `file:{id}:comments` topic. -- **Comment resolutions**: Google-Docs-style thread markers. -- **NotificationService consumes bus events** — up to Phase A the - bus's publish calls sit inline in each mutation site - (`FolderService::create_folder_with_perms`, - `FileUploadService::upload_file_streaming`, and — once folder-live - rounds out — the delete / rename / move sites for both files and - folders). That is the right shape and stays: the bus is - location-keyed (`Topic::Folder(id)`, subscriber-scoped) and - belongs at the mutation site. - When Phase B ships, notifications sit on the **same axis** (also - location + actor + subscriber-driven) — not the FileLifecycleHook - axis (which is server-internal, content-keyed, fan-out-to-all). - So `NotificationService` becomes an in-process subscriber to the - bus itself: it registers a `bus.subscribe(...)` on the topics it - cares about (`folder:{id}`, `file:{id}`, share-grant events), - translates relevant events into `notif.notifications` rows, and - re-publishes on `user:{u}:notifications`. No new dispatcher, no - new hook trait, no changes to existing mutation sites — the bus IS - the mutation-event pipeline for anything subscriber-driven. - Contrast with `FileLifecycleHook` (`src/application/ports/file_lifecycle.rs`): - that stays focused on content transitions (blob_hash, content_type) - and fires unconditionally to server-side workers (thumbnails, - audio metadata, plugins). Bus and lifecycle-hook are complementary - — same triggering moment, orthogonal fan-out shape and payload - discipline. Do NOT try to unify them; the two axes are genuinely - different (all-vs-subscribed × content-vs-location). +- **Reactions** — 👍❤️🎉 on comments and files. Live fan-out on the + same `file:{id}:comments` topic. +- **Comment resolutions** — Google-Docs-style thread markers. +- **`NotificationService` as a bus subscriber** (architectural + pivot). Up to Phase A the bus's publish calls sit inline in each + mutation site (`FolderService::create_folder_with_perms`, + `FileUploadService::upload_file_streaming`, and the delete / + rename / move sites for both files and folders). That is the + right shape and stays: the bus is location-keyed + (`Topic::Folder(id)`, subscriber-scoped) and belongs at the + mutation site. When notifications ship, they sit on the **same + axis** (also location + actor + subscriber-driven) — not the + `FileLifecycleHook` axis (which is server-internal, content-keyed, + fan-out-to-all). So `NotificationService` becomes an in-process + subscriber to the bus itself: it registers a `bus.subscribe(...)` + on the topics it cares about (`folder:{id}`, `file:{id}`, + share-grant events), translates relevant events into + `notif.notifications` rows, and re-publishes on + `user:{u}:notifications`. No new dispatcher, no new hook trait, + no changes to existing mutation sites — the bus IS the + mutation-event pipeline for anything subscriber-driven. Contrast + with `FileLifecycleHook` (`src/application/ports/file_lifecycle.rs`): + that stays focused on content transitions (blob_hash, + content_type) and fires unconditionally to server-side workers + (thumbnails, audio metadata, plugins). Bus and lifecycle-hook are + complementary — same triggering moment, orthogonal fan-out shape + and payload discipline. Do NOT try to unify them; the two axes + are genuinely different (all-vs-subscribed × content-vs-location). -Deliverables sized ~3 weeks after Phase A. +#### Infrastructure payoff (formerly "Phase C") -### Phase C — Sync client push + album live +Where the bus starts paying for itself on operator cost. Ship when +sync-client PROPFIND traffic or the album-viewing experience +becomes a real bottleneck. -Where the bus starts paying for itself on infrastructure cost too. - -- **Sync-client push invalidation**: WebDAV / NextCloud DAV handlers - publish `file:{id}` and `folder:{id}` deltas after commit. Sync - clients get a lightweight `Sync-Invalidate` mechanism (or a - dedicated WS endpoint for headless clients) so they refetch only - changed paths instead of polling PROPFIND. Cuts a large chunk of - Nextcloud-style client chatter. -- **Album live updates**: `folder:{album_id}` reused — as photos are - added to an album, everyone viewing sees them appear. -- **Slideshow sync**: one presenter picks "Present"; other viewers of - the album can opt-in to follow the presenter's current frame. +- **Sync-client push invalidation** — WebDAV / NextCloud DAV + handlers publish `file:{id}` and `folder:{id}` deltas after + commit. Sync clients get a lightweight `Sync-Invalidate` + mechanism (or a dedicated WS endpoint for headless clients) so + they refetch only changed paths instead of polling PROPFIND. + Cuts a large chunk of Nextcloud-style client chatter. +- **Album live updates** — `folder:{album_id}` reused; as photos + are added to an album, everyone viewing sees them appear. +- **Slideshow sync** — one presenter picks "Present"; other viewers + of the album can opt-in to follow the presenter's current frame. Uses `folder:{album_id}` with a `presenter_frame` event kind. -Deliverables sized ~2–3 weeks after Phase B. +#### Multi-instance & broker -### Later — multi-instance & broker - -Only invoked when the deployment actually needs it. Nothing above -depends on these landing on any fixed date. +Only invoked when the deployment actually needs it. Also the +mitigation for the "events lost during outage window" gap (see +`project_message_bus_reconnect_gap` memory) if durable replay +becomes important for collab or sync-push. - **`PgListenReplicator`** — ship when we run more than one server - instance. Same port, no consumer changes. + instance. Same `BusReplicator` port, no consumer changes. - **`BrokerReplicator`** for RabbitMQ or NATS — ship when either cross-datacenter fan-out or a shared broker with other services matters. Same port, no consumer changes. +#### Session-resume tokens (wire-protocol extension) + +Replaces today's "full refetch on reconnect" workaround with a +delta-replay protocol: the client remembers the sequence number of +the last event it processed per topic; on reconnect, it says +"resume from N" and the server replays every event since N. The +canonical shape across the industry — Discord's `OP 6 Resume`, +Slack's sync API, Firestore's `resume_token`, Notion's sync-token +pattern. Cheaper than a REST refetch for high-fan-out topics (Yjs +CRDT deltas, notification streams) where the "catch-up" would +otherwise pull megabytes of state the client mostly already has. + +Requires: + +- **Server-side**: per-topic bounded ring buffer with monotonic + sequence numbers. Bounded because we're not building a durable + log — a hold-back of the last N events per topic is enough for + the common "closed laptop for 10 min" case. A resume request + older than the retention window falls through to a client-side + full refetch (same code path today's `onReconnect` uses), so + the client never fails hard — just degrades. +- **Wire**: `rt.subscribe` gains an optional `since: number` param + and the ack carries the current sequence number. `rt.event` + gains a `seq` field the client stores as `last_seq[topic]`. +- **Client**: `MessageBusClient` persists `last_seq[topic]` and + replays it on `#onOpen`'s subscribe-replay. `onReconnect` + handlers keep their fallback-to-refetch role for the + older-than-retention case. + +Meaningful for the collab editor slice (Yjs) and for future +sync-client push. Not worth doing before either of those lands — +folder-view refetch is a folder-page fetch (small); Yjs +"refetch" would be the whole doc snapshot (potentially large). +See `project_message_bus_reconnect_gap` memory (option 2). + +#### Client-side connection efficiency + +Optimizations to how the SPA holds its WebSocket. Independent of +server changes; ship when the per-user connection count actually +becomes a load concern. Today's grace-period tab-hidden close +(closes the WS after 60 s hidden, reconnects on visibility return) +covers the low-hanging fruit; both items below layer on top. + +- **SharedWorker for multi-tab dedup** — one WebSocket per user per + browser profile, shared across every same-origin tab via a + `SharedWorker`. All tabs `postMessage` through the worker + instead of holding independent `WebSocket` instances. Slack / + Gmail / Google Docs all do this. Turns "user has 5 folder tabs + open" from 5 WS into 1. Refactor cost: `MessageBusClient` moves + behind the worker boundary; every `useTopic` call becomes an + RPC to the worker instead of a direct method call. Payoff + scales with per-user tab count — worth doing if operators see + the "Live WS sessions" admin card sitting persistently at 5× + the user count. Not worth it otherwise; grace-close already + handles the common "background tab" case at ~30% of this + refactor's complexity. +- **Web Push for truly-offline delivery** — service-worker-backed + push notifications delivered by the browser vendor (FCM for + Chrome, Mozilla autopush for Firefox, Apple Push for Safari) + even when the user has no OxiCloud tab open. Complements the + WS-based notification stream: WS delivers to foreground tabs; + Web Push delivers to closed browsers. Requires server-side + push-subscription store, per-vendor endpoint delivery (usually + `web-push` crate), and a service worker in the FE. Meaningful + UX win alongside Slice E (notifications bell) — a share + arriving while the user is away actually reaches them. + Deferred until Slice E ships; the two form a natural pair. + ## What this bus does NOT replace - Message queue / job queue — jobs stay in `job_registry`; bus just diff --git a/frontend/src/lib/components/AdminJobsPanel.svelte b/frontend/src/lib/components/AdminJobsPanel.svelte index d30f1367..f14ac53c 100644 --- a/frontend/src/lib/components/AdminJobsPanel.svelte +++ b/frontend/src/lib/components/AdminJobsPanel.svelte @@ -35,6 +35,8 @@ purgeJobRuns } from '$lib/api/endpoints/adminJobs'; import type { Finding, JobParam, JobSummary, RunSummary, RunStatus } from '$lib/api/types'; + import { messageBus } from '$lib/message-bus/client.svelte'; + import { serverConfig } from '$lib/stores/serverConfig.svelte'; // ─── State ──────────────────────────────────────────────────────── @@ -222,6 +224,68 @@ return () => stopPolling(); }); + // ─── Live updates via the message bus ───────────────────────────── + // + // Subscribes to `job:{name}` for every registered job so a run's + // start/end flips this panel's state within a network hop instead + // of waiting up to POLL_MS for the next poll tick. The 5s polling + // stays as fallback — messages that arrive while the tab was hidden + // (Page Visibility grace-close in `messageBus`) are lost, and + // polling reconciles. + // + // Progress publishes aren't wired yet (deferred — see + // `docs/plan/message-bus.md`), so the handler treats + // `job_run_progress` as a benign no-op and simply refetches the + // row's runs when a run ends. When per-handler progress emits + // land, this composable is where `onProgress` will map into the + // runs table without a poll round-trip. + // + // Keyed on the SORTED name set — the polling refresh reassigns + // `jobs` on every tick with a fresh array, which would tear down + // and rebuild every sub if the effect keyed on `jobs` identity. + // The registered set is fixed at server boot, so this stable key + // stops the effect churning. + const jobNameKey = $derived( + jobs + ?.map((j) => j.name) + .sort() + .join('|') ?? '' + ); + + $effect(() => { + // Don't attempt to open a socket if the server has the bus + // disabled — the WS route is unmounted (404) and the circuit + // breaker would just count failures. + if (!serverConfig.features.message_bus) return; + if (!jobNameKey) return; + const names = jobNameKey.split('|').filter(Boolean); + const releases = names.map((name) => + messageBus.subscribe( + `job:${name}`, + (params) => { + // `job_run_progress` currently has no publisher — + // treat any incoming variant defensively. + if (params.event === 'job_run_started') { + void loadJobs(); + if (expandedJob === name) void loadRuns(name); + } else if (params.event === 'job_run_ended') { + void loadJobs(); + if (expandedJob === name) void loadRuns(name); + } + }, + () => { + // Server-side eviction — admin role revoked or bus + // disabled mid-session. Nothing surgical to do; the + // next poll will reflect whatever changed and the + // operator's UI will render normally. + } + ) + ); + return () => { + for (const release of releases) release(); + }; + }); + async function loadRuns(name: string) { runsLoadingByJob = { ...runsLoadingByJob, [name]: true }; runsErrorByJob = { ...runsErrorByJob, [name]: '' }; diff --git a/frontend/src/lib/composables/useJobTopic.svelte.ts b/frontend/src/lib/composables/useJobTopic.svelte.ts new file mode 100644 index 00000000..5b65a691 --- /dev/null +++ b/frontend/src/lib/composables/useJobTopic.svelte.ts @@ -0,0 +1,76 @@ +// Admin-job-dashboard sugar around `useTopic`. +// +// Subscribes to `job:{name}` and dispatches the three `rt.event` +// variants — `job_run_started`, `job_run_progress`, `job_run_ended` +// — to per-verb handlers. Admin-only server-side (Class 3, see +// `application/ports/message_bus_ports.rs::required_perm`); a +// non-admin caller sees `topic_forbidden` on subscribe and the +// subscription is dropped. +// +// This composable mirrors `useFolderTopic` but is deliberately +// separate: the two share nothing beyond `useTopic`, and merging +// them would smear two AuthZ classes (ResourceRead vs RoleAdmin) +// into one call surface. + +import { useTopic } from './useTopic.svelte'; +import type RtEventParams from '$lib/generated/message-bus/RtEventParams'; +import type RtRevokedParams from '$lib/generated/message-bus/RtRevokedParams'; +import type JobRunStartedData from '$lib/generated/message-bus/JobRunStartedData'; +import type JobRunProgressData from '$lib/generated/message-bus/JobRunProgressData'; +import type JobRunEndedData from '$lib/generated/message-bus/JobRunEndedData'; + +/** + * Optional per-verb handlers for a single job's run stream. Any + * subset is accepted; unhandled verbs fall through silently. + * + * `onEnded` is the canonical "the server is done publishing on this + * topic for now" signal — the admin dashboard uses it to switch a + * row back to "idle" and stop expecting progress updates. The + * subscription itself stays open (jobs can run again), so callers + * that want a one-shot pattern should track that in their own state. + */ +export interface JobTopicHandlers { + onStarted?: (data: JobRunStartedData) => void; + onProgress?: (data: JobRunProgressData) => void; + onEnded?: (data: JobRunEndedData) => void; + /** Server evicted the subscription — admin role revoked, or + * the message bus itself was disabled mid-session. */ + onRevoked?: (params: RtRevokedParams) => void; +} + +/** + * Subscribe to `job:{name}` and dispatch each `rt.event` + * notification to the matching per-verb handler. + * + * `name` accepts the same shapes as `useTopic`'s `topic` — a plain + * string, a nullable string (null = don't subscribe yet), or a + * getter that reads from reactive state so the subscription follows + * the currently-selected job. + */ +export function useJobTopic( + name: string | null | (() => string | null), + handlers: JobTopicHandlers +): void { + const topic = () => { + const n = typeof name === 'function' ? name() : name; + return n ? `job:${n}` : null; + }; + useTopic(topic, (params) => dispatch(params, handlers), handlers.onRevoked); +} + +function dispatch(params: RtEventParams, handlers: JobTopicHandlers): void { + // The generated `RtEventKind` string-enum values match the Rust + // `#[serde(rename_all = "snake_case")]` variants exactly — see + // `application/ports/message_bus_ports.rs::MessageBusEvent`. + switch (params.event) { + case 'job_run_started': + handlers.onStarted?.(params.data as JobRunStartedData); + return; + case 'job_run_progress': + handlers.onProgress?.(params.data as JobRunProgressData); + return; + case 'job_run_ended': + handlers.onEnded?.(params.data as JobRunEndedData); + return; + } +} diff --git a/frontend/src/lib/generated/message-bus/JobRunEndedData.ts b/frontend/src/lib/generated/message-bus/JobRunEndedData.ts new file mode 100644 index 00000000..1eef442b --- /dev/null +++ b/frontend/src/lib/generated/message-bus/JobRunEndedData.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface JobRunEndedData { + ended_at: string; + name: string; + reason?: string | null; + success: boolean; +} +export type { JobRunEndedData as default }; diff --git a/frontend/src/lib/generated/message-bus/JobRunProgressData.ts b/frontend/src/lib/generated/message-bus/JobRunProgressData.ts new file mode 100644 index 00000000..fc237968 --- /dev/null +++ b/frontend/src/lib/generated/message-bus/JobRunProgressData.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface JobRunProgressData { + message?: string | null; + name: string; + step?: number | null; + total?: number | null; +} +export type { JobRunProgressData as default }; diff --git a/frontend/src/lib/generated/message-bus/JobRunStartedData.ts b/frontend/src/lib/generated/message-bus/JobRunStartedData.ts new file mode 100644 index 00000000..c2c13851 --- /dev/null +++ b/frontend/src/lib/generated/message-bus/JobRunStartedData.ts @@ -0,0 +1,8 @@ +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface JobRunStartedData { + actor: string; + name: string; + started_at: string; +} +export type { JobRunStartedData as default }; diff --git a/frontend/src/lib/generated/message-bus/RtEventKind.ts b/frontend/src/lib/generated/message-bus/RtEventKind.ts index 65aa0797..ff42f7f7 100644 --- a/frontend/src/lib/generated/message-bus/RtEventKind.ts +++ b/frontend/src/lib/generated/message-bus/RtEventKind.ts @@ -6,6 +6,9 @@ enum RtEventKind { FOLDER_CREATED = 'folder_created', FOLDER_RENAMED = 'folder_renamed', FOLDER_MOVED = 'folder_moved', - FOLDER_DELETED = 'folder_deleted' + FOLDER_DELETED = 'folder_deleted', + JOB_RUN_STARTED = 'job_run_started', + JOB_RUN_PROGRESS = 'job_run_progress', + JOB_RUN_ENDED = 'job_run_ended' } export type { RtEventKind as default }; diff --git a/frontend/src/lib/generated/message-bus/RtEventParams.ts b/frontend/src/lib/generated/message-bus/RtEventParams.ts index 576ca986..38602e61 100644 --- a/frontend/src/lib/generated/message-bus/RtEventParams.ts +++ b/frontend/src/lib/generated/message-bus/RtEventParams.ts @@ -6,6 +6,9 @@ import type FolderCreatedData from './FolderCreatedData'; import type FolderRenamedData from './FolderRenamedData'; import type FolderMovedData from './FolderMovedData'; import type FolderDeletedData from './FolderDeletedData'; +import type JobRunStartedData from './JobRunStartedData'; +import type JobRunProgressData from './JobRunProgressData'; +import type JobRunEndedData from './JobRunEndedData'; import type RtEventKind from './RtEventKind'; // AUTO-GENERATED — do not edit by hand. // Regenerate with `just asyncapi-ts`. @@ -18,7 +21,10 @@ interface RtEventParams { | FolderCreatedData | FolderRenamedData | FolderMovedData - | FolderDeletedData; + | FolderDeletedData + | JobRunStartedData + | JobRunProgressData + | JobRunEndedData; event: RtEventKind; topic: string; } diff --git a/src/application/ports/message_bus_ports.rs b/src/application/ports/message_bus_ports.rs index ecdfe6ff..a69bf99c 100644 --- a/src/application/ports/message_bus_ports.rs +++ b/src/application/ports/message_bus_ports.rs @@ -53,7 +53,7 @@ use crate::common::errors::DomainError; /// Encodes to a stable dotted wire key that maps naturally onto RabbitMQ /// topic-exchange routing keys or NATS subjects when the [`BusReplicator`] /// seam is filled in later. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +#[derive(Clone, PartialEq, Eq, Hash, Debug)] pub enum Topic { /// A folder's mutation stream — file/subfolder created/deleted/renamed/ /// moved in or out. Consumed by the folder view for live refresh. @@ -63,6 +63,17 @@ pub enum Topic { /// subscribe the caller and evict stale subs when its events fire once /// the eviction wiring lands (Phase-A follow-up). UserAuthz(Uuid), + + /// A named background job's run lifecycle — start / progress / + /// end. Consumed by the admin job dashboard so operators who + /// trigger a long-running job (backend migration, thumb import…) + /// can navigate to other admin pages without losing progress + /// visibility. AuthZ: **admin-only** (Class 3 role-scoped). + /// Non-admins get `topic_forbidden` — indistinguishable on the + /// wire from an unknown topic. Job names are stable + /// scheduler-registered strings (e.g. `backend_migration`, + /// `thumb_derived_import`); the topic string is `job:`. + Job(String), } impl Topic { @@ -72,6 +83,7 @@ impl Topic { match self { Topic::Folder(id) => format!("folder:{id}"), Topic::UserAuthz(id) => format!("user:{id}:authz"), + Topic::Job(name) => format!("job:{name}"), } } @@ -90,6 +102,22 @@ impl Topic { let id = Uuid::parse_str(id_str).map_err(|_| ParseTopicErr::BadUuid)?; return Ok(Topic::UserAuthz(id)); } + if let Some(name) = s.strip_prefix("job:") { + // Job names are scheduler-registered short slugs — see + // `infrastructure/scheduler/registry.rs`. Validate here + // only that the name is non-empty and consists of + // `[a-z0-9_-]` chars — reject anything else as + // `Unknown` (indistinguishable to the caller from a + // topic shape we've never heard of). + if !name.is_empty() + && name + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') + { + return Ok(Topic::Job(name.to_string())); + } + return Err(ParseTopicErr::Unknown); + } Err(ParseTopicErr::Unknown) } @@ -107,6 +135,7 @@ impl Topic { resource: BusResource::Folder(*id), }, Topic::UserAuthz(id) => AuthzCheck::IdentityMatch { user_id: *id }, + Topic::Job(_) => AuthzCheck::RoleAdmin, } } } @@ -152,8 +181,12 @@ pub enum AuthzCheck { /// Class 2 — Identity-scoped. `caller_id` must equal `user_id`. /// No admin bypass — privacy is a hard rule. IdentityMatch { user_id: Uuid }, - // Class 3 (role-scoped `admin:*`) and the bespoke `job:{id}` check - // land with their topic variants. + + /// Class 3 — Role-scoped. Caller must hold the admin role. Used + /// by `Topic::Job(_)` today; future `admin:*` topics land here. + /// Non-admin subscriber gets `topic_forbidden` on the wire — + /// same anti-enum shape as unknown-topic denial. + RoleAdmin, } // ════════════════════════════════════════════════════════════════════════════ @@ -257,6 +290,50 @@ pub enum MessageBusEvent { /// the payload extends with additional resource classes — see the /// plan's Phase-B roadmap. AuthzChanged { affected_folders: Vec }, + + /// A background job's run started. Published on + /// [`Topic::Job`]. `started_at` is server wall-clock (RFC 3339 + /// serialised by serde). Admin dashboard's job-list view uses + /// this to flip a row from "idle" to "running" without a + /// polling round-trip. + JobRunStarted { + name: String, + started_at: chrono::DateTime, + actor: Uuid, + }, + + /// A background job made progress. Published at most every + /// 3 seconds per job (throttled at the publish site — see + /// scheduler engine). `step` / `total` populate an operator- + /// facing progress bar; `message` is a one-line free-form + /// status. All three are optional because different jobs have + /// different progress semantics (some know the total up front, + /// some don't; some can render a step count, some just have a + /// running status message). + JobRunProgress { + name: String, + step: Option, + total: Option, + message: Option, + }, + + /// A background job's run ended. `success = true` for a normal + /// completion; `false` for failure / cancelled / paused with + /// unhandled outcome. `reason` populates the "click for + /// details" flow on the admin dashboard: the notification (Slice + /// E) will link to `/admin/jobs/` on the `false` branch, + /// where the full outcome and paused-run state live. + /// + /// Deliberately NOT a rich outcome enum — the admin panel is one + /// click away and holds the full detail; the bus event just + /// needs to say "done, ok or not". Adding a new outcome nuance + /// server-side does NOT churn the wire. + JobRunEnded { + name: String, + success: bool, + reason: Option, + ended_at: chrono::DateTime, + }, } // ════════════════════════════════════════════════════════════════════════════ @@ -420,6 +497,33 @@ mod tests { assert_eq!(Topic::parse(&wire).unwrap(), t); } + #[test] + fn job_topic_roundtrip() { + let t = Topic::Job("backend_migration".to_string()); + let wire = t.to_wire_key(); + assert_eq!(wire, "job:backend_migration"); + assert_eq!(Topic::parse(&wire).unwrap(), t); + } + + #[test] + fn job_topic_rejects_bad_name_chars() { + // Job names come from the scheduler registry — a stable + // `[a-z0-9_-]` alphabet. Anything else is `Unknown` (same + // wire response as an unrecognised topic shape). + assert_eq!(Topic::parse("job:"), Err(ParseTopicErr::Unknown)); + assert_eq!(Topic::parse("job:UPPER"), Err(ParseTopicErr::Unknown)); + assert_eq!(Topic::parse("job:with.dot"), Err(ParseTopicErr::Unknown)); + assert_eq!(Topic::parse("job:with space"), Err(ParseTopicErr::Unknown)); + } + + #[test] + fn required_perm_job_is_role_admin() { + assert_eq!( + Topic::Job("thumb_derived_import".to_string()).required_perm(), + AuthzCheck::RoleAdmin + ); + } + #[test] fn parse_rejects_bad_uuid() { assert_eq!( @@ -547,6 +651,32 @@ mod tests { }, "authz_changed", ), + ( + MessageBusEvent::JobRunStarted { + name: "backend_migration".into(), + started_at: chrono::DateTime::::from_timestamp(0, 0).unwrap(), + actor: Uuid::nil(), + }, + "job_run_started", + ), + ( + MessageBusEvent::JobRunProgress { + name: "backend_migration".into(), + step: Some(10), + total: Some(100), + message: Some("phase 2".into()), + }, + "job_run_progress", + ), + ( + MessageBusEvent::JobRunEnded { + name: "backend_migration".into(), + success: true, + reason: None, + ended_at: chrono::DateTime::::from_timestamp(0, 0).unwrap(), + }, + "job_run_ended", + ), ]; for (ev, expected) in cases { let json = serde_json::to_value(ev).unwrap(); diff --git a/src/bin/generate-asyncapi.rs b/src/bin/generate-asyncapi.rs index 3df67207..52bd67e8 100644 --- a/src/bin/generate-asyncapi.rs +++ b/src/bin/generate-asyncapi.rs @@ -140,6 +140,21 @@ fn channels() -> Value { "SubscribeRequest": { "$ref": "#/components/messages/RtSubscribeRequest" }, "UnsubscribeRequest": { "$ref": "#/components/messages/RtUnsubscribeRequest" }, } + }, + "Job": { + "address": "job:{jobName}", + "description": "A named background job's run lifecycle — Started / Progress / Ended. Consumed by the admin dashboard so operators who trigger a long-running job (backend migration, thumb import…) can navigate off the admin page and come back without losing progress. AuthZ: admin-only (Class 3 role-scoped) — non-admin gets `topic_forbidden`, indistinguishable on the wire from an unknown topic.", + "parameters": { + "jobName": { "description": "Scheduler-registered short slug (e.g. `backend_migration`); `[a-z0-9_-]` chars only" } + }, + "messages": { + "SubscribeRequest": { "$ref": "#/components/messages/RtSubscribeRequest" }, + "UnsubscribeRequest": { "$ref": "#/components/messages/RtUnsubscribeRequest" }, + "SubscribedResponse": { "$ref": "#/components/messages/RtSubscribedResponse" }, + "ErrorResponse": { "$ref": "#/components/messages/RtErrorResponse" }, + "JobEvent": { "$ref": "#/components/messages/RtFolderEventNotification" }, + "RevokedNotification": { "$ref": "#/components/messages/RtRevokedNotification" }, + } } }) } @@ -301,6 +316,9 @@ fn components() -> Value { "FolderRenamedData": folder_renamed_schema(), "FolderMovedData": folder_moved_schema(), "FolderDeletedData": folder_deleted_schema(), + "JobRunStartedData": job_run_started_schema(), + "JobRunProgressData": job_run_progress_schema(), + "JobRunEndedData": job_run_ended_schema(), }, // How the client authenticates. Handler side is `auth_middleware` // — the same middleware every `/api/*` request goes through, so @@ -580,6 +598,7 @@ fn event_kind_schema() -> Value { "enum": [ "file_created", "file_renamed", "file_moved", "file_deleted", "folder_created", "folder_renamed", "folder_moved", "folder_deleted", + "job_run_started", "job_run_progress", "job_run_ended", ], }) } @@ -596,6 +615,9 @@ fn event_data_union_schema() -> Value { ref_schema("FolderRenamedData"), ref_schema("FolderMovedData"), ref_schema("FolderDeletedData"), + ref_schema("JobRunStartedData"), + ref_schema("JobRunProgressData"), + ref_schema("JobRunEndedData"), ] }) } @@ -710,6 +732,52 @@ fn folder_deleted_schema() -> Value { }) } +// ─────────────────── Job event data payloads ───────────────────── +// Published on `Topic::Job(name)`. AuthZ is Class-3 (admin-only) — +// non-admins get `topic_forbidden` on subscribe, so these payloads +// only ever reach admin subscribers. See `handlers/rt_ws.rs`. + +fn job_run_started_schema() -> Value { + json!({ + "type": "object", + "description": "A background job's run started. `name` matches the scheduler-registered job name (e.g. `backend_migration`). `actor` is `00000000-0000-0000-0000-000000000000` today — the scheduler doesn't yet thread the trigger caller through.", + "required": ["name", "started_at", "actor"], + "properties": { + "name": { "type": "string" }, + "started_at": { "type": "string", "format": "date-time" }, + "actor": { "type": "string", "format": "uuid" }, + } + }) +} + +fn job_run_progress_schema() -> Value { + json!({ + "type": "object", + "description": "A background job made progress. Throttled at the publish site to at most one per 3 s per job (see scheduler engine). `step` / `total` populate a progress bar; all three fields are optional because different jobs report different granularities.", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "step": { "type": ["integer", "null"], "minimum": 0 }, + "total": { "type": ["integer", "null"], "minimum": 0 }, + "message": { "type": ["string", "null"] }, + } + }) +} + +fn job_run_ended_schema() -> Value { + json!({ + "type": "object", + "description": "A background job's run ended. `success = true` for a normal completion; `false` for failure / timeout / cancelled / paused-with-unhandled-outcome. `reason` populates the toast text on the `false` branch and links to `/admin/jobs/` for the full outcome. Consumer typically drops its subscription on receipt (job is done).", + "required": ["name", "success", "ended_at"], + "properties": { + "name": { "type": "string" }, + "success": { "type": "boolean" }, + "reason": { "type": ["string", "null"] }, + "ended_at": { "type": "string", "format": "date-time" }, + } + }) +} + /// `rt.revoked` notification body — server tells the client that a /// specific subscription has been evicted. `topic` is the wire-form /// string the client originally subscribed to. `reason` is the stable diff --git a/src/common/di.rs b/src/common/di.rs index fec2e0ad..0efe2a65 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -1759,6 +1759,17 @@ impl AppServiceFactory { Arc::new(crate::application::ports::message_bus_ports::NoopReplicator), ); + // Wire the bus into the JobRegistry so `dispatch` (both the + // periodic supervisor and the manual `trigger` paths) can + // publish `JobRunStarted` / `JobRunEnded` on `Topic::Job(name)`. + // Set here — after both the bus and the registry are + // constructed — via `OnceLock`. Silent no-op on subsequent + // calls; unit tests that build a registry without a bus just + // skip this. + let bus_for_jobs: Arc = + bus.clone(); + core.job_registry.set_message_bus(bus_for_jobs); + // WebSocket ticket store — see `rt_ticket_store` module doc for // why this exists (DPoP-bound sessions can't be re-proofed on // a browser-issued WS upgrade). Reaper task runs for the app diff --git a/src/infrastructure/scheduler/engine.rs b/src/infrastructure/scheduler/engine.rs index 88e18cf4..57206c40 100644 --- a/src/infrastructure/scheduler/engine.rs +++ b/src/infrastructure/scheduler/engine.rs @@ -94,8 +94,11 @@ async fn run(registry: Arc) { // Fire and forget from the supervisor's perspective — we // don't care about the outcome, `dispatch` records it on the // entry and emits the log line itself. Periodic ticks never - // force — that's an admin-trigger-only affordance. - let _ = dispatch(&name, entry, &JobRunArgs::default()).await; + // force — that's an admin-trigger-only affordance. Pass the + // bus reference so periodic runs also publish job events + // (same reasoning as the manual-trigger path). + let bus = registry.message_bus_snapshot(); + let _ = dispatch(&name, entry, &JobRunArgs::default(), bus).await; } } @@ -117,7 +120,12 @@ async fn run(registry: Arc) { /// `args` is passed through to `JobHandler::run`. The supervisor's /// periodic ticks pass `JobRunArgs::default()`; the admin trigger /// endpoint forwards parsed query params such as `?force=true`. -pub(super) async fn dispatch(name: &str, entry: Arc, args: &JobRunArgs) -> JobOutcome { +pub(super) async fn dispatch( + name: &str, + entry: Arc, + args: &JobRunArgs, + bus: Option>, +) -> JobOutcome { // Try to acquire the single-permit gate. `try_acquire` is // non-blocking — if held, we know the previous run is still // executing and skip this tick. @@ -161,6 +169,26 @@ pub(super) async fn dispatch(name: &str, entry: Arc, args: &JobRunArgs let started_wall = Utc::now(); let start_instant = Instant::now(); + // Publish `JobRunStarted` on `Topic::Job(name)` so the admin + // job dashboard's live tab receives a "started" tick without + // polling. Silent no-op when the bus isn't wired (test setup) + // or when nobody is subscribed. `actor` is `Uuid::nil()` today + // because the scheduler doesn't carry the trigger caller + // through — the periodic supervisor has no caller, and the + // admin trigger endpoints don't thread it in. When they do, + // swap to the real UUID. + if let Some(bus) = bus.as_ref() { + use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic}; + bus.publish( + &Topic::Job(name.to_string()), + MessageBusEvent::JobRunStarted { + name: name.to_string(), + started_at: started_wall, + actor: uuid::Uuid::nil(), + }, + ); + } + // Spawn so panics land as `JoinError::is_panic()` instead of // unwinding into the supervisor loop. Args cloned into the spawn // scope so the borrow doesn't outlive the caller. @@ -214,6 +242,33 @@ pub(super) async fn dispatch(name: &str, entry: Arc, args: &JobRunArgs // the diagnostic `cause` field. log_outcome(name, &outcome, cause, elapsed_ms); + // Publish `JobRunEnded` on `Topic::Job(name)`. This is the + // signal the FE watches for to terminate its subscription + // (`useJobTopic` unsubscribes on `onEnded`). `success = false` + // covers timeout, panic, handler error — the admin dashboard + // renders the row as failed and the "click for details" + // notification (Slice E) will link to `/admin/jobs/`. + // Silent no-op when the bus isn't wired. + if let Some(bus) = bus.as_ref() { + use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic}; + let success = outcome.is_ok(); + let reason = match &outcome { + crate::infrastructure::scheduler::types::JobOutcome::Err { message } => { + Some(message.clone()) + } + _ => None, + }; + bus.publish( + &Topic::Job(name.to_string()), + MessageBusEvent::JobRunEnded { + name: name.to_string(), + success, + reason, + ended_at: Utc::now(), + }, + ); + } + drop(permit); outcome } @@ -421,16 +476,15 @@ mod tests { // Kick off dispatch 1 in the background — it holds the permit // for ~200 ms. let entry_bg = entry.clone(); - let bg = - tokio::spawn( - async move { dispatch("overrun", entry_bg, &JobRunArgs::default()).await }, - ); + let bg = tokio::spawn(async move { + dispatch("overrun", entry_bg, &JobRunArgs::default(), None).await + }); // Give dispatch 1 time to grab the permit. tokio::time::sleep(Duration::from_millis(50)).await; // Dispatch 2 should observe the permit taken and skip. - dispatch("overrun", entry.clone(), &JobRunArgs::default()).await; + dispatch("overrun", entry.clone(), &JobRunArgs::default(), None).await; // Only dispatch 1's handler should have actually run so far. assert_eq!(calls.load(Ordering::SeqCst), 1); @@ -458,7 +512,7 @@ mod tests { .await; let entry = registry.get("slow").await.unwrap(); - dispatch("slow", entry.clone(), &JobRunArgs::default()).await; + dispatch("slow", entry.clone(), &JobRunArgs::default(), None).await; // The timeout fired; last_outcome must be Err. let state = entry.state.lock().unwrap(); diff --git a/src/infrastructure/scheduler/registry.rs b/src/infrastructure/scheduler/registry.rs index 7692d982..9573c831 100644 --- a/src/infrastructure/scheduler/registry.rs +++ b/src/infrastructure/scheduler/registry.rs @@ -60,12 +60,24 @@ pub(super) struct JobState { /// native services `register()` during DI wiring. pub struct JobRegistry { entries: RwLock>>, + /// Message bus — used by `dispatch` (via `trigger`) to publish + /// `JobRunStarted` / `JobRunProgress` / `JobRunEnded` on + /// `Topic::Job(name)` so the admin dashboard can render live + /// progress without polling. `OnceLock` because it's set exactly + /// once at DI time (after both the registry and the bus are + /// constructed) and read from many concurrent triggers; `Arc` + /// keeps consumers cheap. `None` before wiring (unit tests + /// exercise the registry without a bus). + message_bus: std::sync::OnceLock< + std::sync::Arc, + >, } impl JobRegistry { pub fn new() -> Self { Self { entries: RwLock::new(HashMap::new()), + message_bus: std::sync::OnceLock::new(), } } @@ -294,7 +306,33 @@ impl JobRegistry { /// that just want a plain run pass `JobRunArgs::default()`. pub async fn trigger(self: &Arc, name: &str, args: &JobRunArgs) -> Option { let entry = self.get(name).await?; - Some(super::engine::dispatch(name, entry, args).await) + // Pass the bus reference through to `dispatch` so start / end + // events publish on `Topic::Job(name)`. `Option::cloned()` + // returns a fresh `Arc` clone (or None) — negligible. + let bus = self.message_bus.get().cloned(); + Some(super::engine::dispatch(name, entry, args, bus).await) + } + + /// Wire the message bus. Called once from DI after both the + /// registry and the bus are constructed. Idempotent: a second + /// call is a silent no-op (`OnceLock::set` returns `Err`), so + /// test setups that call this more than once don't panic. + pub fn set_message_bus( + &self, + bus: std::sync::Arc, + ) { + let _ = self.message_bus.set(bus); + } + + /// Snapshot the currently-wired bus (if any). `None` when + /// `set_message_bus` hasn't been called yet — every test setup + /// that skips DI wiring, and the very early boot before the + /// bus is constructed. Called by the periodic supervisor and + /// by `trigger` so both paths publish job events identically. + pub(super) fn message_bus_snapshot( + &self, + ) -> Option> { + self.message_bus.get().cloned() } } diff --git a/src/infrastructure/services/in_process_message_bus.rs b/src/infrastructure/services/in_process_message_bus.rs index 324f3665..b97fb8fc 100644 --- a/src/infrastructure/services/in_process_message_bus.rs +++ b/src/infrastructure/services/in_process_message_bus.rs @@ -112,8 +112,13 @@ impl InProcessMessageBus { /// (for the receiver) — one code path for the map insert avoids a race /// where publish creates a sender concurrent subscribers miss. fn sender_for(&self, topic: &Topic) -> broadcast::Sender { + // `topic.clone()` because `Topic::Job(String)` isn't `Copy`. + // The clone is a String alloc on the cold path (first ever + // subscriber for a topic) and free on the hot path (existing + // entry — `entry` doesn't need to move the key when the + // entry is already present). self.topics - .entry(*topic) + .entry(topic.clone()) .or_insert_with(|| broadcast::channel(BROADCAST_RING_CAPACITY).0) .clone() } @@ -194,6 +199,9 @@ fn event_kind(event: &MessageBusEvent) -> &'static str { MessageBusEvent::FolderMoved { .. } => "folder_moved", MessageBusEvent::FolderDeleted { .. } => "folder_deleted", MessageBusEvent::AuthzChanged { .. } => "authz_changed", + MessageBusEvent::JobRunStarted { .. } => "job_run_started", + MessageBusEvent::JobRunProgress { .. } => "job_run_progress", + MessageBusEvent::JobRunEnded { .. } => "job_run_ended", } } diff --git a/src/interfaces/api/handlers/rt_ws.rs b/src/interfaces/api/handlers/rt_ws.rs index 7a8655da..8608b155 100644 --- a/src/interfaces/api/handlers/rt_ws.rs +++ b/src/interfaces/api/handlers/rt_ws.rs @@ -338,6 +338,48 @@ async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc { + match crate::interfaces::middleware::user::resolve_live_role( + auth.auth_application_service.as_ref(), + caller_id, + "user", + ) + .await + { + crate::interfaces::middleware::user::LiveRole::Active(role) => role.to_string(), + crate::interfaces::middleware::user::LiveRole::Revoked => { + // Account revoked between ticket-issue and now. + // Terminate the session immediately — dropping + // `socket` at end of scope closes the WS cleanly + // (no explicit `.close()` needed; that would + // require pulling `SinkExt` into scope for one + // line). + tracing::info!( + target: "audit", + event = "message_bus.session_rejected", + reason = "account_revoked", + caller_id = %caller_id, + "👮🏻‍♂️ WS session rejected — account revoked", + ); + drop(socket); + return; + } + } + } + None => "user".to_string(), + }; + // Outbound queue — every path that produces a client-bound frame // enqueues here; the writer half of the select drains. Also // carries internal `EvictFolders` control signals from the @@ -450,7 +492,7 @@ async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc { if let Some(reply) = - handle_text_frame(&txt, caller_id, &state, &mut subs, &out_tx).await + handle_text_frame(&txt, caller_id, &caller_role, &state, &mut subs, &out_tx).await && socket.send(Message::Text(reply.into())).await.is_err() { break; } @@ -487,6 +529,7 @@ async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc, subs: &mut HashMap, out_tx: &mpsc::Sender, @@ -516,9 +559,9 @@ async fn handle_text_frame( }; match method.as_str() { - "rt.subscribe" => { - Some(handle_subscribe(id, req.params, caller_id, state, subs, out_tx).await) - } + "rt.subscribe" => Some( + handle_subscribe(id, req.params, caller_id, caller_role, state, subs, out_tx).await, + ), "rt.unsubscribe" => Some(handle_unsubscribe(id, req.params, subs)), "rt.ping" => Some(success_response(id, serde_json::json!({ "pong": true }))), _ => Some(error_response( @@ -534,6 +577,7 @@ async fn handle_subscribe( id: Value, params: Value, caller_id: Uuid, + caller_role: &str, state: &Arc, subs: &mut HashMap, out_tx: &mpsc::Sender, @@ -622,6 +666,21 @@ async fn handle_subscribe( ); } } + AuthzCheck::RoleAdmin => { + // Class 3 — role-scoped. Caller must be admin. `caller_role` + // was snapshotted at session start (see `handle_session`), + // so no per-subscribe DB hit. A demotion mid-session + // takes effect on the caller's next reconnect. + if caller_role != "admin" { + audit_denied(caller_id, &topic_str, "role_denied"); + return error_response( + id, + error_code::TOPIC_FORBIDDEN, + "topic_forbidden", + Some(serde_json::json!({ "topic": topic_str })), + ); + } + } } // AuthZ passed — install the subscription and spawn a reader task diff --git a/tests/api/rt_bus_check.sh b/tests/api/rt_bus_check.sh index a3a5880d..0ecd0122 100755 --- a/tests/api/rt_bus_check.sh +++ b/tests/api/rt_bus_check.sh @@ -52,6 +52,18 @@ # again. Guards replay: a captured token # outside its 30 s TTL, or one already # consumed, MUST fail the upgrade with 401. +# S12 Job topic admin-only — user1 (non-admin) subscribes to +# `job:`; server must reject +# with `topic_forbidden` (audit reason +# `role_denied`). Guards the Class-3 +# role-scoped AuthZ gate on +# `Topic::Job` — admin-only, no bypass, +# anti-enumeration parity with unknown +# topics. The allow side is covered by +# the Rust `required_perm` + dispatch +# unit tests; the seeded suite has no +# admin token, and minting one here +# would pollute state for other files. # # Exit non-zero on any failure — run.sh treats that as a suite failure. # ───────────────────────────────────────────────────────────────────────────── @@ -641,4 +653,30 @@ set -e || die "S11: expected exit 2 (connect refused), got $reuse_exit" log "S11 OK" -log "All eleven message-bus scenarios passed." +# ── Scenario 12 — Job topic is admin-only ─────────────────────────────────── +# `Topic::Job("")` maps to `AuthzCheck::RoleAdmin` in +# `application/ports/message_bus_ports.rs::required_perm`, and +# `handle_subscribe` denies any caller whose snapshotted role at +# session open is not "admin". user1 is a plain account, so this +# subscribe MUST land on the deny arm. +# +# The wire response uses `topic_forbidden` (same shape as an unknown +# topic — anti-enumeration: a non-admin cannot probe which job names +# are registered). The audit reason `role_denied` is asserted at the +# Rust unit-test layer. +# +# If this ever accepts and delivers events, someone weakened the +# Class-3 gate (dropped the role-snapshot check in the Job arm, +# widened `required_perm`, or reused a permissive dispatch branch). +log "S12: user1 subscribes to job:whatever; expect topic_forbidden." +if ! "$HELPER_BIN" expect-denied \ + --url "$ws_url" \ + --token "$user1_token" \ + --subscribe "job:whatever" \ + --reason topic_forbidden \ + --timeout 3s; then + die "S12: user1 was NOT denied on job topic (admin gate broken?)" +fi +log "S12 OK" + +log "All twelve message-bus scenarios passed." From 617ae4b424c3b5c7711359749781bf8768b6d273 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 11 Sep 2026 22:08:35 +0200 Subject: [PATCH 20/21] feat(notification): add persistent notification --- docs/config/env.md | 1 + docs/plan/message-bus.md | 49 +-- example.env | 11 + .../src/lib/api/endpoints/notifications.ts | 76 +++++ frontend/src/lib/api/types.ts | 37 +++ frontend/src/lib/components/AppShell.svelte | 139 ++++++++- .../composables/useNotifications.svelte.ts | 192 ++++++++++++ .../message-bus/NotificationReceivedData.ts | 8 + .../lib/generated/message-bus/RtEventKind.ts | 1 + .../generated/message-bus/RtEventParams.ts | 2 + migrations/20261026000000_notifications.sql | 61 ++++ src/application/ports/message_bus_ports.rs | 75 ++++- src/application/services/mod.rs | 1 + .../notification_application_service.rs | 127 ++++++++ src/bin/generate-asyncapi.rs | 35 +++ src/common/config.rs | 21 ++ src/common/di.rs | 47 +++ src/domain/entities/mod.rs | 1 + src/domain/entities/notification.rs | 70 +++++ src/domain/repositories/mod.rs | 1 + .../repositories/notification_repository.rs | 90 ++++++ src/infrastructure/repositories/pg/mod.rs | 2 + .../pg/notification_pg_repository.rs | 281 ++++++++++++++++++ .../services/in_process_message_bus.rs | 1 + src/infrastructure/services/mod.rs | 1 + .../services/notifications_cleanup_service.rs | 136 +++++++++ src/interfaces/api/handlers/grant_handler.rs | 65 ++++ src/interfaces/api/handlers/mod.rs | 1 + .../api/handlers/notifications_handler.rs | 222 ++++++++++++++ src/interfaces/api/handlers/rt_ws.rs | 20 ++ src/interfaces/api/routes.rs | 23 +- tests/api/rt_bus_check.sh | 142 ++++++++- 32 files changed, 1911 insertions(+), 28 deletions(-) create mode 100644 frontend/src/lib/api/endpoints/notifications.ts create mode 100644 frontend/src/lib/composables/useNotifications.svelte.ts create mode 100644 frontend/src/lib/generated/message-bus/NotificationReceivedData.ts create mode 100644 migrations/20261026000000_notifications.sql create mode 100644 src/application/services/notification_application_service.rs create mode 100644 src/domain/entities/notification.rs create mode 100644 src/domain/repositories/notification_repository.rs create mode 100644 src/infrastructure/repositories/pg/notification_pg_repository.rs create mode 100644 src/infrastructure/services/notifications_cleanup_service.rs create mode 100644 src/interfaces/api/handlers/notifications_handler.rs diff --git a/docs/config/env.md b/docs/config/env.md index e7703a49..f3f6f727 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -120,6 +120,7 @@ rather than as a visible error. | `OXICLOUD_GRANT_CLEANUP_ENABLED` | `true` | Background daemon that deletes expired rows from `storage.role_grants`. The authorization engine already filters expired grants out of every permission check at read time (`expires_at IS NULL OR expires_at > NOW()`), so leaving expired rows in place is a hygiene issue — not a security one. This daemon garbage-collects them daily. Set to `false` to keep every expired grant row forever (uncommon; a fresh install rarely wants this). | | `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` | `15` | Days past a grant's `expires_at` before the row is eligible for deletion. The grace window preserves the audit / support answer to "what happened to my access?" for a couple of weeks past expiration. Values below 1 are legal but discouraged — the recommendation is **≥ 15 days**. Values above the actual grant TTL used by clients waste index space; a few weeks is the sweet spot. | | `OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS` | `24` | How often the grant-cleanup daemon fires. Clamped to a minimum of 1 hour. Adjusting this doesn't change what gets deleted — only how promptly. Daily is fine for any realistic grant volume. | +| `OXICLOUD_NOTIFICATIONS_RETENTION_DAYS` | `30` | Retention window for **read** notification rows (`notif.notifications`). The `notifications_cleanup` scheduled job runs daily and deletes rows where `read_at IS NOT NULL` and `read_at < now() - retention_days`. Unread rows are preserved unconditionally — the whole point of the durable table is that a user offline for a month still sees the share-granted notice on next login. Clamped to a minimum of 1 (0 would purge every read row on every tick). Adjust down for compliance-sensitive deployments where "cleared once seen" matters; adjust up when operators expect users to reference old notifications for support. | | `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX` | `@drive` | Native WebDAV URL segment that renders the caller's drive list. Sanitized by trimming leading/trailing `/`. Three shapes: (1) default `@drive` — `/webdav/…` addresses the caller's default personal drive (back-compat), `/webdav/@drive/` returns the drive listing, `/webdav/@drive//…` targets a specific drive. (2) empty string `""` — `/webdav/` IS the drive listing, `/webdav//…` targets a specific drive, no default-drive shortcut. (3) any other string (e.g. `drives`) — same shape as `@drive` with that segment substituted. Only drives the caller has Read on via `role_grants` resolve. | ## Storage Entries (multi-entry, recommended) diff --git a/docs/plan/message-bus.md b/docs/plan/message-bus.md index ded7f55f..4525f1d4 100644 --- a/docs/plan/message-bus.md +++ b/docs/plan/message-bus.md @@ -18,9 +18,9 @@ almost no extra scaffolding. ## Status — 2026-09-11 -The `feat/message-bus` branch delivers **D + F + follow-ups shipped -end-to-end** on the FE and BE, verified by S1–S11 in the api-test -smoke suite plus manual multi-user E2E. Live today: +The `feat/message-bus` branch delivers **D + F + Job dashboard live + +follow-ups shipped end-to-end** on the FE and BE, verified by S1–S12 +in the api-test smoke suite plus manual multi-user E2E. Live today: - **Bus core** — `MessageBus` port + `InProcessMessageBus` + `NoopReplicator`. `📤 bus publish` trace under @@ -71,21 +71,29 @@ smoke suite plus manual multi-user E2E. Live today: in every mutation entry point so `$state` reads don't leak into caller `$effect` deps. - **AuthZ tested** — S3 (folder no_read), S4 (nonexistent folder = - anti-enum parity), S9 (cross-user identity topic → `topic_forbidden`). + anti-enum parity), S9 (cross-user identity topic → `topic_forbidden`), + S12 (`job:*` non-admin denied — Class-3 role-scoped gate). - **Ticket tested** — S10 (happy path), S11 (single-use replay rejected). +- **Job dashboard live** — scheduler engine publishes + `JobRunStarted` / `JobRunEnded` on `Topic::Job(name)` around every + dispatch; `useJobTopic` + `AdminJobsPanel` subscribes to every + registered job's topic (keyed on the sorted name set so the 5 s + poll doesn't churn subs). State flips within a network hop instead + of waiting up to POLL_MS. Progress publishes are deferred (see + below); the polling refresh stays as fallback. Active — still under Phase A, ordered by priority: -- **Job dashboard live** (next) — `JobRegistry` publishes step - progress + terminal state on `job:{id}`; the admin jobs view - subscribes and drops its polling. Small; same shape as folder-live. - Value: an operator who triggers a long-running job (backend - migration, thumbnail import, etc.) can navigate to another admin - page and come back without losing progress visibility. - **Notifications table + bell** (E) — topic + producer + auto-sub land here. Same pattern as `:authz`. Larger; unblocks Phase-B `@mentions`. +- **Job progress publishes** (small follow-up to Job dashboard) — + handler-side per-run publisher + 3 s throttle so long jobs + (backend_migration, thumb_derived_import…) push `JobRunProgress` + events. Wire is already in place (`useJobTopic.onProgress`, + `MessageBusEvent::JobRunProgress`); waits for a per-run + `ProgressReporter` handle threaded into `JobHandler::run`. Deferred — see the Roadmap section's `## Deferred` block and the `project_message_bus_reconnect_gap` memory: @@ -1242,13 +1250,13 @@ Ships the infrastructure and the two most visible consumers together. `useReconnect` composable → folder view refetches after WS comes back. Bridges the in-memory-bus "events lost during outage" gap (see `project_message_bus_reconnect_gap` memory). -- **Job dashboard live** — TODO (next slice). `JobRegistry` - publishes step progress and terminal state on `job:{id}`; FE - job dashboard subscribes and replaces polling. Operator value: - once a long-running job is triggered (backend migration, thumb - import, blobs consistency…), the admin can navigate to another - page and come back without losing progress visibility — the WS - push keeps whatever component is subscribed up-to-date. +- **Job dashboard live** — SHIPPED 2026-09-11. Scheduler engine + publishes `JobRunStarted` + `JobRunEnded` on `Topic::Job(name)` + around every dispatch; `useJobTopic` + `AdminJobsPanel` subscribe + to every registered job's topic and flip state within a network + hop. Progress publishes deferred to a follow-up (needs a per-run + `ProgressReporter` threaded into `JobHandler::run`). The 5 s + poll stays as fallback. - **Notifications table + bell** — TODO (Slice E). New `notifications` table + `NotificationService` port; initial ingesters for `share-granted`, `new-login-from-new-device`, @@ -1261,9 +1269,10 @@ Ships the infrastructure and the two most visible consumers together. which this plan sketches but doesn't ship (`rt_ws.rs` today drops binary frames with a debug log). -Deliverables sized ~4 weeks end-to-end. Slice D (folder-live) and -Slice F (ticket flow) landed 2026-09-11. Slices E + collab are the -open work in Phase A. +Deliverables sized ~4 weeks end-to-end. Slice D (folder-live), +Slice F (ticket flow), and Job dashboard live all landed 2026-09-11. +Slice E (notifications bell) + collab are the remaining open work +in Phase A. ### Deferred — everything below is on the shelf diff --git a/example.env b/example.env index f8373e6a..90773277 100644 --- a/example.env +++ b/example.env @@ -406,6 +406,17 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud #OXICLOUD_GRANT_CLEANUP_GRACE_DAYS=15 #OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS=24 +# ─── Persistent notifications (bell) ───────────────────────────── +# Retention window (in days) for READ notifications in the bell. +# Unread rows are preserved unconditionally — that's the point of +# the durable table: a user offline for a month still sees the +# share-granted notice on next login. The `notifications_cleanup` +# scheduled job runs daily and deletes read rows older than this. +# +# Minimum 1 day (0 would purge every read row on every tick — the +# service clamps defensively). Default: 30. +#OXICLOUD_NOTIFICATIONS_RETENTION_DAYS=30 + # Enable search functionality (default: true) #OXICLOUD_ENABLE_SEARCH=true diff --git a/frontend/src/lib/api/endpoints/notifications.ts b/frontend/src/lib/api/endpoints/notifications.ts new file mode 100644 index 00000000..84a442db --- /dev/null +++ b/frontend/src/lib/api/endpoints/notifications.ts @@ -0,0 +1,76 @@ +/** + * Persistent notifications (bell) — REST client. + * + * Backs `useNotifications` (composable) and `NotificationBell` + * (component). The bell reads from these; the message bus is a + * cache-invalidation hint that triggers a refetch, not a data path. + * See `docs/plan/message-bus.md § Slice E` for the pattern. + */ +import { apiJson } from '$lib/api/client'; +import { apiFetch } from '$lib/api/client'; +import { getCsrfHeaders } from '$lib/api/csrf'; +import type { + MarkAllReadResponse, + Notification, + NotificationListResponse, + UnreadCountResponse +} from '$lib/api/types'; + +/** List newest-first. Optional `unread` filter, `before` cursor, `limit` cap. */ +export async function listNotifications(opts?: { + unread?: boolean; + before?: string; + limit?: number; +}): Promise { + const q = new URLSearchParams(); + if (opts?.unread) q.set('unread', 'true'); + if (opts?.before) q.set('before', opts.before); + if (opts?.limit !== undefined) q.set('limit', String(opts.limit)); + const suffix = q.toString(); + return apiJson(`/api/notifications${suffix ? `?${suffix}` : ''}`); +} + +/** Badge-only fast path — no payloads fetched. */ +export async function getUnreadCount(): Promise { + const res = await apiJson('/api/notifications/unread'); + return res.unread_count; +} + +/** + * Mark one notification as read. Always resolves — the server responds + * 204 regardless of whether the row existed or belonged to the caller + * (anti-enumeration). Duplicated calls are safe. + */ +export async function markNotificationRead(id: string): Promise { + const res = await apiFetch(`/api/notifications/${encodeURIComponent(id)}/read`, { + method: 'POST', + headers: getCsrfHeaders() + }); + if (!res.ok && res.status !== 204) { + throw new Error(`markNotificationRead failed: HTTP ${res.status}`); + } +} + +/** Bulk mark-all-read. Returns the number of rows the server flipped. */ +export async function markAllNotificationsRead(): Promise { + const res = await apiJson('/api/notifications/read-all', { + method: 'POST', + headers: getCsrfHeaders() + }); + return res.marked; +} + +/** Hard-delete one row. Same anti-enum shape as mark-read. */ +export async function deleteNotification(id: string): Promise { + const res = await apiFetch(`/api/notifications/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: getCsrfHeaders() + }); + if (!res.ok && res.status !== 204) { + throw new Error(`deleteNotification failed: HTTP ${res.status}`); + } +} + +// Convenience: expose the row shape for consumers that don't want +// to import from `$lib/api/types` too. +export type { Notification }; diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 0f928a27..d8526160 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -934,3 +934,40 @@ export interface ServerConfig { features: ServerFeatures; server_status: ServerStatus; } + +// ─── Notifications (Slice E) ───────────────────────────────────── +// Row shape mirrors `application/dtos` output of the Rust backend. +// `payload` stays a raw JSON object (`Record`) — +// per-kind decoding is a UI concern (kind-specific components read +// what they need from the blob). Adding a new kind server-side does +// NOT churn this file; the FE renders a generic bell row for any +// unknown kind. +export interface Notification { + id: string; + kind: string; + payload: Record; + created_at: string; + /** `null` = unread. */ + read_at: string | null; +} + +export interface NotificationListResponse { + items: Notification[]; + unread_count: number; +} + +export interface UnreadCountResponse { + unread_count: number; +} + +export interface MarkAllReadResponse { + marked: number; +} + +/** Canonical kind slugs — mirror `domain::entities::notification::kind`. */ +export const NOTIFICATION_KIND = { + SHARE_GRANTED: 'share_granted', + NEW_LOGIN_FROM_NEW_DEVICE: 'new_login_from_new_device', + JOB_COMPLETED_FOR_YOU: 'job_completed_for_you', + STORAGE_QUOTA_THRESHOLD: 'storage_quota_threshold' +} as const; diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index 2e18ab21..f40016ac 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -26,6 +26,10 @@ import { session } from '$lib/stores/session.svelte'; import { theme, type Theme } from '$lib/stores/theme.svelte'; import { ui } from '$lib/stores/ui.svelte'; + import { + notifications as persistentNotifications, + useNotifications + } from '$lib/composables/useNotifications.svelte'; import { errorToast } from '$lib/utils/errors'; import { formatBytes } from '$lib/utils/format'; @@ -327,6 +331,82 @@ setTimeout(() => (bellRinging = false), 900); }); + // Persistent notifications (Slice E) — server-backed rows, + // survive reload, delivered via `user:{me}:notifications` bus + // topic + refetched from `GET /api/notifications`. Fires the + // initial hydrate + subscribes to the topic. Independent of the + // transient toast bell above (`ui.notifications`) — that stays + // as-is for upload-progress / one-shot messages; this stream + // carries `share_granted` and friends. + useNotifications(); + + // Merged unread count for the bell badge — transient toasts plus + // persistent unread rows. Same wire and same UX affordance so a + // user sees one number and one bell for both classes. + const totalUnread = $derived(ui.unread + persistentNotifications.unread); + const totalUnreadBadge = $derived(totalUnread > 99 ? '99+' : String(totalUnread)); + + /** Format the server-side `created_at` for a persistent row. */ + function formatPersistentTime(iso: string): string { + try { + return formatTime(new Date(iso).getTime()); + } catch { + return ''; + } + } + + /** Human summary for a persistent notification. Kind-specific + * wording lives here so the DTO stays payload-agnostic. */ + function persistentSummary(row: { kind: string; payload: Record }): string { + switch (row.kind) { + case 'share_granted': { + const role = String(row.payload.role ?? 'a role'); + const resType = String(row.payload.resource_type ?? 'resource'); + return t( + 'notifications.persistent.share_granted', + { role, resType }, + `You were granted ${role} on a ${resType}.` + ); + } + case 'new_login_from_new_device': + return t( + 'notifications.persistent.new_device_login', + 'A new device signed into your account.' + ); + case 'job_completed_for_you': { + const name = String(row.payload.name ?? row.payload.job_name ?? 'a job'); + return t('notifications.persistent.job_completed', { name }, `Job "${name}" finished.`); + } + case 'storage_quota_threshold': + return t( + 'notifications.persistent.quota_threshold', + 'You are approaching your storage quota.' + ); + default: + return t( + 'notifications.persistent.generic', + { kind: row.kind }, + `Notification (${row.kind}).` + ); + } + } + + /** Icon for a persistent row's kind. Falls back to a generic bell. */ + function persistentIcon(kind: string): string { + switch (kind) { + case 'share_granted': + return 'user-plus'; + case 'new_login_from_new_device': + return 'shield-alt'; + case 'job_completed_for_you': + return 'check-circle'; + case 'storage_quota_threshold': + return 'database'; + default: + return 'bell'; + } + } + function openMobileSearch() { searchActive = true; requestAnimationFrame(() => searchInputEl?.focus()); @@ -802,11 +882,19 @@ e.stopPropagation(); notifOpen = !notifOpen; menuOpen = false; - if (notifOpen) ui.markNotificationsRead(); + if (notifOpen) { + ui.markNotificationsRead(); + // Persistent rows stay unread until the user + // explicitly clicks one — opening the panel + // doesn't mark them read (unlike the transient + // toast bell, which resets on view). Keeps the + // bell's badge accurate to "still-relevant + // server-side rows" without a bulk mark-read. + } }} > - {#if ui.unread > 0}{ui.unreadBadge}{/if} + {#if totalUnread > 0}{totalUnreadBadge}{/if}
@@ -827,7 +915,7 @@ {/if}
- {#if ui.notifications.length === 0} + {#if ui.notifications.length === 0 && persistentNotifications.items.length === 0}
{t('notifications.empty', 'No notifications')} @@ -869,6 +957,43 @@
{/each} + {#if persistentNotifications.items.length > 0} + {#if ui.notifications.length > 0} + + {/if} + {#each persistentNotifications.items as row (row.id)} +
void persistentNotifications.markRead(row.id)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + void persistentNotifications.markRead(row.id); + } + }} + > + + + +
+
{persistentSummary(row)}
+
+ {formatPersistentTime(row.created_at)} +
+
+
+ {/each} + {/if} {/if}
@@ -1295,6 +1420,14 @@ white-space: nowrap; } + /* Divider between transient toasts and persistent (server-backed) + rows. Slice E adds a section under the toast list; the divider + is only rendered when both sections have content. */ + .notif-section-divider { + border-top: 1px solid var(--color-border); + margin: 0.5rem 0; + } + /* Bell "ring" animation, replayed when bellRinging toggles on. */ .notif-bell-btn.ring :global(svg), .notif-bell-btn.ring :global(i) { diff --git a/frontend/src/lib/composables/useNotifications.svelte.ts b/frontend/src/lib/composables/useNotifications.svelte.ts new file mode 100644 index 00000000..7e95d0fa --- /dev/null +++ b/frontend/src/lib/composables/useNotifications.svelte.ts @@ -0,0 +1,192 @@ +/** + * Global bell store — persistent notifications (Slice E). + * + * Owns the reactive state for `NotificationBell`. Module-scoped so + * one instance drives every consumer in the SPA (badge in AppShell, + * slide-out panel, admin dashboard hooks, …). Same lifetime as + * `messageBus`: survives every intra-SPA navigation, dies only on + * full reload / tab close. + * + * Message-bus contract: the FE subscribes to `user:{me}:notifications` + * (auto-subscribed server-side on WS session open — no `rt.subscribe` + * frame needed from the client) and refetches the row list whenever + * a `notification_received` event arrives. The DB is truth; the bus + * event just says "there's new data, refresh". + */ +import { messageBus } from '$lib/message-bus/client.svelte'; +import { session } from '$lib/stores/session.svelte'; +import { serverConfig } from '$lib/stores/serverConfig.svelte'; +import { + deleteNotification as apiDelete, + getUnreadCount, + listNotifications, + markAllNotificationsRead, + markNotificationRead +} from '$lib/api/endpoints/notifications'; +import type { Notification } from '$lib/api/types'; +import log from 'loglevel'; + +const bellLog = log.getLogger('oxi:notifications'); + +class NotificationsStore { + #items = $state([]); + #unread = $state(0); + #loading = $state(false); + #error = $state(null); + + get items(): Notification[] { + return this.#items; + } + get unread(): number { + return this.#unread; + } + get loading(): boolean { + return this.#loading; + } + get error(): string | null { + return this.#error; + } + + /** + * Fetch the newest page + refresh the badge count. Idempotent — + * safe to call on every bus push, on mount, on visibility return. + */ + async refresh(): Promise { + this.#loading = true; + try { + const res = await listNotifications({ limit: 50 }); + this.#items = res.items; + this.#unread = res.unread_count; + this.#error = null; + } catch (e) { + this.#error = e instanceof Error ? e.message : String(e); + bellLog.warn('notifications refresh failed', e); + } finally { + this.#loading = false; + } + } + + /** Badge-only fast path — avoids fetching payloads. */ + async refreshBadge(): Promise { + try { + this.#unread = await getUnreadCount(); + } catch (e) { + bellLog.warn('badge refresh failed', e); + } + } + + async markRead(id: string): Promise { + // Optimistic update — flip locally, then confirm on the wire. + // Same pattern the folder-view uses on rename: reactive-first, + // server-eventually. A wire failure re-fetches from truth. + const row = this.#items.find((n) => n.id === id); + if (row && row.read_at === null) { + row.read_at = new Date().toISOString(); + this.#unread = Math.max(0, this.#unread - 1); + } + try { + await markNotificationRead(id); + } catch (e) { + bellLog.warn('markRead failed; reconciling', e); + await this.refresh(); + } + } + + async markAllRead(): Promise { + const now = new Date().toISOString(); + for (const row of this.#items) { + if (row.read_at === null) row.read_at = now; + } + this.#unread = 0; + try { + await markAllNotificationsRead(); + } catch (e) { + bellLog.warn('markAllRead failed; reconciling', e); + await this.refresh(); + } + } + + async delete(id: string): Promise { + const idx = this.#items.findIndex((n) => n.id === id); + if (idx >= 0) { + const [removed] = this.#items.splice(idx, 1); + if (removed && removed.read_at === null) { + this.#unread = Math.max(0, this.#unread - 1); + } + } + try { + await apiDelete(id); + } catch (e) { + bellLog.warn('delete failed; reconciling', e); + await this.refresh(); + } + } + + /** Reset — called on logout so a switch-user doesn't inherit the + * previous session's rows. */ + reset(): void { + this.#items = []; + this.#unread = 0; + this.#error = null; + } +} + +/** Module-scoped singleton — one bell state per SPA lifetime. */ +export const notifications = new NotificationsStore(); + +/** + * Wire the bell into a component's lifecycle. Fires an initial fetch + * on mount, subscribes to `user:{me}:notifications` for live pushes, + * refetches on reconnect (bus events lost during outage window). + * + * Call once from the app root (`+layout.svelte`) — this store is + * global. Additional callers do NOT need to re-mount; they can just + * read `notifications.items` / `notifications.unread`. + */ +export function useNotifications(): void { + $effect(() => { + const userId = session.user?.id; + if (!userId) return; // not logged in — nothing to fetch + // Initial hydrate from DB truth. Runs whether or not the bus + // is enabled — the bell has to work in "polling only" mode + // when OXICLOUD_MESSAGEBUS_ENABLE=false too. + void notifications.refresh(); + }); + + $effect(() => { + if (!serverConfig.features.message_bus) return; + const userId = session.user?.id; + if (!userId) return; + + // The topic is auto-subscribed server-side on WS session open + // (same pattern as `:authz`); this call refcounts up to the + // existing sub, doesn't fire a second `rt.subscribe` frame. + const release = messageBus.subscribe( + `user:${userId}:notifications`, + (params) => { + if (params.event === 'notification_received') { + // Bus event carries only the poke. Refetch the + // list — cheap, gives us the new row with its + // full payload from truth. + void notifications.refresh(); + } + }, + () => { + // Server-evicted (session flipped) — clear local so + // the badge stops showing stale count. + notifications.reset(); + } + ); + + const releaseReconnect = messageBus.onReconnect(() => { + // A push we missed during the outage window is only + // recoverable by rereading the DB. + void notifications.refresh(); + }); + + return () => { + release(); + releaseReconnect(); + }; + }); +} diff --git a/frontend/src/lib/generated/message-bus/NotificationReceivedData.ts b/frontend/src/lib/generated/message-bus/NotificationReceivedData.ts new file mode 100644 index 00000000..2a76e7a7 --- /dev/null +++ b/frontend/src/lib/generated/message-bus/NotificationReceivedData.ts @@ -0,0 +1,8 @@ +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface NotificationReceivedData { + created_at: string; + kind: string; + notification_id: string; +} +export type { NotificationReceivedData as default }; diff --git a/frontend/src/lib/generated/message-bus/RtEventKind.ts b/frontend/src/lib/generated/message-bus/RtEventKind.ts index ff42f7f7..df4aaab7 100644 --- a/frontend/src/lib/generated/message-bus/RtEventKind.ts +++ b/frontend/src/lib/generated/message-bus/RtEventKind.ts @@ -7,6 +7,7 @@ enum RtEventKind { FOLDER_RENAMED = 'folder_renamed', FOLDER_MOVED = 'folder_moved', FOLDER_DELETED = 'folder_deleted', + NOTIFICATION_RECEIVED = 'notification_received', JOB_RUN_STARTED = 'job_run_started', JOB_RUN_PROGRESS = 'job_run_progress', JOB_RUN_ENDED = 'job_run_ended' diff --git a/frontend/src/lib/generated/message-bus/RtEventParams.ts b/frontend/src/lib/generated/message-bus/RtEventParams.ts index 38602e61..5c485a8f 100644 --- a/frontend/src/lib/generated/message-bus/RtEventParams.ts +++ b/frontend/src/lib/generated/message-bus/RtEventParams.ts @@ -6,6 +6,7 @@ import type FolderCreatedData from './FolderCreatedData'; import type FolderRenamedData from './FolderRenamedData'; import type FolderMovedData from './FolderMovedData'; import type FolderDeletedData from './FolderDeletedData'; +import type NotificationReceivedData from './NotificationReceivedData'; import type JobRunStartedData from './JobRunStartedData'; import type JobRunProgressData from './JobRunProgressData'; import type JobRunEndedData from './JobRunEndedData'; @@ -22,6 +23,7 @@ interface RtEventParams { | FolderRenamedData | FolderMovedData | FolderDeletedData + | NotificationReceivedData | JobRunStartedData | JobRunProgressData | JobRunEndedData; diff --git a/migrations/20261026000000_notifications.sql b/migrations/20261026000000_notifications.sql new file mode 100644 index 00000000..2ddee7d1 --- /dev/null +++ b/migrations/20261026000000_notifications.sql @@ -0,0 +1,61 @@ +-- notif.notifications — durable per-user notification records. +-- +-- Backs the bell UI and the retention job. The message bus is best-effort +-- (a subscriber offline at publish time misses the push); this table is +-- the truth. Every `NotificationService::create` writes a row AND +-- publishes a `NotificationReceived` event on `user:{user_id}:notifications`. +-- A missed bus event recovers on the next `GET /api/notifications`. +-- +-- See `docs/plan/message-bus.md § Slice E` for the wire contract and +-- retention policy. + +CREATE SCHEMA IF NOT EXISTS notif; + +CREATE TABLE IF NOT EXISTS notif.notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Recipient. Every row is scoped to exactly one user; a share fanned + -- to N members is N rows. Fanout truncation for very-large groups + -- happens in the ingester (see plan § Notification fanout truncated), + -- not here. + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Notification kind — a stable slug the FE routes on for icon/label/ + -- action-button choice. New kinds are additive; never repurpose an + -- existing one. Initial kinds: + -- share_granted, new_login_from_new_device, + -- job_completed_for_you, storage_quota_threshold + kind TEXT NOT NULL, + + -- Per-kind opaque JSON with the fields the FE needs to render the + -- row without a follow-up API call (subject name, resource id, + -- action link…). Shape is a per-kind contract owned by the ingester; + -- the DB stays schema-free here so a new field doesn't require a + -- migration. + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + + -- Wall-clock creation stamp. Sort key for the bell. Server-clock, + -- not caller-clock — this is a DB-generated fact. + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- NULL = unread; non-NULL = when the user explicitly marked it + -- read. Retention job deletes rows where read_at IS NOT NULL AND + -- read_at < now() - retention_days. + read_at TIMESTAMPTZ +); + +-- Bell fetch — GET /api/notifications lists a user's rows newest-first, +-- typically capped at ~50, sometimes filtered on unread. This one index +-- covers both the list query and the mark-all-read filter, and the +-- INCLUDE clause keeps common bell renders (id, kind, created_at, +-- read_at) index-only. +CREATE INDEX IF NOT EXISTS notifications_user_created_read + ON notif.notifications (user_id, created_at DESC) + INCLUDE (read_at, kind); + +-- Retention job DELETE — scans read-and-old rows only. Partial keeps +-- the index tiny in the typical steady state where most rows are +-- unread. +CREATE INDEX IF NOT EXISTS notifications_read_at + ON notif.notifications (read_at) + WHERE read_at IS NOT NULL; diff --git a/src/application/ports/message_bus_ports.rs b/src/application/ports/message_bus_ports.rs index a69bf99c..84e5ab9e 100644 --- a/src/application/ports/message_bus_ports.rs +++ b/src/application/ports/message_bus_ports.rs @@ -64,6 +64,16 @@ pub enum Topic { /// the eviction wiring lands (Phase-A follow-up). UserAuthz(Uuid), + /// A user's private notifications channel — poked when a + /// [`MessageBusEvent::NotificationReceived`] event fires. The WS + /// handler auto-subscribes each session at session open (same + /// pattern as [`Topic::UserAuthz`]). Payload is a thin fact + /// (`notification_id` + `kind`); the client refetches the row from + /// `GET /api/notifications` for the details. AuthZ: **strict + /// identity match** — no admin bypass, direct UUID equality, + /// anti-enumeration parity with [`Topic::UserAuthz`]. + UserNotifications(Uuid), + /// A named background job's run lifecycle — start / progress / /// end. Consumed by the admin job dashboard so operators who /// trigger a long-running job (backend migration, thumb import…) @@ -83,6 +93,7 @@ impl Topic { match self { Topic::Folder(id) => format!("folder:{id}"), Topic::UserAuthz(id) => format!("user:{id}:authz"), + Topic::UserNotifications(id) => format!("user:{id}:notifications"), Topic::Job(name) => format!("job:{name}"), } } @@ -97,10 +108,14 @@ impl Topic { return Ok(Topic::Folder(id)); } if let Some(rest) = s.strip_prefix("user:") - && let Some((id_str, "authz")) = rest.rsplit_once(':') + && let Some((id_str, suffix)) = rest.rsplit_once(':') { let id = Uuid::parse_str(id_str).map_err(|_| ParseTopicErr::BadUuid)?; - return Ok(Topic::UserAuthz(id)); + return match suffix { + "authz" => Ok(Topic::UserAuthz(id)), + "notifications" => Ok(Topic::UserNotifications(id)), + _ => Err(ParseTopicErr::Unknown), + }; } if let Some(name) = s.strip_prefix("job:") { // Job names are scheduler-registered short slugs — see @@ -135,6 +150,7 @@ impl Topic { resource: BusResource::Folder(*id), }, Topic::UserAuthz(id) => AuthzCheck::IdentityMatch { user_id: *id }, + Topic::UserNotifications(id) => AuthzCheck::IdentityMatch { user_id: *id }, Topic::Job(_) => AuthzCheck::RoleAdmin, } } @@ -291,6 +307,25 @@ pub enum MessageBusEvent { /// plan's Phase-B roadmap. AuthzChanged { affected_folders: Vec }, + /// A new notification was created for the caller — publishes on + /// [`Topic::UserNotifications`]. Payload is deliberately thin: the + /// FE learns "there's something new to look at" and calls + /// `GET /api/notifications` to load the row. Same recovery path a + /// missed push takes on next mount, so the wire event stays a + /// pure poke — no fields the bell needs to render on its own. + /// + /// `kind` is the notification's registered kind slug + /// (`share_granted`, `job_completed_for_you`, + /// `new_login_from_new_device`, `storage_quota_threshold`, …). + /// The FE may use it to route the toast (high-priority kinds pop + /// a toast; low-priority ones just bump the badge) but never + /// treats it as authoritative — the DB row is the truth. + NotificationReceived { + notification_id: Uuid, + kind: String, + created_at: chrono::DateTime, + }, + /// A background job's run started. Published on /// [`Topic::Job`]. `started_at` is server wall-clock (RFC 3339 /// serialised by serde). Admin dashboard's job-list view uses @@ -497,6 +532,15 @@ mod tests { assert_eq!(Topic::parse(&wire).unwrap(), t); } + #[test] + fn user_notifications_topic_roundtrip() { + let id = Uuid::new_v4(); + let t = Topic::UserNotifications(id); + let wire = t.to_wire_key(); + assert_eq!(wire, format!("user:{id}:notifications")); + assert_eq!(Topic::parse(&wire).unwrap(), t); + } + #[test] fn job_topic_roundtrip() { let t = Topic::Job("backend_migration".to_string()); @@ -539,7 +583,12 @@ mod tests { assert_eq!( Topic::parse(&format!("user:{}", Uuid::new_v4())), Err(ParseTopicErr::Unknown), - "user: without :authz suffix is not a known topic in MVP" + "user: without a known suffix (:authz, :notifications) is not a known topic" + ); + assert_eq!( + Topic::parse(&format!("user:{}:whatever", Uuid::new_v4())), + Err(ParseTopicErr::Unknown), + "an unrecognised suffix rejects — no partial match on the prefix" ); } @@ -563,6 +612,18 @@ mod tests { ); } + #[test] + fn required_perm_user_notifications_is_identity_match() { + // Same strict-privacy gate as :authz — no admin bypass, direct + // UUID equality, anti-enum parity. A regression here would + // let admins snoop on other users' notification streams. + let id = Uuid::new_v4(); + assert_eq!( + Topic::UserNotifications(id).required_perm(), + AuthzCheck::IdentityMatch { user_id: id } + ); + } + #[test] fn event_serializes_with_snake_case_discriminator() { // The `#[serde(tag = "event")]` shape is the WS wire contract for @@ -651,6 +712,14 @@ mod tests { }, "authz_changed", ), + ( + MessageBusEvent::NotificationReceived { + notification_id: Uuid::nil(), + kind: "share_granted".into(), + created_at: chrono::DateTime::::from_timestamp(0, 0).unwrap(), + }, + "notification_received", + ), ( MessageBusEvent::JobRunStarted { name: "backend_migration".into(), diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 88710afa..c255f09a 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -25,6 +25,7 @@ pub mod mount_registry; pub mod music_service; pub mod nextcloud_file_id_service; pub mod nextcloud_login_flow_service; +pub mod notification_application_service; pub mod people_service; pub mod places_service; pub mod recent_service; diff --git a/src/application/services/notification_application_service.rs b/src/application/services/notification_application_service.rs new file mode 100644 index 00000000..b7ceaf93 --- /dev/null +++ b/src/application/services/notification_application_service.rs @@ -0,0 +1,127 @@ +//! Orchestrates persistent notifications. +//! +//! `create()` is the single ingester entry point: +//! +//! 1. Insert the row via [`NotificationRepository::create`]. +//! 2. Publish a thin `NotificationReceived` event on +//! `user:{user_id}:notifications` so subscribed sessions refetch +//! immediately. +//! +//! The DB row is the truth (see `docs/plan/message-bus.md § Slice E`). +//! The bus is best-effort — a subscriber offline at publish time +//! recovers on next `GET /api/notifications`. Publish happens AFTER +//! the DB write succeeds, never inside a transaction — the plan's +//! "publish after commit" invariant. +//! +//! Reads (`list_for_user`, `count_unread_for_user`) and state changes +//! (`mark_read`, `mark_all_read`, `delete`) back the REST endpoints in +//! `interfaces/api/handlers/notifications.rs`. Every mutating method +//! is scoped on `user_id` at the SQL layer; the service does not run +//! its own AuthZ check because the identity is by construction +//! (`caller_id == user_id`, extracted from the auth middleware). + +use std::sync::Arc; + +use chrono::Utc; +use uuid::Uuid; + +use crate::application::ports::message_bus_ports::{MessageBus, MessageBusEvent, Topic}; +use crate::common::errors::DomainError; +use crate::domain::entities::notification::{NewNotification, Notification}; +use crate::domain::repositories::notification_repository::{ + NotificationListFilter, NotificationRepository, +}; + +pub struct NotificationApplicationService { + repo: Arc, + bus: Arc, +} + +impl NotificationApplicationService { + pub fn new(repo: Arc, bus: Arc) -> Self { + Self { repo, bus } + } + + /// Insert a row for `new_notif` and publish a thin bus event. + /// Returns the persisted row. This is the ingester-facing method + /// — called from `ShareService::create_grant`, + /// `AuthApplicationService` (new-device login), + /// `SchedulerEngine` (job completed for actor), and the quota + /// threshold hook. + pub async fn create(&self, new_notif: NewNotification) -> Result { + let row = self.repo.create(&new_notif).await?; + + // Publish AFTER the row is durable. Silent no-op if the bus + // is disabled at boot (`OXICLOUD_MESSAGEBUS_ENABLE=false`) — + // the WS route is unmounted so the publish just hits a dead + // sender. The FE bell still works: it reads from the DB on + // mount. See plan § "Slice E". + self.bus.publish( + &Topic::UserNotifications(row.user_id), + MessageBusEvent::NotificationReceived { + notification_id: row.id, + kind: row.kind.clone(), + created_at: row.created_at, + }, + ); + + Ok(row) + } + + /// List notifications for `user_id` newest-first. Default limit at + /// this layer is 50 rows (the repo caps at 500 defensively). + pub async fn list_for_user( + &self, + user_id: Uuid, + filter: NotificationListFilter, + ) -> Result, DomainError> { + self.repo.list_for_user(user_id, &filter).await + } + + /// Unread badge count. + pub async fn count_unread_for_user(&self, user_id: Uuid) -> Result { + self.repo.count_unread_for_user(user_id).await + } + + /// Mark one notification as read. Returns `true` if the row + /// transitioned unread → read (i.e. was owned by `caller_id` and + /// was previously unread). Returns `false` for already-read, + /// missing, or misowned rows — indistinguishable at the wire so + /// enumeration doesn't leak. + pub async fn mark_read( + &self, + notification_id: Uuid, + caller_id: Uuid, + ) -> Result { + self.repo + .mark_read(notification_id, caller_id, Utc::now()) + .await + } + + /// Bulk mark-all-read. Returns rows updated. + pub async fn mark_all_read(&self, caller_id: Uuid) -> Result { + self.repo + .mark_all_read_for_user(caller_id, Utc::now()) + .await + } + + /// Hard-delete one row. Same anti-enumeration semantics as + /// [`mark_read`] — returns `false` for missing / misowned. + pub async fn delete( + &self, + notification_id: Uuid, + caller_id: Uuid, + ) -> Result { + self.repo.delete_by_id(notification_id, caller_id).await + } + + /// Retention job entry point. Called by `notifications_cleanup` + /// on its daily cadence — deletes read rows older than `cutoff`. + /// Unread rows are always preserved. + pub async fn purge_read_before_cutoff( + &self, + cutoff: chrono::DateTime, + ) -> Result { + self.repo.purge_read_before(cutoff).await + } +} diff --git a/src/bin/generate-asyncapi.rs b/src/bin/generate-asyncapi.rs index 52bd67e8..ef3c5f19 100644 --- a/src/bin/generate-asyncapi.rs +++ b/src/bin/generate-asyncapi.rs @@ -141,6 +141,17 @@ fn channels() -> Value { "UnsubscribeRequest": { "$ref": "#/components/messages/RtUnsubscribeRequest" }, } }, + "UserNotifications": { + "address": "user:{userId}:notifications", + "description": "A user's private notifications channel. Identity-scoped: caller_id must equal userId (no admin bypass). Auto-subscribed at session open; the FE bell refetches `GET /api/notifications` when a `notification_received` event fires. The DB row is authoritative — a missed push recovers on the next mount.", + "parameters": { + "userId": { "description": "User UUID — must match the authenticated caller" } + }, + "messages": { + "SubscribeRequest": { "$ref": "#/components/messages/RtSubscribeRequest" }, + "UnsubscribeRequest": { "$ref": "#/components/messages/RtUnsubscribeRequest" }, + } + }, "Job": { "address": "job:{jobName}", "description": "A named background job's run lifecycle — Started / Progress / Ended. Consumed by the admin dashboard so operators who trigger a long-running job (backend migration, thumb import…) can navigate off the admin page and come back without losing progress. AuthZ: admin-only (Class 3 role-scoped) — non-admin gets `topic_forbidden`, indistinguishable on the wire from an unknown topic.", @@ -316,6 +327,7 @@ fn components() -> Value { "FolderRenamedData": folder_renamed_schema(), "FolderMovedData": folder_moved_schema(), "FolderDeletedData": folder_deleted_schema(), + "NotificationReceivedData": notification_received_schema(), "JobRunStartedData": job_run_started_schema(), "JobRunProgressData": job_run_progress_schema(), "JobRunEndedData": job_run_ended_schema(), @@ -598,6 +610,7 @@ fn event_kind_schema() -> Value { "enum": [ "file_created", "file_renamed", "file_moved", "file_deleted", "folder_created", "folder_renamed", "folder_moved", "folder_deleted", + "notification_received", "job_run_started", "job_run_progress", "job_run_ended", ], }) @@ -615,6 +628,7 @@ fn event_data_union_schema() -> Value { ref_schema("FolderRenamedData"), ref_schema("FolderMovedData"), ref_schema("FolderDeletedData"), + ref_schema("NotificationReceivedData"), ref_schema("JobRunStartedData"), ref_schema("JobRunProgressData"), ref_schema("JobRunEndedData"), @@ -732,6 +746,27 @@ fn folder_deleted_schema() -> Value { }) } +// ─────────────────── Notification event payload ────────────────── +// Published on `Topic::UserNotifications(user_id)`. Identity-scoped +// (Class 2) — caller must equal the topic's user_id, no admin +// bypass. Payload is a thin poke: `notification_id` + `kind` + +// `created_at`. The FE bell refetches `GET /api/notifications` on +// receipt for the row's full payload; the DB is the truth, the bus +// event is just an invalidation. + +fn notification_received_schema() -> Value { + json!({ + "type": "object", + "description": "A new notification was created for the caller. Payload is intentionally thin — the FE refetches `GET /api/notifications` for the row's full contents. `kind` is the notification's registered kind slug (`share_granted`, `job_completed_for_you`, `new_login_from_new_device`, `storage_quota_threshold`, …); the FE may use it to route a toast for high-priority kinds but never treats it as authoritative.", + "required": ["notification_id", "kind", "created_at"], + "properties": { + "notification_id": { "type": "string", "format": "uuid" }, + "kind": { "type": "string" }, + "created_at": { "type": "string", "format": "date-time" }, + } + }) +} + // ─────────────────── Job event data payloads ───────────────────── // Published on `Topic::Job(name)`. AuthZ is Class-3 (admin-only) — // non-admins get `topic_forbidden` on subscribe, so these payloads diff --git a/src/common/config.rs b/src/common/config.rs index c6b88781..c094e294 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -2311,6 +2311,17 @@ pub struct FeaturesConfig { /// Enabled by default: expired-auth-row cleanup is a /// security-hygiene default, not opt-in. pub grant_cleanup: GrantCleanupConfig, + + /// Retention window (in days) for read notification rows — + /// `notif.notifications` with `read_at IS NOT NULL`. Unread rows + /// are preserved unconditionally; the `notifications_cleanup` + /// scheduled job deletes read rows older than this on a daily + /// cadence. + /// + /// Env: `OXICLOUD_NOTIFICATIONS_RETENTION_DAYS` (default `30`). + /// Minimum 1 (0 would delete every read row on every tick — the + /// service clamps defensively). + pub notifications_retention_days: u32, } /// Config for the daily expired-grant purge (see @@ -2498,6 +2509,7 @@ impl Default for FeaturesConfig { webdav_drive_listing_prefix: "@drive".to_string(), enable_message_bus: true, // Message bus (WS + ticket) on by default grant_cleanup: GrantCleanupConfig::default(), + notifications_retention_days: 30, // 30 days is the plan's default } } } @@ -3377,6 +3389,15 @@ impl AppConfig { config.features.enable_message_bus = val; } + // Slice E — notification retention. Read as u32 so a + // non-numeric or negative value falls back to the declared + // default (30 days) rather than crashing at boot. + if let Ok(raw) = env::var("OXICLOUD_NOTIFICATIONS_RETENTION_DAYS") + && let Ok(val) = raw.parse::() + { + config.features.notifications_retention_days = val.max(1); + } + if let Ok(enable_search) = env::var("OXICLOUD_ENABLE_SEARCH").map(|v| v.parse::()) && let Ok(val) = enable_search { diff --git a/src/common/di.rs b/src/common/di.rs index 0efe2a65..f2cff698 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -2418,6 +2418,7 @@ impl AppServiceFactory { mock_email_sender: None, // populated below magic_link_invite_service: None, // populated below recipient_notification_service: None, // populated below alongside magic_link_invite_service + notification_service: None, // populated below (Slice E) // Per-caller limits, configurable since the hardcoded ceilings // had no escape hatch for deployments where several actors share // one identity — a CI suite running as a single `admin` shares @@ -2536,6 +2537,42 @@ impl AppServiceFactory { ), )); } + + // Persistent in-app notifications (Slice E). Repo + bus + // are both always available when auth is on; the service + // wraps them into the ingester-facing `create()` + + // bell-facing reads. Always wired under `auth_service` — + // notifications are per-user and require an authenticated + // caller everywhere they surface. + let notif_repo: Arc< + dyn crate::domain::repositories::notification_repository::NotificationRepository, + > = Arc::new( + crate::infrastructure::repositories::pg::NotificationPgRepository::new( + pool.clone(), + ), + ); + let notif_bus: Arc = + app_state.bus.clone(); + let notification_service = Arc::new( + crate::application::services::notification_application_service::NotificationApplicationService::new( + notif_repo, + notif_bus, + ), + ); + app_state.notification_service = Some(notification_service.clone()); + + // Retention sweep — daily; deletes read notifications + // older than OXICLOUD_NOTIFICATIONS_RETENTION_DAYS. Same + // self-registering pattern as `trash_cleanup`. + let retention_days = app_state.core.config.features.notifications_retention_days; + let _ = Arc::new( + crate::infrastructure::services::notifications_cleanup_service::NotificationsCleanupService::new( + notification_service, + retention_days, + ), + ) + .register(&app_state.core.job_registry) + .await; } // 9b. Wire admin settings service when auth is available @@ -3441,6 +3478,16 @@ pub struct AppState { pub recipient_notification_service: Option< Arc, >, + /// Persistent in-app notifications — bell UI, retention job, four + /// initial ingesters (share-granted, new-login-from-new-device, + /// job-completed-for-you, storage-quota-threshold). Always + /// populated when auth is enabled (bell requires an authenticated + /// caller). Wraps a PG repo + the message bus; `create()` writes + /// the row AND publishes on `user:{u}:notifications` in one call. + /// See `docs/plan/message-bus.md § Slice E`. + pub notification_service: Option< + Arc, + >, /// Per-caller sliding-window limiter for `GET /api/users/{id}`. The /// endpoint's primary defense is the visibility check, but a stale /// JWT could in theory iterate UUIDs against the related-by-grant diff --git a/src/domain/entities/mod.rs b/src/domain/entities/mod.rs index a475accb..2a7cdffe 100644 --- a/src/domain/entities/mod.rs +++ b/src/domain/entities/mod.rs @@ -9,6 +9,7 @@ pub mod face; pub mod file; pub mod folder; pub mod magic_link_token; +pub mod notification; pub mod playlist; pub mod session; pub mod share; diff --git a/src/domain/entities/notification.rs b/src/domain/entities/notification.rs new file mode 100644 index 00000000..a251f971 --- /dev/null +++ b/src/domain/entities/notification.rs @@ -0,0 +1,70 @@ +//! In-app notification — one durable row per recipient per event. +//! +//! Backs the bell UI. The message bus poke on +//! `user:{user_id}:notifications` is a fast path; the row is truth. +//! See `docs/plan/message-bus.md § Slice E` for the wire contract. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// A stable kind slug. The FE routes on this string for icon / label / +/// action-button choice. New kinds are additive; **never repurpose an +/// existing value** — the FE reads it as an enum-like discriminant. +/// +/// The initial set matches the plan's Slice-E ingester list. Additional +/// values are legal on the wire (an older FE ignores unknown kinds +/// gracefully by falling back to a generic bell row); we still keep the +/// canonical list here so the ingester callsites reach for symbolic +/// constants instead of literal strings. +/// +/// The DB column is plain `TEXT` (see `migrations/20261026000000_notifications.sql`) +/// — no CHECK constraint. Adding a new kind is a code change only, no +/// migration, no downtime. +pub mod kind { + /// A grant was created for the recipient user (they can now access + /// a resource). Payload carries the resource id + role + granter. + pub const SHARE_GRANTED: &str = "share_granted"; + + /// A login succeeded from a device / IP fingerprint the user + /// hasn't seen before. Payload carries the user-agent snippet + /// and the coarsened location if available. + pub const NEW_LOGIN_FROM_NEW_DEVICE: &str = "new_login_from_new_device"; + + /// A background job triggered by the recipient user finished + /// (success or failure). Payload carries the job name and + /// `success: bool`. Clicking navigates to `/admin/jobs/`. + pub const JOB_COMPLETED_FOR_YOU: &str = "job_completed_for_you"; + + /// The recipient's storage quota crossed a warning threshold + /// (e.g. 80 %, 95 %). Payload carries `used_bytes` / `quota_bytes` + /// and the crossed percentage. + pub const STORAGE_QUOTA_THRESHOLD: &str = "storage_quota_threshold"; +} + +/// One notification row. +/// +/// `payload` is a per-kind opaque JSON blob; the DB stays schema-free +/// so a new field never requires a migration. Callers deserialize it +/// against a kind-specific struct on the FE. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Notification { + pub id: Uuid, + pub user_id: Uuid, + pub kind: String, + pub payload: serde_json::Value, + pub created_at: DateTime, + /// `None` = unread; `Some(t)` = when the user explicitly marked it + /// read via `POST /api/notifications/{id}/read` or + /// `POST /api/notifications/read-all`. + pub read_at: Option>, +} + +/// The service-layer input for [`NotificationService::create`]. Split +/// from [`Notification`] because `id` / `created_at` are DB-generated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewNotification { + pub user_id: Uuid, + pub kind: String, + pub payload: serde_json::Value, +} diff --git a/src/domain/repositories/mod.rs b/src/domain/repositories/mod.rs index 51a36364..97e414e3 100644 --- a/src/domain/repositories/mod.rs +++ b/src/domain/repositories/mod.rs @@ -6,6 +6,7 @@ pub mod drive_repository; pub mod file_repository; pub mod folder_repository; pub mod magic_link_token_repository; +pub mod notification_repository; pub mod playlist_repository; pub mod session_repository; pub mod settings_repository; diff --git a/src/domain/repositories/notification_repository.rs b/src/domain/repositories/notification_repository.rs new file mode 100644 index 00000000..6055711a --- /dev/null +++ b/src/domain/repositories/notification_repository.rs @@ -0,0 +1,90 @@ +//! Storage port for [`Notification`]. +//! +//! Backs the bell UI. `create` is the only ingester-facing method; +//! `list_for_user` / `mark_read` / `mark_all_read` / `delete_by_id` / +//! `purge_read_before` back the REST endpoints and the retention job. +//! +//! Every method takes `user_id` where relevant so the SQL includes the +//! caller-scope in its WHERE clause — the application service double- +//! checks the requested notification's owner matches the caller, but +//! the repo scoping is defense in depth (a bug that misroutes an id +//! still can't leak another user's row through `mark_read`). + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use crate::common::errors::DomainError; +use crate::domain::entities::notification::{NewNotification, Notification}; + +/// Optional filter for [`NotificationRepository::list_for_user`]. All +/// fields are additive — `None` means "no restriction on this axis". +#[derive(Debug, Clone, Default)] +pub struct NotificationListFilter { + /// Cap on rows returned. Default at the service layer is 50; the + /// repo does not impose one so a full-export use case remains + /// possible. + pub limit: Option, + /// When `Some(true)`, return only rows with `read_at IS NULL`. + /// When `Some(false)`, return only rows with `read_at IS NOT NULL`. + /// `None` returns both. + pub unread_only: Option, + /// When `Some(t)`, return only rows created strictly before `t`. + /// Cursor-style pagination: caller passes the oldest `created_at` + /// from the previous page. + pub before: Option>, +} + +#[async_trait] +pub trait NotificationRepository: Send + Sync + 'static { + /// Insert a new notification. Returns the persisted row (id + + /// created_at populated). The application service publishes the + /// bus event AFTER this returns Ok — see plan's "publish after + /// commit" invariant. + async fn create(&self, new_notif: &NewNotification) -> Result; + + /// List notifications for `user_id` newest-first, honouring + /// `filter`. Returns an empty Vec (not an error) when the user + /// has none. + async fn list_for_user( + &self, + user_id: Uuid, + filter: &NotificationListFilter, + ) -> Result, DomainError>; + + /// Count unread rows for `user_id`. Backs the bell's unread badge. + /// Separate from `list_for_user` so the badge can render without + /// fetching payloads. + async fn count_unread_for_user(&self, user_id: Uuid) -> Result; + + /// Mark one notification as read. Returns `Ok(true)` if a row + /// transitioned from unread → read (i.e. was owned by `user_id` + /// AND had `read_at IS NULL`); `Ok(false)` if the row didn't + /// exist, was owned by someone else, or was already read. + /// Idempotent from the caller's perspective; the `bool` is for + /// logs / audit only. + async fn mark_read( + &self, + notification_id: Uuid, + user_id: Uuid, + at: DateTime, + ) -> Result; + + /// Bulk mark-all-read. Returns the number of rows updated. + async fn mark_all_read_for_user( + &self, + user_id: Uuid, + at: DateTime, + ) -> Result; + + /// Hard-delete a single row. Same ownership scoping as + /// [`mark_read`]. Returns `Ok(true)` iff a row was deleted. + async fn delete_by_id(&self, notification_id: Uuid, user_id: Uuid) + -> Result; + + /// Retention job: delete every read row whose `read_at` is older + /// than `cutoff`. Returns the number of rows removed. + /// Unread rows are preserved unconditionally — that's the whole + /// point of the durable table. + async fn purge_read_before(&self, cutoff: DateTime) -> Result; +} diff --git a/src/infrastructure/repositories/pg/mod.rs b/src/infrastructure/repositories/pg/mod.rs index 99483610..857bf3d9 100644 --- a/src/infrastructure/repositories/pg/mod.rs +++ b/src/infrastructure/repositories/pg/mod.rs @@ -14,6 +14,7 @@ mod favorites_pg_repository; pub mod file_metadata_repository; mod magic_link_token_pg_repository; mod nextcloud_object_id_repository; +mod notification_pg_repository; mod opaque_pg_repository; pub mod playlist_pg_repository; mod recent_items_pg_repository; @@ -48,6 +49,7 @@ pub use file_metadata_repository::FileMetadataRepository; pub use folder_db_repository::FolderDbRepository; pub use magic_link_token_pg_repository::MagicLinkTokenPgRepository; pub use nextcloud_object_id_repository::NextcloudObjectIdRepository; +pub use notification_pg_repository::NotificationPgRepository; pub use opaque_pg_repository::OpaquePgRepository; pub use playlist_pg_repository::{ AudioMetadataPgRepository, PlaylistItemPgRepository, PlaylistPgRepository, diff --git a/src/infrastructure/repositories/pg/notification_pg_repository.rs b/src/infrastructure/repositories/pg/notification_pg_repository.rs new file mode 100644 index 00000000..ee219496 --- /dev/null +++ b/src/infrastructure/repositories/pg/notification_pg_repository.rs @@ -0,0 +1,281 @@ +//! PostgreSQL implementation of [`NotificationRepository`]. +//! +//! Backs the bell UI plus the daily retention job. All queries scope on +//! `user_id` at the SQL layer so a row misroute in the caller can't +//! leak another user's data through mark_read / delete. Schema lives +//! in `migrations/20261026000000_notifications.sql`. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::{PgPool, Row}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::entities::notification::{NewNotification, Notification}; +use crate::domain::repositories::notification_repository::{ + NotificationListFilter, NotificationRepository, +}; + +pub struct NotificationPgRepository { + pool: Arc, +} + +impl NotificationPgRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + fn map_row(row: &sqlx::postgres::PgRow) -> Result { + let map_err = |field: &str, e: sqlx::Error| { + DomainError::new( + ErrorKind::DatabaseError, + "Notification", + format!("read {field}: {e}"), + ) + }; + Ok(Notification { + id: row.try_get("id").map_err(|e| map_err("id", e))?, + user_id: row.try_get("user_id").map_err(|e| map_err("user_id", e))?, + kind: row.try_get("kind").map_err(|e| map_err("kind", e))?, + payload: row.try_get("payload").map_err(|e| map_err("payload", e))?, + created_at: row + .try_get("created_at") + .map_err(|e| map_err("created_at", e))?, + read_at: row.try_get("read_at").ok(), + }) + } +} + +fn db_err(op: &'static str, e: sqlx::Error) -> DomainError { + DomainError::new( + ErrorKind::DatabaseError, + "Notification", + format!("{op}: {e}"), + ) +} + +#[async_trait] +impl NotificationRepository for NotificationPgRepository { + async fn create(&self, new_notif: &NewNotification) -> Result { + let row = sqlx::query( + r#" + INSERT INTO notif.notifications (user_id, kind, payload) + VALUES ($1::uuid, $2, $3) + RETURNING id, user_id, kind, payload, created_at, read_at + "#, + ) + .bind(new_notif.user_id) + .bind(&new_notif.kind) + .bind(&new_notif.payload) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| db_err("create", e))?; + Self::map_row(&row) + } + + async fn list_for_user( + &self, + user_id: Uuid, + filter: &NotificationListFilter, + ) -> Result, DomainError> { + // Dynamic-shape query built to still hit the + // notifications_user_created_read index — every branch keys + // on (user_id, created_at DESC). + let limit: i64 = filter.limit.unwrap_or(50).min(500) as i64; + let rows = match (filter.unread_only, filter.before) { + (None, None) => { + sqlx::query( + r#" + SELECT id, user_id, kind, payload, created_at, read_at + FROM notif.notifications + WHERE user_id = $1::uuid + ORDER BY created_at DESC + LIMIT $2 + "#, + ) + .bind(user_id) + .bind(limit) + .fetch_all(self.pool.as_ref()) + .await + } + (Some(true), None) => { + sqlx::query( + r#" + SELECT id, user_id, kind, payload, created_at, read_at + FROM notif.notifications + WHERE user_id = $1::uuid AND read_at IS NULL + ORDER BY created_at DESC + LIMIT $2 + "#, + ) + .bind(user_id) + .bind(limit) + .fetch_all(self.pool.as_ref()) + .await + } + (Some(false), None) => { + sqlx::query( + r#" + SELECT id, user_id, kind, payload, created_at, read_at + FROM notif.notifications + WHERE user_id = $1::uuid AND read_at IS NOT NULL + ORDER BY created_at DESC + LIMIT $2 + "#, + ) + .bind(user_id) + .bind(limit) + .fetch_all(self.pool.as_ref()) + .await + } + (None, Some(before)) => { + sqlx::query( + r#" + SELECT id, user_id, kind, payload, created_at, read_at + FROM notif.notifications + WHERE user_id = $1::uuid AND created_at < $2 + ORDER BY created_at DESC + LIMIT $3 + "#, + ) + .bind(user_id) + .bind(before) + .bind(limit) + .fetch_all(self.pool.as_ref()) + .await + } + (Some(true), Some(before)) => { + sqlx::query( + r#" + SELECT id, user_id, kind, payload, created_at, read_at + FROM notif.notifications + WHERE user_id = $1::uuid AND read_at IS NULL AND created_at < $2 + ORDER BY created_at DESC + LIMIT $3 + "#, + ) + .bind(user_id) + .bind(before) + .bind(limit) + .fetch_all(self.pool.as_ref()) + .await + } + (Some(false), Some(before)) => { + sqlx::query( + r#" + SELECT id, user_id, kind, payload, created_at, read_at + FROM notif.notifications + WHERE user_id = $1::uuid AND read_at IS NOT NULL AND created_at < $2 + ORDER BY created_at DESC + LIMIT $3 + "#, + ) + .bind(user_id) + .bind(before) + .bind(limit) + .fetch_all(self.pool.as_ref()) + .await + } + } + .map_err(|e| db_err("list_for_user", e))?; + + rows.iter().map(Self::map_row).collect() + } + + async fn count_unread_for_user(&self, user_id: Uuid) -> Result { + let row = sqlx::query( + r#" + SELECT COUNT(*)::bigint AS c + FROM notif.notifications + WHERE user_id = $1::uuid AND read_at IS NULL + "#, + ) + .bind(user_id) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| db_err("count_unread_for_user", e))?; + row.try_get::("c") + .map_err(|e| db_err("count_unread_for_user.map", e)) + } + + async fn mark_read( + &self, + notification_id: Uuid, + user_id: Uuid, + at: DateTime, + ) -> Result { + // Guard on read_at IS NULL so a re-issued call from a client + // that's already ack'd the row is a no-op instead of stamping + // a later timestamp over the earlier one. + let res = sqlx::query( + r#" + UPDATE notif.notifications + SET read_at = $3 + WHERE id = $1::uuid + AND user_id = $2::uuid + AND read_at IS NULL + "#, + ) + .bind(notification_id) + .bind(user_id) + .bind(at) + .execute(self.pool.as_ref()) + .await + .map_err(|e| db_err("mark_read", e))?; + Ok(res.rows_affected() == 1) + } + + async fn mark_all_read_for_user( + &self, + user_id: Uuid, + at: DateTime, + ) -> Result { + let res = sqlx::query( + r#" + UPDATE notif.notifications + SET read_at = $2 + WHERE user_id = $1::uuid AND read_at IS NULL + "#, + ) + .bind(user_id) + .bind(at) + .execute(self.pool.as_ref()) + .await + .map_err(|e| db_err("mark_all_read_for_user", e))?; + Ok(res.rows_affected()) + } + + async fn delete_by_id( + &self, + notification_id: Uuid, + user_id: Uuid, + ) -> Result { + let res = sqlx::query( + r#" + DELETE FROM notif.notifications + WHERE id = $1::uuid AND user_id = $2::uuid + "#, + ) + .bind(notification_id) + .bind(user_id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| db_err("delete_by_id", e))?; + Ok(res.rows_affected() == 1) + } + + async fn purge_read_before(&self, cutoff: DateTime) -> Result { + let res = sqlx::query( + r#" + DELETE FROM notif.notifications + WHERE read_at IS NOT NULL AND read_at < $1 + "#, + ) + .bind(cutoff) + .execute(self.pool.as_ref()) + .await + .map_err(|e| db_err("purge_read_before", e))?; + Ok(res.rows_affected()) + } +} diff --git a/src/infrastructure/services/in_process_message_bus.rs b/src/infrastructure/services/in_process_message_bus.rs index b97fb8fc..a5646d73 100644 --- a/src/infrastructure/services/in_process_message_bus.rs +++ b/src/infrastructure/services/in_process_message_bus.rs @@ -199,6 +199,7 @@ fn event_kind(event: &MessageBusEvent) -> &'static str { MessageBusEvent::FolderMoved { .. } => "folder_moved", MessageBusEvent::FolderDeleted { .. } => "folder_deleted", MessageBusEvent::AuthzChanged { .. } => "authz_changed", + MessageBusEvent::NotificationReceived { .. } => "notification_received", MessageBusEvent::JobRunStarted { .. } => "job_run_started", MessageBusEvent::JobRunProgress { .. } => "job_run_progress", MessageBusEvent::JobRunEnded { .. } => "job_run_ended", diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index e3ed53c9..64a60d55 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -39,6 +39,7 @@ pub mod mock_email_sender; pub mod mount_provider_factory; pub mod nextcloud_chunked_upload_service; pub mod noop_face_analyzer; +pub mod notifications_cleanup_service; pub mod oidc_service; #[cfg(feature = "faces-onnx")] pub mod onnx_face_analyzer; diff --git a/src/infrastructure/services/notifications_cleanup_service.rs b/src/infrastructure/services/notifications_cleanup_service.rs new file mode 100644 index 00000000..7153006a --- /dev/null +++ b/src/infrastructure/services/notifications_cleanup_service.rs @@ -0,0 +1,136 @@ +//! `notifications_cleanup` scheduled job — daily retention sweep. +//! +//! Deletes rows from `notif.notifications` where `read_at IS NOT NULL` +//! and older than the retention window. Unread rows are preserved +//! unconditionally (the whole point of the durable table is that a +//! user offline for a month still sees the share-granted notice on +//! next login). +//! +//! Retention window comes from `OXICLOUD_NOTIFICATIONS_RETENTION_DAYS` +//! (default 30), applied at job dispatch — one env var maps to one +//! `retention_days` parameter so an operator can override the default +//! at trigger time without a redeploy. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use chrono::Utc; +use tracing::info; + +use crate::application::services::notification_application_service::NotificationApplicationService; +use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs, Mutates}; + +/// Parameter declaration table. Kept at module scope so +/// `JobHandler::parameters` can return a `'static` slice without +/// stack-allocating each call. +static PARAMETERS: [crate::infrastructure::scheduler::JobParam; 1] = + [crate::infrastructure::scheduler::JobParam::number( + "retention_days", + 30, + "Delete read notifications older than this many days.", + )]; + +pub struct NotificationsCleanupService { + service: Arc, + /// Default retention window in days when the trigger call did NOT + /// supply an explicit `retention_days` parameter. Read from + /// `OXICLOUD_NOTIFICATIONS_RETENTION_DAYS` at boot; the constructor + /// clamps to a minimum of 1 day (0 would purge every read row on + /// every tick). + default_retention_days: i64, +} + +impl NotificationsCleanupService { + pub const JOB_NAME: &'static str = "notifications_cleanup"; + + pub fn new(service: Arc, default_retention_days: u32) -> Self { + Self { + service, + default_retention_days: default_retention_days.max(1) as i64, + } + } + + /// Interval — daily. Same tier as `trash_cleanup`; retention is a + /// "days" concept, so a finer cadence buys nothing. + fn interval() -> Duration { + Duration::from_secs(24 * 3600) + } + + /// Register self with the scheduler. Chained DI helper, same shape + /// as [`TrashCleanupService::register`]. + pub async fn register(self: Arc, registry: &JobRegistry) -> Arc { + registry + .register(self.clone(), Some(Self::interval()), None) + .await; + self + } +} + +#[async_trait] +impl JobHandler for NotificationsCleanupService { + fn name(&self) -> &str { + Self::JOB_NAME + } + + fn description(&self) -> &'static str { + "Deletes read notifications older than the retention window \ + (default 30 days, override via `retention_days` parameter or \ + OXICLOUD_NOTIFICATIONS_RETENTION_DAYS). Unread rows are \ + preserved unconditionally." + } + + fn mutates(&self) -> Mutates { + Mutates::Always + } + + fn parameters(&self) -> &'static [crate::infrastructure::scheduler::JobParam] { + // Declared default of 30 days is the SAME literal the config + // block's env fallback uses (`OXICLOUD_NOTIFICATIONS_RETENTION_DAYS` + // default), so an operator who never sets the env sees 30 + // everywhere. The env-derived `default_retention_days` on + // this struct only diverges from 30 when the operator DID + // set the env — see the guard in `run()` below. + &PARAMETERS + } + + async fn run(&self, args: &JobRunArgs) -> JobOutcome { + // `get_number` returns the fallback ONLY when the arg is + // absent — but declared defaults are seeded by the engine + // before `run` runs (see JobRunArgs::normalized_for), so the + // param is always present with either the caller's value or + // the declared 30. We treat "declared default AND env + // override differs" as "use env override" to keep the + // OXICLOUD_NOTIFICATIONS_RETENTION_DAYS knob effective + // without teaching the engine per-instance defaults. + let declared_default = 30_i64; + let raw = args.get_number("retention_days", declared_default); + let retention_days = if raw == declared_default { + self.default_retention_days + } else { + raw + } + .max(1); + let cutoff = Utc::now() - chrono::Duration::days(retention_days); + + match self.service.purge_read_before_cutoff(cutoff).await { + Ok(removed) => { + info!( + target: "audit", + event = "notifications.retention_sweep", + retention_days, + removed, + "🧹 notifications retention sweep: {removed} row(s) purged (retention {retention_days} d)" + ); + JobOutcome::ok_with( + removed, + serde_json::json!({ + "retention_days": retention_days, + "removed": removed, + }), + ) + } + Err(e) => JobOutcome::err(format!("notifications cleanup failed: {e}")), + } + } +} diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index b302fc62..7c053754 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -330,6 +330,71 @@ pub async fn create_grant( "🤝 grant created with role '{}'", role.as_str(), ); + // Slice E — persistent in-app notification (bell) for every + // recipient user. Separate channel from the email path below: + // the DB row is authoritative and survives SMTP being down / + // the recipient not having email, and it powers the FE bell + + // unread badge. + // + // Fan out to the resolved user ids: + // - Subject::User(id) → one row for that user + // - Subject::Group(id) → one row per transitive member (uses + // subject_group_service if wired; groups + // without a service configured skip the + // bell but still get email via the + // recipient service below) + // - Subject::Token(_) → no bell row (anonymous share link, no + // target user to route it to) + // + // Every failure here is best-effort — a row-write hiccup logs a + // warn and continues to the email path. The grant row is already + // durable in `role_grants`; the recipient can still discover the + // share via the resources-shared-with-me listing. + if let Some(notif_svc) = state.notification_service.as_ref() { + let recipient_ids: Vec = match subject { + Subject::User(id) => vec![id], + Subject::Group(group_id) => match state.subject_group_service.as_ref() { + Some(sgs) => sgs + .list_transitive_users(group_id) + .await + .unwrap_or_else(|e| { + warn!("group {group_id} member expansion failed; skipping bell: {e}"); + Vec::new() + }), + None => Vec::new(), + }, + Subject::Token(_) => Vec::new(), + }; + for rid in recipient_ids { + // Self-shares (owner grants themselves via a group they + // are also in) would fire a bell on the owner — filter + // that out here. Every other filter (opt-out flag, etc.) + // is deferred; in-app notifications are less intrusive + // than SMTP so the ceremony is lighter. + if rid == caller_id { + continue; + } + let payload = serde_json::json!({ + "granter_id": caller_id, + "resource_type": resource.type_str(), + "resource_id": resource.id(), + "role": role.as_str(), + "expires_at": expires_at, + }); + let new_notif = crate::domain::entities::notification::NewNotification { + user_id: rid, + kind: crate::domain::entities::notification::kind::SHARE_GRANTED.to_string(), + payload, + }; + if let Err(e) = notif_svc.create(new_notif).await { + warn!( + "notification.create failed for share_granted (recipient={rid}, resource={:?}): {e}", + resource + ); + } + } + } + // PR N1 — route the post-grant notification through the unified // RecipientNotificationService. Handles user/group/token subjects // uniformly (Token subjects return an empty outcome set); applies diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index c3b3e7b0..875754a1 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -20,6 +20,7 @@ pub mod grant_handler; pub mod i18n_handler; pub mod magic_link_handler; pub mod music_handler; +pub mod notifications_handler; pub mod opaque_auth_handler; pub mod people_handler; pub mod photos_handler; diff --git a/src/interfaces/api/handlers/notifications_handler.rs b/src/interfaces/api/handlers/notifications_handler.rs new file mode 100644 index 00000000..2a9c02b7 --- /dev/null +++ b/src/interfaces/api/handlers/notifications_handler.rs @@ -0,0 +1,222 @@ +//! `/api/notifications/*` — the bell UI's REST surface. +//! +//! Five endpoints back the FE `NotificationBell`: +//! +//! - `GET /api/notifications` — list newest-first; optional +//! `unread=true` filter, `before` cursor, `limit` cap. +//! - `GET /api/notifications/unread` — badge-only fast path (count). +//! - `POST /api/notifications/{id}/read` — mark one as read. +//! - `POST /api/notifications/read-all` — bulk mark-all-read. +//! - `DELETE /api/notifications/{id}` — hard-delete one row. +//! +//! Every endpoint scopes on `auth_user.id` at the SQL layer via the +//! application service, so an id enumeration against +//! `POST /api/notifications/{id}/read` returns the same 204 whether +//! the row exists-and-belongs-to-somebody-else, or doesn't exist at +//! all. Anti-enumeration is the reason the response body doesn't +//! distinguish "already read" from "not yours" — the service returns +//! a bool for our logs, we always return 204 to the wire. + +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::application::services::notification_application_service::NotificationApplicationService; +use crate::domain::entities::notification::Notification; +use crate::domain::repositories::notification_repository::NotificationListFilter; +use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::auth::AuthUser; + +/// Wire shape for one notification row. `payload` stays a raw JSON +/// value — per-kind decoding happens on the FE using the `kind` +/// discriminant. +#[derive(Debug, Serialize, ToSchema)] +pub struct NotificationDto { + pub id: Uuid, + pub kind: String, + #[schema(value_type = Object)] + pub payload: serde_json::Value, + pub created_at: DateTime, + /// `null` = unread. + pub read_at: Option>, +} + +impl From for NotificationDto { + fn from(n: Notification) -> Self { + Self { + id: n.id, + kind: n.kind, + payload: n.payload, + created_at: n.created_at, + read_at: n.read_at, + } + } +} + +/// Query params for `GET /api/notifications`. +#[derive(Debug, Deserialize, ToSchema)] +pub struct ListQuery { + /// When `true`, return only unread rows. Default: `false` (both). + #[serde(default)] + pub unread: bool, + /// Cursor — return rows strictly before this `created_at`. Omit + /// for the newest page. + pub before: Option>, + /// Max rows returned. Server-side clamp at 500. + pub limit: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ListResponseDto { + pub items: Vec, + /// Unread rows for this user across the whole table — the bell + /// badge reads this. Kept on the list response so a bell open + /// doesn't need a second round-trip for the badge. + pub unread_count: i64, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct UnreadCountDto { + pub unread_count: i64, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct MarkAllReadResponseDto { + /// Number of rows that transitioned unread → read. + pub marked: u64, +} + +/// GET /api/notifications +#[utoipa::path( + get, + path = "/api/notifications", + params( + ("unread" = Option, Query, description = "Only return unread rows"), + ("before" = Option>, Query, description = "Cursor — rows strictly before this created_at"), + ("limit" = Option, Query, description = "Max rows (server-side clamp at 500)"), + ), + responses( + (status = 200, description = "List of notifications", body = ListResponseDto), + ), + security(("bearerAuth" = [])), + tag = "notifications" +)] +pub async fn list_notifications( + State(service): State>, + auth_user: AuthUser, + Query(query): Query, +) -> Result, AppError> { + let filter = NotificationListFilter { + limit: query.limit, + unread_only: if query.unread { Some(true) } else { None }, + before: query.before, + }; + let rows = service.list_for_user(auth_user.id, filter).await?; + let unread_count = service.count_unread_for_user(auth_user.id).await?; + Ok(Json(ListResponseDto { + items: rows.into_iter().map(NotificationDto::from).collect(), + unread_count, + })) +} + +/// GET /api/notifications/unread — badge-only fast path. +#[utoipa::path( + get, + path = "/api/notifications/unread", + responses( + (status = 200, description = "Unread count", body = UnreadCountDto), + ), + security(("bearerAuth" = [])), + tag = "notifications" +)] +pub async fn unread_count( + State(service): State>, + auth_user: AuthUser, +) -> Result, AppError> { + let unread_count = service.count_unread_for_user(auth_user.id).await?; + Ok(Json(UnreadCountDto { unread_count })) +} + +/// POST /api/notifications/{id}/read — mark one as read. +/// +/// Always responds 204 regardless of whether the row existed and +/// belonged to the caller — the service's `bool` return is logged +/// (audit reason `notification.marked_read` on success), never +/// surfaced to the wire. +#[utoipa::path( + post, + path = "/api/notifications/{id}/read", + params(("id" = Uuid, Path, description = "Notification id")), + responses((status = 204, description = "Marked read (idempotent, anti-enum)")), + security(("bearerAuth" = [])), + tag = "notifications" +)] +pub async fn mark_read( + State(service): State>, + auth_user: AuthUser, + Path(id): Path, +) -> Result { + let transitioned = service.mark_read(id, auth_user.id).await?; + if transitioned { + tracing::debug!( + target: "oxicloud::notifications", + caller_id = %auth_user.id, + notification_id = %id, + "notification marked read" + ); + } + Ok(StatusCode::NO_CONTENT) +} + +/// POST /api/notifications/read-all — bulk mark-all-read. +#[utoipa::path( + post, + path = "/api/notifications/read-all", + responses((status = 200, description = "Rows marked", body = MarkAllReadResponseDto)), + security(("bearerAuth" = [])), + tag = "notifications" +)] +pub async fn mark_all_read( + State(service): State>, + auth_user: AuthUser, +) -> Result, AppError> { + let marked = service.mark_all_read(auth_user.id).await?; + Ok(Json(MarkAllReadResponseDto { marked })) +} + +/// DELETE /api/notifications/{id} — hard-delete one row. +/// +/// Same anti-enum semantics as `mark_read` — always 204. +#[utoipa::path( + delete, + path = "/api/notifications/{id}", + params(("id" = Uuid, Path, description = "Notification id")), + responses((status = 204, description = "Deleted (idempotent, anti-enum)")), + security(("bearerAuth" = [])), + tag = "notifications" +)] +pub async fn delete_notification( + State(service): State>, + auth_user: AuthUser, + Path(id): Path, +) -> Result { + let deleted = service.delete(id, auth_user.id).await?; + if deleted { + tracing::debug!( + target: "oxicloud::notifications", + caller_id = %auth_user.id, + notification_id = %id, + "notification deleted" + ); + } + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/interfaces/api/handlers/rt_ws.rs b/src/interfaces/api/handlers/rt_ws.rs index 8608b155..a56f1d66 100644 --- a/src/interfaces/api/handlers/rt_ws.rs +++ b/src/interfaces/api/handlers/rt_ws.rs @@ -401,6 +401,20 @@ async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc) /// loop then walks the sub set and drops matching topics. Any other /// event kind on this topic is ignored (defensive; shouldn't happen /// in MVP). +/// - For `Topic::UserNotifications(_)`: an incoming +/// `MessageBusEvent::NotificationReceived` is forwarded through the +/// default path — the FE bell listens for `rt.event` on the +/// auto-subscribed identity topic and refetches `GET +/// /api/notifications` when it sees one. Same anti-enumeration +/// invariant as `:authz` (identity-scoped, no admin bypass). /// - For every other topic: bus events are wrapped into a client- /// visible `rt.event` notification and pushed as `SessionOut::Frame`. fn install_subscription( diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 84a4c488..d402460c 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -195,6 +195,7 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { let share_service = app_state.share_service.clone(); let favorites_service = app_state.favorites_service.clone(); let recent_service = app_state.recent_service.clone(); + let notification_service = app_state.notification_service.clone(); // authorization is no longer extracted separately — the grants router now // uses app_state directly so handlers can access all services. @@ -409,6 +410,25 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { Router::new() }; + // Notifications bell (Slice E). Mounted only when the service is + // wired (i.e. auth is enabled — bell requires a caller). Non- + // registration path: with the flag off, the routes 404 instead of + // 5xx-ing on a NULL service — matches the OXICLOUD_MESSAGEBUS_ENABLE + // approach for `/api/rt/*` and `OXICLOUD_ENABLE_EXTERNAL_MOUNTS` + // for admin mounts. + let notifications_router = if let Some(ref svc) = notification_service { + use crate::interfaces::api::handlers::notifications_handler; + Router::new() + .route("/", get(notifications_handler::list_notifications)) + .route("/unread", get(notifications_handler::unread_count)) + .route("/read-all", post(notifications_handler::mark_all_read)) + .route("/{id}/read", post(notifications_handler::mark_read)) + .route("/{id}", delete(notifications_handler::delete_notification)) + .with_state(svc.clone()) + } else { + Router::new() + }; + // Create routes for chunked uploads (large files >10MB). // All five handlers are free functions — see chunked_upload_handler.rs for why // #[utoipa::path] cannot be applied to ChunkedUploadHandler impl methods directly. @@ -455,7 +475,8 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { .nest("/shares", share_router) .nest("/grants", grants_router) .nest("/favorites", favorites_router) - .nest("/recent", recent_router); + .nest("/recent", recent_router) + .nest("/notifications", notifications_router); // Photos timeline endpoint — lists all image/video files sorted by capture date { diff --git a/tests/api/rt_bus_check.sh b/tests/api/rt_bus_check.sh index 0ecd0122..adb68eac 100755 --- a/tests/api/rt_bus_check.sh +++ b/tests/api/rt_bus_check.sh @@ -64,6 +64,39 @@ # unit tests; the seeded suite has no # admin token, and minting one here # would pollute state for other files. +# S13 Notification wire push — user2 subscribes to +# `user:{user2_id}:notifications`; user1 +# creates a grant that targets user2. +# Server must publish one `rt.event` +# with `event="notification_received"`, +# `data.kind="share_granted"`. Guards +# the Slice-E ingester + the auto-sub +# delivery path together — this is the +# only scenario that exercises the +# wire push from the `NotificationService`. +# S14 Notification DB row — after S13's grant, GET +# `/api/notifications` as user2 lists +# at least one row with +# `kind="share_granted"` whose payload +# references the freshly-shared folder, +# and `unread_count >= 1`. Guards the +# authoritative side of the pattern — +# a subscriber offline at publish time +# recovers via this endpoint. +# S15 Cross-user notif deny — user1 subscribes to +# `user:{user2_id}:notifications` +# (an identity-scoped topic that +# resolves to somebody else). Server +# must reject with `topic_forbidden` +# (same wire shape as unknown topic, +# same rule as S9 for `:authz`). +# Guards the strict-privacy Class-2 +# AuthZ gate on Slice-E notifications +# — no admin bypass, direct UUID +# equality only. If this ever accepts +# and delivers events, an admin (or +# anyone else) could snoop on other +# users' notification streams. # # Exit non-zero on any failure — run.sh treats that as a suite failure. # ───────────────────────────────────────────────────────────────────────────── @@ -679,4 +712,111 @@ if ! "$HELPER_BIN" expect-denied \ fi log "S12 OK" -log "All twelve message-bus scenarios passed." +# ── Scenario 13 — Notification wire push (Slice E) ────────────────────────── +# The `share_granted` ingester runs in `grant_handler::create_grant`: +# after `set_role` lands and before the email path, it calls +# `NotificationService::create` for every resolved recipient user. +# `create` writes the DB row AND publishes a thin +# `MessageBusEvent::NotificationReceived` on +# `user:{user_id}:notifications`. This scenario exercises the wire +# path end-to-end: user2 opens a WS + explicitly subscribes to their +# own notifications topic (idempotent with the server's auto-sub), +# user1 fires a fresh grant, user2 sees the one event. +# +# A fresh folder C is used so this scenario is independent of the +# S8 grant/revoke sequence — user2 already has DB rows from S8's +# grants on A + B, but those events fired BEFORE user2's WS opened +# so no wire delivery competes with S13's. +log "S13: create folder C, subscribe user2 to their notifications, expect one share_granted event." +folder_c=$(c_post "$base_url/api/folders" "$user1_token" \ + "$(printf '{"name":"rt_bus_C_%s","parent_id":"%s"}' "$suffix" "$root_id")" | jq -r '.id') +[[ -n "$folder_c" && "$folder_c" != "null" ]] || die "S13: folder C creation failed" + +out_s13="$(mktemp -t rtbus_s13.XXXXXX)" +ready_s13="$(mktemp -t rtbus_s13_ready.XXXXXX)"; rm -f "$ready_s13" +"$HELPER_BIN" subscribe-and-collect \ + --url "$ws_url" \ + --token "$user2_token" \ + --subscribe "user:${user2_id}:notifications" \ + --expect-events 1 \ + --timeout 5s \ + --ready-file "$ready_s13" \ + --output "$out_s13" & +helper_pid=$! +wait_ready "$ready_s13" + +grant_c=$(c_post "$base_url/api/grants" "$user1_token" \ + "$(printf '{"subject":{"type":"user","id":"%s"},"resource":{"type":"folder","id":"%s"},"role":"viewer"}' \ + "$user2_id" "$folder_c")") +grant_c_id=$(printf '%s' "$grant_c" | jq -r '.grants[0].id') +[[ -n "$grant_c_id" && "$grant_c_id" != "null" ]] \ + || die "S13: grant on folder C failed: $grant_c" + +if ! wait "$helper_pid"; then + cat "$out_s13" >&2 || true + die "S13: helper did not observe the notification_received event" +fi +[[ "$(jq -r '.events | length' "$out_s13")" == "1" ]] \ + || { cat "$out_s13"; die "S13: expected 1 event, got $(jq -r '.events | length' "$out_s13")"; } +[[ "$(jq -r '.events[0].event' "$out_s13")" == "notification_received" ]] \ + || die "S13: wrong event discriminator: $(jq -r '.events[0].event' "$out_s13")" +[[ "$(jq -r '.events[0].data.kind' "$out_s13")" == "share_granted" ]] \ + || die "S13: wrong notification kind: $(jq -r '.events[0].data.kind' "$out_s13")" +# `notification_id` is a fresh UUID stamped by the DB — check it's +# non-empty and non-null. Value asserted by S14 via GET /api/notifications. +[[ -n "$(jq -r '.events[0].data.notification_id' "$out_s13")" ]] \ + && [[ "$(jq -r '.events[0].data.notification_id' "$out_s13")" != "null" ]] \ + || die "S13: notification_id missing on wire payload" +log "S13 OK" + +# ── Scenario 14 — Notification DB row (Slice E) ───────────────────────────── +# The bus event is best-effort. The DB row is truth: a subscriber +# offline at publish time recovers via `GET /api/notifications`. +# S13 fired a grant on folder C; the ingester wrote a row for user2. +# This scenario reads it back and asserts on shape. +# +# `unread_count` from the same response reflects ALL unread rows, +# including the 2 from S8's grants (folders A + B) — the fresh grant +# in S13 brings the total to >= 3. We assert >= 1 (loose enough to +# not couple to S8's state, tight enough to prove the row landed). +log "S14: GET /api/notifications as user2; expect a share_granted row for folder C." +notifs=$(c_get "$base_url/api/notifications" "$user2_token") +unread=$(printf '%s' "$notifs" | jq -r '.unread_count') +[[ "$unread" -ge 1 ]] \ + || { printf '%s\n' "$notifs" >&2; die "S14: unread_count expected >= 1, got $unread"; } +# Filter for the S13 row: kind == share_granted AND payload.resource_id == folder_c. +match_count=$(printf '%s' "$notifs" | jq --arg fc "$folder_c" \ + '[.items[] | select(.kind == "share_granted" and .payload.resource_id == $fc)] | length') +[[ "$match_count" -ge 1 ]] \ + || { printf '%s\n' "$notifs" >&2; die "S14: no share_granted row for folder C (matches=$match_count)"; } +# The matched row must be unread (read_at is null) — the caller +# hasn't clicked it yet, so the bell would still badge it. +first_read_at=$(printf '%s' "$notifs" | jq -r --arg fc "$folder_c" \ + 'first(.items[] | select(.kind == "share_granted" and .payload.resource_id == $fc)) | .read_at') +[[ "$first_read_at" == "null" ]] \ + || die "S14: matched row unexpectedly marked read: read_at=$first_read_at" +log "S14 OK" + +# ── Scenario 15 — Cross-user notifications identity gate ──────────────────── +# `Topic::UserNotifications(u)` maps to `AuthzCheck::IdentityMatch{u}` +# in `application/ports/message_bus_ports.rs::required_perm`. Direct +# UUID equality only — no admin bypass. A caller subscribing to +# another user's notifications channel MUST be denied with the same +# wire shape (`topic_forbidden`) as an unknown topic — anti-enum. +# +# If this ever regresses (identity check dropped, engine wired on +# this class, admin bypass added) it becomes a privacy leak on par +# with an admin snooping on `:authz` streams. Same guard as S9, +# different topic suffix. +log "S15: user1 subscribes to user:{user2_id}:notifications; expect topic_forbidden." +if ! "$HELPER_BIN" expect-denied \ + --url "$ws_url" \ + --token "$user1_token" \ + --subscribe "user:${user2_id}:notifications" \ + --reason topic_forbidden \ + --timeout 3s; then + die "S15: user1 was NOT denied on user2's notifications topic (identity gate broken?)" +fi +log "S15 OK" + +log "All fifteen message-bus scenarios passed." From ebe467ee92a3ede0535c052cf84168a6b01f83d4 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 12 Sep 2026 00:16:06 +0200 Subject: [PATCH 21/21] feat(notification): recover notification since last known event on client resume --- .../src/lib/api/endpoints/notifications.ts | 13 +- .../composables/useNotifications.svelte.ts | 143 +++++++++++++++--- .../lib/composables/useNotifications.test.ts | 58 +++++++ .../repositories/notification_repository.rs | 29 ++-- .../pg/notification_pg_repository.rs | 127 ++++------------ .../api/handlers/notifications_handler.rs | 16 +- tests/api/rt_bus_check.sh | 91 ++++++++++- 7 files changed, 345 insertions(+), 132 deletions(-) create mode 100644 frontend/src/lib/composables/useNotifications.test.ts diff --git a/frontend/src/lib/api/endpoints/notifications.ts b/frontend/src/lib/api/endpoints/notifications.ts index 84a442db..57a2edea 100644 --- a/frontend/src/lib/api/endpoints/notifications.ts +++ b/frontend/src/lib/api/endpoints/notifications.ts @@ -16,15 +16,26 @@ import type { UnreadCountResponse } from '$lib/api/types'; -/** List newest-first. Optional `unread` filter, `before` cursor, `limit` cap. */ +/** + * List newest-first. All filters are optional and additive: + * - `unread` — only rows with `read_at IS NULL` + * - `before` — older-than cursor for "load older page" pagination + * - `after` — newer-than cursor for delta catch-up on WS reconnect + * or tab reactivation (dedup handled at the store layer + * via `mergeById`, since the WS push and the delta fetch + * can race on the same row) + * - `limit` — server-side clamp at 500 rows + */ export async function listNotifications(opts?: { unread?: boolean; before?: string; + after?: string; limit?: number; }): Promise { const q = new URLSearchParams(); if (opts?.unread) q.set('unread', 'true'); if (opts?.before) q.set('before', opts.before); + if (opts?.after) q.set('after', opts.after); if (opts?.limit !== undefined) q.set('limit', String(opts.limit)); const suffix = q.toString(); return apiJson(`/api/notifications${suffix ? `?${suffix}` : ''}`); diff --git a/frontend/src/lib/composables/useNotifications.svelte.ts b/frontend/src/lib/composables/useNotifications.svelte.ts index 7e95d0fa..4a388e0a 100644 --- a/frontend/src/lib/composables/useNotifications.svelte.ts +++ b/frontend/src/lib/composables/useNotifications.svelte.ts @@ -9,9 +9,33 @@ * * Message-bus contract: the FE subscribes to `user:{me}:notifications` * (auto-subscribed server-side on WS session open — no `rt.subscribe` - * frame needed from the client) and refetches the row list whenever - * a `notification_received` event arrives. The DB is truth; the bus - * event just says "there's new data, refresh". + * frame needed from the client) and refetches on every push. The DB + * is truth; the bus event is a cache-invalidation hint. + * + * # Delta catch-up + dedup + * + * Two paths can deliver the SAME row and must not double-count it: + * + * 1. **WS live push** — `notification_received` event → calls + * `refreshDelta(#lastReceivedAt)` which fetches + * `?after=&limit=100`, merges the result into the + * reactive list. + * 2. **Reconnect catch-up** — after a grace-close (tab idle > 60 s) + * or a network drop, the WS reopens and `onReconnect` fires the + * same `refreshDelta(#lastReceivedAt)`. This backfills rows that + * landed while the socket was closed. + * + * The race: a NEW notification created after the reconnect but + * before the delta fetch returns lands via BOTH paths — WS push + * (delta fetch A) and reconnect (delta fetch B). Dedup lives in + * `mergeById`: incoming rows keyed on `id` displace any existing + * entry with the same id, so the row appears exactly once. Server + * `read_at` always wins over local because incoming replaces. + * + * `#lastReceivedAt` is the newest `created_at` we've observed. It + * feeds every delta fetch. Initial `refresh()` seeds it from the + * newest returned row; subsequent merges update it to the newest of + * the incoming set. */ import { messageBus } from '$lib/message-bus/client.svelte'; import { session } from '$lib/stores/session.svelte'; @@ -28,11 +52,37 @@ import log from 'loglevel'; const bellLog = log.getLogger('oxi:notifications'); +/** + * Merge `incoming` rows into `existing`, deduplicating on `id`. + * Where an id appears in both, the incoming (fresh-from-server) + * copy wins — so a `read_at` flip visible in `incoming` correctly + * overrides a stale local unread state. Result stays sorted + * newest-first by `created_at`. + * + * Exported for the unit tests to exercise the race semantics + * without spinning up a full store. + */ +export function mergeById(existing: Notification[], incoming: Notification[]): Notification[] { + if (incoming.length === 0) return existing; + // Local lookup set — pure function, no reactive state involved, + // so `SvelteSet` would add allocations without buying anything. + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const incomingIds = new Set(incoming.map((n) => n.id)); + const kept = existing.filter((n) => !incomingIds.has(n.id)); + // String compare of ISO-8601 UTC timestamps sorts identically + // to Date compare — cheaper, no allocation per row. + return [...incoming, ...kept].sort((a, b) => b.created_at.localeCompare(a.created_at)); +} + class NotificationsStore { #items = $state([]); #unread = $state(0); #loading = $state(false); #error = $state(null); + /** Newest `created_at` we've observed, ISO 8601. Feeds the + * `?after=…` cursor on delta fetches. `null` until the first + * successful `refresh()` seeds it. */ + #lastReceivedAt: string | null = null; get items(): Notification[] { return this.#items; @@ -48,8 +98,9 @@ class NotificationsStore { } /** - * Fetch the newest page + refresh the badge count. Idempotent — - * safe to call on every bus push, on mount, on visibility return. + * Full refresh — replaces the local list with the newest page + * from the server. Used on initial mount + as fallback when a + * delta fetch fails or a mutation reconciliation runs. */ async refresh(): Promise { this.#loading = true; @@ -57,6 +108,7 @@ class NotificationsStore { const res = await listNotifications({ limit: 50 }); this.#items = res.items; this.#unread = res.unread_count; + this.#lastReceivedAt = res.items[0]?.created_at ?? this.#lastReceivedAt; this.#error = null; } catch (e) { this.#error = e instanceof Error ? e.message : String(e); @@ -66,6 +118,48 @@ class NotificationsStore { } } + /** + * Delta fetch — pulls only rows strictly newer than + * `#lastReceivedAt` (or does nothing if we've never fetched yet; + * the caller should fall back to `refresh()` in that case). + * Merges via `mergeById` so a concurrent WS push and reconnect + * catch-up can't double-count a row that landed twice. + * + * Silent no-op when the server returns 0 rows — we're already in + * sync. Updates `#lastReceivedAt` to the newest of the merged set. + */ + async refreshDelta(): Promise { + if (this.#lastReceivedAt === null) { + // Never fetched — fall back to a full refresh so the + // caller doesn't need to distinguish the two cases. + return this.refresh(); + } + try { + // `limit: 100` sized to cover realistic bell traffic per + // hour without paginating; a rare heavy sender who blows + // past 100 in one gap still gets 100 newest and the DB + // row count (unread badge) stays authoritative. + const res = await listNotifications({ + after: this.#lastReceivedAt, + limit: 100 + }); + if (res.items.length > 0) { + this.#items = mergeById(this.#items, res.items); + // Newest of merged set — take the first item's + // created_at since the result is sorted DESC. + this.#lastReceivedAt = res.items[0].created_at; + } + // unread_count is the authoritative live server count — + // always update it even when the delta was empty (a row + // could have been mark-read'd on another device). + this.#unread = res.unread_count; + this.#error = null; + } catch (e) { + this.#error = e instanceof Error ? e.message : String(e); + bellLog.warn('notifications delta failed', e); + } + } + /** Badge-only fast path — avoids fetching payloads. */ async refreshBadge(): Promise { try { @@ -127,6 +221,7 @@ class NotificationsStore { reset(): void { this.#items = []; this.#unread = 0; + this.#lastReceivedAt = null; this.#error = null; } } @@ -135,13 +230,14 @@ class NotificationsStore { export const notifications = new NotificationsStore(); /** - * Wire the bell into a component's lifecycle. Fires an initial fetch - * on mount, subscribes to `user:{me}:notifications` for live pushes, - * refetches on reconnect (bus events lost during outage window). + * Wire the bell into a component's lifecycle. Fires an initial full + * fetch on mount, subscribes to `user:{me}:notifications` for live + * pushes, delta-fetches on reconnect (backfills rows missed during + * grace-close / network gap). * - * Call once from the app root (`+layout.svelte`) — this store is - * global. Additional callers do NOT need to re-mount; they can just - * read `notifications.items` / `notifications.unread`. + * Call once from the app root (`AppShell`) — this store is global. + * Additional callers do NOT need to re-mount; they can just read + * `notifications.items` / `notifications.unread`. */ export function useNotifications(): void { $effect(() => { @@ -165,10 +261,13 @@ export function useNotifications(): void { `user:${userId}:notifications`, (params) => { if (params.event === 'notification_received') { - // Bus event carries only the poke. Refetch the - // list — cheap, gives us the new row with its - // full payload from truth. - void notifications.refresh(); + // Bus event carries only the poke. Delta-fetch + // from `#lastReceivedAt` — cheap when the store + // is caught up, brings the new row with its full + // payload from truth. Dedup via `mergeById` + // handles the race with an in-flight reconnect + // catch-up returning the same row. + void notifications.refreshDelta(); } }, () => { @@ -179,9 +278,17 @@ export function useNotifications(): void { ); const releaseReconnect = messageBus.onReconnect(() => { - // A push we missed during the outage window is only - // recoverable by rereading the DB. - void notifications.refresh(); + // Tab was hidden > 60 s, or network dropped. WS just + // reopened — any bus events published during the gap + // are lost. Backfill via the `?after=` + // cursor. Server's `unread_count` in the response is + // authoritative — a mark-read on another device while + // we were dark shows up here. + // + // Race with a live rt.event that lands milliseconds + // later: `mergeById` deduplicates on `id`, so the + // same row from both paths appears exactly once. + void notifications.refreshDelta(); }); return () => { diff --git a/frontend/src/lib/composables/useNotifications.test.ts b/frontend/src/lib/composables/useNotifications.test.ts new file mode 100644 index 00000000..93566512 --- /dev/null +++ b/frontend/src/lib/composables/useNotifications.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { mergeById } from './useNotifications.svelte'; +import type { Notification } from '$lib/api/types'; + +function row(id: string, created_at: string, read_at: string | null = null): Notification { + return { + id, + kind: 'share_granted', + payload: {}, + created_at, + read_at + }; +} + +describe('mergeById — WS-push vs delta-fetch race dedup', () => { + it('preserves existing when incoming is empty', () => { + const existing = [row('a', '2026-09-11T10:00:00Z'), row('b', '2026-09-11T09:00:00Z')]; + expect(mergeById(existing, [])).toEqual(existing); + }); + + it('appends non-overlapping incoming and sorts newest-first', () => { + const existing = [row('b', '2026-09-11T09:00:00Z')]; + const incoming = [row('a', '2026-09-11T10:00:00Z')]; + const merged = mergeById(existing, incoming); + expect(merged.map((n) => n.id)).toEqual(['a', 'b']); + }); + + it('dedupes on id — same row from WS push and delta fetch appears once', () => { + // Simulates the race: `x` was delivered live via rt.event + // and appended locally, then the reconnect delta fetch + // returns the same `x` again. Must not double it. + const existing = [row('x', '2026-09-11T10:00:00Z')]; + const incoming = [row('x', '2026-09-11T10:00:00Z')]; + expect(mergeById(existing, incoming)).toHaveLength(1); + }); + + it('lets server value win — read_at flip visible in incoming', () => { + // User marked `x` as read on another device. Local copy is + // stale (still unread). The delta fetch returns the fresh + // row with read_at populated — that must win. + const existing = [row('x', '2026-09-11T10:00:00Z', null)]; + const incoming = [row('x', '2026-09-11T10:00:00Z', '2026-09-11T10:05:00Z')]; + const merged = mergeById(existing, incoming); + expect(merged).toHaveLength(1); + expect(merged[0].read_at).toBe('2026-09-11T10:05:00Z'); + }); + + it('merges mixed overlap correctly', () => { + const existing = [row('b', '2026-09-11T09:00:00Z'), row('a', '2026-09-11T08:00:00Z')]; + const incoming = [ + row('c', '2026-09-11T10:00:00Z'), // new + row('b', '2026-09-11T09:00:00Z', '2026-09-11T09:30:00Z') // updated + ]; + const merged = mergeById(existing, incoming); + expect(merged.map((n) => n.id)).toEqual(['c', 'b', 'a']); + expect(merged[1].read_at).toBe('2026-09-11T09:30:00Z'); + }); +}); diff --git a/src/domain/repositories/notification_repository.rs b/src/domain/repositories/notification_repository.rs index 6055711a..727b69d5 100644 --- a/src/domain/repositories/notification_repository.rs +++ b/src/domain/repositories/notification_repository.rs @@ -18,21 +18,30 @@ use crate::common::errors::DomainError; use crate::domain::entities::notification::{NewNotification, Notification}; /// Optional filter for [`NotificationRepository::list_for_user`]. All -/// fields are additive — `None` means "no restriction on this axis". +/// fields are additive — the default (Default::default) applies no +/// restriction on any axis. #[derive(Debug, Clone, Default)] pub struct NotificationListFilter { /// Cap on rows returned. Default at the service layer is 50; the - /// repo does not impose one so a full-export use case remains - /// possible. + /// repo caps defensively at 500 so a runaway caller can't drag + /// the DB. pub limit: Option, - /// When `Some(true)`, return only rows with `read_at IS NULL`. - /// When `Some(false)`, return only rows with `read_at IS NOT NULL`. - /// `None` returns both. - pub unread_only: Option, - /// When `Some(t)`, return only rows created strictly before `t`. - /// Cursor-style pagination: caller passes the oldest `created_at` - /// from the previous page. + /// `true` → return only rows with `read_at IS NULL`. `false` + /// (default) returns both read and unread. There is no + /// "read-only" filter — no consumer needed it, and adding one + /// bloats the query surface. + pub unread_only: bool, + /// When `Some(t)`, return only rows created strictly BEFORE `t`. + /// Cursor-style pagination for the "load older page" flow: caller + /// passes the oldest `created_at` from the previous page. pub before: Option>, + /// When `Some(t)`, return only rows created strictly AFTER `t`. + /// Delta-catch-up cursor for the "since last seen" flow — used by + /// the FE bell on WS reconnect / tab reactivation to fetch rows + /// that arrived during a disconnect window. Combines with + /// `before` (both applied); combining them semantically bounds + /// the returned range on both sides. + pub after: Option>, } #[async_trait] diff --git a/src/infrastructure/repositories/pg/notification_pg_repository.rs b/src/infrastructure/repositories/pg/notification_pg_repository.rs index ee219496..8db6fd93 100644 --- a/src/infrastructure/repositories/pg/notification_pg_repository.rs +++ b/src/infrastructure/repositories/pg/notification_pg_repository.rs @@ -79,105 +79,36 @@ impl NotificationRepository for NotificationPgRepository { user_id: Uuid, filter: &NotificationListFilter, ) -> Result, DomainError> { - // Dynamic-shape query built to still hit the - // notifications_user_created_read index — every branch keys - // on (user_id, created_at DESC). + // One dynamic query covers every combination of + // (unread_only, before, after). NULL sentinels short-circuit + // the corresponding predicate at planner time, so the + // notifications_user_created_read index still drives the + // scan — the extra `IS NULL` checks are constant-folded. + // + // `before` and `after` combine: passing both bounds the + // returned range on both sides — useful for future + // "paginate a specific window" flows, harmless today when + // callers use one at a time. let limit: i64 = filter.limit.unwrap_or(50).min(500) as i64; - let rows = match (filter.unread_only, filter.before) { - (None, None) => { - sqlx::query( - r#" - SELECT id, user_id, kind, payload, created_at, read_at - FROM notif.notifications - WHERE user_id = $1::uuid - ORDER BY created_at DESC - LIMIT $2 - "#, - ) - .bind(user_id) - .bind(limit) - .fetch_all(self.pool.as_ref()) - .await - } - (Some(true), None) => { - sqlx::query( - r#" - SELECT id, user_id, kind, payload, created_at, read_at - FROM notif.notifications - WHERE user_id = $1::uuid AND read_at IS NULL - ORDER BY created_at DESC - LIMIT $2 - "#, - ) - .bind(user_id) - .bind(limit) - .fetch_all(self.pool.as_ref()) - .await - } - (Some(false), None) => { - sqlx::query( - r#" - SELECT id, user_id, kind, payload, created_at, read_at - FROM notif.notifications - WHERE user_id = $1::uuid AND read_at IS NOT NULL - ORDER BY created_at DESC - LIMIT $2 - "#, - ) - .bind(user_id) - .bind(limit) - .fetch_all(self.pool.as_ref()) - .await - } - (None, Some(before)) => { - sqlx::query( - r#" - SELECT id, user_id, kind, payload, created_at, read_at - FROM notif.notifications - WHERE user_id = $1::uuid AND created_at < $2 - ORDER BY created_at DESC - LIMIT $3 - "#, - ) - .bind(user_id) - .bind(before) - .bind(limit) - .fetch_all(self.pool.as_ref()) - .await - } - (Some(true), Some(before)) => { - sqlx::query( - r#" - SELECT id, user_id, kind, payload, created_at, read_at - FROM notif.notifications - WHERE user_id = $1::uuid AND read_at IS NULL AND created_at < $2 - ORDER BY created_at DESC - LIMIT $3 - "#, - ) - .bind(user_id) - .bind(before) - .bind(limit) - .fetch_all(self.pool.as_ref()) - .await - } - (Some(false), Some(before)) => { - sqlx::query( - r#" - SELECT id, user_id, kind, payload, created_at, read_at - FROM notif.notifications - WHERE user_id = $1::uuid AND read_at IS NOT NULL AND created_at < $2 - ORDER BY created_at DESC - LIMIT $3 - "#, - ) - .bind(user_id) - .bind(before) - .bind(limit) - .fetch_all(self.pool.as_ref()) - .await - } - } + let rows = sqlx::query( + r#" + SELECT id, user_id, kind, payload, created_at, read_at + FROM notif.notifications + WHERE user_id = $1::uuid + AND ($2::bool = FALSE OR read_at IS NULL) + AND ($3::timestamptz IS NULL OR created_at < $3) + AND ($4::timestamptz IS NULL OR created_at > $4) + ORDER BY created_at DESC + LIMIT $5 + "#, + ) + .bind(user_id) + .bind(filter.unread_only) + .bind(filter.before) + .bind(filter.after) + .bind(limit) + .fetch_all(self.pool.as_ref()) + .await .map_err(|e| db_err("list_for_user", e))?; rows.iter().map(Self::map_row).collect() diff --git a/src/interfaces/api/handlers/notifications_handler.rs b/src/interfaces/api/handlers/notifications_handler.rs index 2a9c02b7..f9e9e13a 100644 --- a/src/interfaces/api/handlers/notifications_handler.rs +++ b/src/interfaces/api/handlers/notifications_handler.rs @@ -68,9 +68,15 @@ pub struct ListQuery { /// When `true`, return only unread rows. Default: `false` (both). #[serde(default)] pub unread: bool, - /// Cursor — return rows strictly before this `created_at`. Omit - /// for the newest page. + /// Older-than cursor — return rows strictly BEFORE this + /// `created_at`. Used by the "load older page" pagination flow. + /// Omit for the newest page. pub before: Option>, + /// Newer-than cursor — return rows strictly AFTER this + /// `created_at`. Used by the FE bell on WS reconnect / tab + /// reactivation to catch up on rows that arrived during a + /// disconnect window. Combines with `before` if both are set. + pub after: Option>, /// Max rows returned. Server-side clamp at 500. pub limit: Option, } @@ -101,7 +107,8 @@ pub struct MarkAllReadResponseDto { path = "/api/notifications", params( ("unread" = Option, Query, description = "Only return unread rows"), - ("before" = Option>, Query, description = "Cursor — rows strictly before this created_at"), + ("before" = Option>, Query, description = "Cursor — rows strictly before this created_at (load-older pagination)"), + ("after" = Option>, Query, description = "Cursor — rows strictly after this created_at (delta catch-up on WS reconnect / tab reactivation)"), ("limit" = Option, Query, description = "Max rows (server-side clamp at 500)"), ), responses( @@ -117,8 +124,9 @@ pub async fn list_notifications( ) -> Result, AppError> { let filter = NotificationListFilter { limit: query.limit, - unread_only: if query.unread { Some(true) } else { None }, + unread_only: query.unread, before: query.before, + after: query.after, }; let rows = service.list_for_user(auth_user.id, filter).await?; let unread_count = service.count_unread_for_user(auth_user.id).await?; diff --git a/tests/api/rt_bus_check.sh b/tests/api/rt_bus_check.sh index adb68eac..3ed851f6 100755 --- a/tests/api/rt_bus_check.sh +++ b/tests/api/rt_bus_check.sh @@ -97,6 +97,25 @@ # and delivers events, an admin (or # anyone else) could snoop on other # users' notification streams. +# S16 Notif ?after= cursor — the delta catch-up cursor the FE +# bell hits on WS reconnect / tab +# reactivation. Snapshots the newest +# row's `created_at` as T0, fires a +# fresh share (folder D) that lands +# exactly one new row, and asserts: +# (a) GET ?after=T0 returns exactly +# one row — the folder-D row +# (guards: predicate applied at +# all; silently-dropped param +# would return everything). +# (b) GET ?after= +# returns exactly zero rows +# (guards: bound is strict `>`, +# not `>=` — a `>=` regression +# would break FE `mergeById` +# dedup because rows would come +# in both via WS push and via +# the delta fetch). # # Exit non-zero on any failure — run.sh treats that as a suite failure. # ───────────────────────────────────────────────────────────────────────────── @@ -819,4 +838,74 @@ if ! "$HELPER_BIN" expect-denied \ fi log "S15 OK" -log "All fifteen message-bus scenarios passed." +# ── Scenario 16 — Notifications `?after=` cursor ──────────────────────────── +# The FE bell fires `refreshDelta()` with `?after=` +# on WS reconnect (grace-close return, network reconnect) and on +# rt.event push. `mergeById` dedups client-side; the server-side +# strict-`>` predicate is what keeps the boundary row from +# double-arriving in the first place. This scenario locks both +# invariants at the wire. +# +# Reuses S13's fresh row on folder C as T0. Creates a new folder D +# and grants it → one row lands strictly after T0. Then two +# assertions: `?after=T0` returns EXACTLY that one row (predicate +# applied), and `?after=` returns ZERO rows +# (strict `>` boundary — the row equal to its own cursor is +# excluded, so no duplicate delivery). +log "S16: cursor delta — grant on folder D, expect ?after=T0 returns 1 row, ?after=T1 returns 0." +# T0 = the S13 row's created_at. Pull it fresh via list so this +# scenario stays self-contained; the S13 row is the newest row +# for user2 at this point (S8 grants are older, S13/C is newest). +notifs_pre=$(c_get "$base_url/api/notifications" "$user2_token") +t0=$(printf '%s' "$notifs_pre" | jq -r --arg fc "$folder_c" \ + 'first(.items[] | select(.kind == "share_granted" and .payload.resource_id == $fc)) | .created_at') +[[ -n "$t0" && "$t0" != "null" ]] \ + || { printf '%s\n' "$notifs_pre" >&2; die "S16: could not resolve T0 from folder C row"; } + +# Fresh folder D, grant to user2 → one share_granted row lands. +folder_d=$(c_post "$base_url/api/folders" "$user1_token" \ + "$(printf '{"name":"rt_bus_D_%s","parent_id":"%s"}' "$suffix" "$root_id")" | jq -r '.id') +[[ -n "$folder_d" && "$folder_d" != "null" ]] || die "S16: folder D creation failed" + +grant_d=$(c_post "$base_url/api/grants" "$user1_token" \ + "$(printf '{"subject":{"type":"user","id":"%s"},"resource":{"type":"folder","id":"%s"},"role":"viewer"}' \ + "$user2_id" "$folder_d")") +grant_d_id=$(printf '%s' "$grant_d" | jq -r '.grants[0].id') +[[ -n "$grant_d_id" && "$grant_d_id" != "null" ]] \ + || die "S16: grant on folder D failed: $grant_d" + +# `after` must URL-encode the ISO timestamp — `:` and `+` are +# reserved. curl's `--data-urlencode`/`-G` handles it cleanly. +notifs_after_t0=$(curl -sS -G \ + -H "Accept: application/json" \ + -H "Authorization: Bearer $user2_token" \ + --data-urlencode "after=$t0" \ + "$base_url/api/notifications") +count_after_t0=$(printf '%s' "$notifs_after_t0" | jq -r '.items | length') +[[ "$count_after_t0" == "1" ]] \ + || { printf '%s\n' "$notifs_after_t0" >&2; die "S16: expected 1 row after T0, got $count_after_t0"; } +# The one row MUST be the folder-D row. +d_kind=$(printf '%s' "$notifs_after_t0" | jq -r '.items[0].kind') +d_resource=$(printf '%s' "$notifs_after_t0" | jq -r '.items[0].payload.resource_id') +[[ "$d_kind" == "share_granted" && "$d_resource" == "$folder_d" ]] \ + || { printf '%s\n' "$notifs_after_t0" >&2; die "S16: post-T0 row not folder D (kind=$d_kind resource=$d_resource)"; } + +# T1 = folder-D row's own created_at. Bound is strict `>`, so a +# query at T1 must return zero rows (the boundary row is excluded). +# This is what keeps the FE's mergeById honest — a `>=` regression +# would return the boundary row here, then the WS push would +# deliver it AGAIN, and only client-side dedup would save us. +t1=$(printf '%s' "$notifs_after_t0" | jq -r '.items[0].created_at') +[[ -n "$t1" && "$t1" != "null" ]] || die "S16: could not resolve T1" + +notifs_after_t1=$(curl -sS -G \ + -H "Accept: application/json" \ + -H "Authorization: Bearer $user2_token" \ + --data-urlencode "after=$t1" \ + "$base_url/api/notifications") +count_after_t1=$(printf '%s' "$notifs_after_t1" | jq -r '.items | length') +[[ "$count_after_t1" == "0" ]] \ + || { printf '%s\n' "$notifs_after_t1" >&2; die "S16: expected 0 rows after T1 (strict '>' bound), got $count_after_t1"; } +log "S16 OK" + +log "All sixteen message-bus scenarios passed."