diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a19b1ea..af9dcc55 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 }} + message_bus_spec: ${{ steps.filter.outputs.message_bus_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/**' + message_bus_spec: + - 'src/application/ports/message_bus_ports.rs' + - 'src/bin/generate-asyncapi.rs' + - 'resources/gen/asyncapi.json' + - 'frontend/scripts/gen-message-bus-types.mjs' + - 'frontend/src/lib/generated/message-bus/**' + - '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 (`message_bus_ports.rs`), and the TS DTOs are + # a compile-time artefact of the spec, so both must be reproducible. + # + # 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. + message-bus-spec-drift: + name: Message-bus spec — AsyncAPI + TypeScript DTO drift + needs: changes + if: needs.changes.outputs.message_bus_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/message-bus/` 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: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- + # 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/message-bus/; then + echo "" + 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 + 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/Cargo.lock b/Cargo.lock index 1dd449b5..7590cfee 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]] @@ -4712,6 +4721,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", + "tokio-tungstenite", "tokio-util", "toml 1.1.2+spec-1.1.0", "tower", @@ -5265,7 +5275,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 +5312,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -5820,7 +5830,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6343,7 +6353,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 +6865,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 +7096,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 +7385,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 +7578,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 +8368,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..ecf09c4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,12 +25,16 @@ 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"] } 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`/`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"] + [[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 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/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 e261767e..f3f6f727 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,9 +116,11 @@ 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. | +| `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) @@ -355,6 +356,17 @@ 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 + +| Variable | Default | Description | +|---|---|---| +| `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 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 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 new file mode 100644 index 00000000..4525f1d4 --- /dev/null +++ b/docs/plan/message-bus.md @@ -0,0 +1,1437 @@ +# Plan — 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 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 +almost no extra scaffolding. + +## Status — 2026-09-11 + +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 + `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`), + 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: + +- **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: + +- **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 + +- 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, MessageBusEvent) + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ MESSAGE BUS (MessageBus trait — application/ports) │ +│ │ +│ InProcessMessageBus (v1) │ +│ DashMap> │ +│ │ +└──────┬───────────────────────────────────────────────────────────────┘ + │ + │ ┌──────────────────────────────────────────────────────┐ + │ │ REPLICATOR (optional, feature-flagged) │ + │ │ │ + │ │ BusReplicator trait ── separate port │ + │ │ - v1: NoopReplicator (single-instance) │ + │ │ - v2: PgListenReplicator (pg_notify) │ + │ │ - v3: BrokerReplicator (RabbitMQ / NATS) │ + │ │ │ + │ │ Sits BESIDE InProcessMessageBus, forwards │ + │ │ local publishes outbound + inbound events │ + │ │ from the broker back into local publish. │ + │ └──────────────────────────────────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ WS HANDLER (interfaces/api/handlers/rt_ws.rs) │ +│ │ +│ 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 │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +**The seam that keeps RabbitMQ/NATS doors open is the replicator, not +the bus.** Services and the WS handler only ever see the local +`MessageBus`. A future `BrokerReplicator` publishes outbound + injects +inbound. Zero touch to callers. + +## Backend components + +### 1. Port + event types (`application/ports/message_bus_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 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 }, + 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 MessageBus: Send + Sync { + /// Fire-and-forget. SYNC (not async) — services must not await + /// under a DB transaction. + 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>>; +} + +/// 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: &MessageBusEvent); + + /// 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. `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)`: + +``` +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_message_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`. +- `InProcessMessageBus::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 `BusSession` 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 = "message_bus.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/message-bus/client.svelte.ts`) + +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 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. Composables + +```ts +// 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 +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.*` — 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) + +```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 `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 + 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`, `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). +- 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` 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. + +### 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-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 + 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 + +"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 = "message_bus.subscribe_denied"`, `reason` + from the audit column above, plus `caller_id`, `topic`. Emitted + BEFORE the wire `denied` frame. +- **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"`, + `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 + +- `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 + 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 — eleven scenarios, all green + +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) + +``` +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 +`message_bus.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** + `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** — 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`, + `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. 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 + +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; + 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 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). + +#### Infrastructure payoff (formerly "Phase C") + +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. + +- **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. + +#### Multi-instance & broker + +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 `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 + 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/example.env b/example.env index 649443e5..90773277 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 @@ -409,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 @@ -464,6 +472,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. @@ -1093,6 +1132,48 @@ 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. + +# 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 + +# 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/AGENTS.md b/frontend/AGENTS.md index 4ff903a0..cc478099 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -9,3 +9,78 @@ 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: + +- 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), `$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/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..1cf4c204 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:message-bus": "node scripts/gen-message-bus-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-message-bus-types.mjs b/frontend/scripts/gen-message-bus-types.mjs new file mode 100644 index 00000000..1d763aa9 --- /dev/null +++ b/frontend/scripts/gen-message-bus-types.mjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node +// 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`) +// 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: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/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. +// * 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: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/message-bus'); + +// 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-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.` + ); + 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-message-bus-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-message-bus-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-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` + + `re-run \`just asyncapi-ts\`.` + ); + process.exit(1); +} + +console.log( + `gen-message-bus-types: wrote ${models.length} model(s) to ${outputDir}` + + ` (rewrote ${rewritten} for verbatimModuleSyntax, 0 AnonymousSchema,` + + ` prettier-formatted)` +); diff --git a/frontend/src/hooks.client.ts b/frontend/src/hooks.client.ts index 5329bb23..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,9 @@ 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`), +// `oxi:config` (server-config boot fetch). +// Levels: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent'. // Choices persist to `localStorage['loglevel:']` via loglevel. // // oxi.setLogLevel('oxi:upload', 'debug') // deep dive @@ -118,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/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/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/endpoints/notifications.ts b/frontend/src/lib/api/endpoints/notifications.ts new file mode 100644 index 00000000..57a2edea --- /dev/null +++ b/frontend/src/lib/api/endpoints/notifications.ts @@ -0,0 +1,87 @@ +/** + * 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. 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}` : ''}`); +} + +/** 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 4758bf7c..d8526160 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -878,3 +878,96 @@ 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; + // 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; + 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; +} + +// ─── 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/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/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index 74f21ad1..f40016ac 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'; @@ -25,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'; @@ -74,68 +79,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'); @@ -313,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()); @@ -788,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}
@@ -813,7 +915,7 @@ {/if}
- {#if ui.notifications.length === 0} + {#if ui.notifications.length === 0 && persistentNotifications.items.length === 0}
{t('notifications.empty', 'No notifications')} @@ -855,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}
@@ -1281,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/useFolderTopic.svelte.ts b/frontend/src/lib/composables/useFolderTopic.svelte.ts new file mode 100644 index 00000000..9193fb56 --- /dev/null +++ b/frontend/src/lib/composables/useFolderTopic.svelte.ts @@ -0,0 +1,105 @@ +// 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 { 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'; +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; + /** 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; +} + +/** + * 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); + useReconnect(handlers.onReconnect); +} + +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/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/composables/useNotifications.svelte.ts b/frontend/src/lib/composables/useNotifications.svelte.ts new file mode 100644 index 00000000..4a388e0a --- /dev/null +++ b/frontend/src/lib/composables/useNotifications.svelte.ts @@ -0,0 +1,299 @@ +/** + * 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 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'; +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'); + +/** + * 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; + } + get unread(): number { + return this.#unread; + } + get loading(): boolean { + return this.#loading; + } + get error(): string | null { + return this.#error; + } + + /** + * 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; + try { + 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); + bellLog.warn('notifications refresh failed', e); + } finally { + this.#loading = false; + } + } + + /** + * 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 { + 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.#lastReceivedAt = null; + 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 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 (`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(() => { + 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. 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(); + } + }, + () => { + // Server-evicted (session flipped) — clear local so + // the badge stops showing stale count. + notifications.reset(); + } + ); + + const releaseReconnect = messageBus.onReconnect(() => { + // 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 () => { + release(); + releaseReconnect(); + }; + }); +} 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/frontend/src/lib/composables/useReconnect.svelte.ts b/frontend/src/lib/composables/useReconnect.svelte.ts new file mode 100644 index 00000000..2d0eb200 --- /dev/null +++ b/frontend/src/lib/composables/useReconnect.svelte.ts @@ -0,0 +1,31 @@ +// 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'; +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 || !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 new file mode 100644 index 00000000..cf0167ef --- /dev/null +++ b/frontend/src/lib/composables/useTopic.svelte.ts @@ -0,0 +1,48 @@ +// 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 { serverConfig } from '$lib/stores/serverConfig.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(() => { + // 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); + return () => release(); + }); +} diff --git a/frontend/src/lib/generated/message-bus/FileCreatedData.ts b/frontend/src/lib/generated/message-bus/FileCreatedData.ts new file mode 100644 index 00000000..88ec8c38 --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/FileDeletedData.ts b/frontend/src/lib/generated/message-bus/FileDeletedData.ts new file mode 100644 index 00000000..3b35817c --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/FileMovedData.ts b/frontend/src/lib/generated/message-bus/FileMovedData.ts new file mode 100644 index 00000000..fcabd6ac --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/FileRenamedData.ts b/frontend/src/lib/generated/message-bus/FileRenamedData.ts new file mode 100644 index 00000000..1680a56f --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/Folder.ts b/frontend/src/lib/generated/message-bus/Folder.ts new file mode 100644 index 00000000..516d2836 --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/FolderCreatedData.ts b/frontend/src/lib/generated/message-bus/FolderCreatedData.ts new file mode 100644 index 00000000..d01f7acf --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/FolderDeletedData.ts b/frontend/src/lib/generated/message-bus/FolderDeletedData.ts new file mode 100644 index 00000000..f41ea5e6 --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/FolderMovedData.ts b/frontend/src/lib/generated/message-bus/FolderMovedData.ts new file mode 100644 index 00000000..84d086b2 --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/FolderRenamedData.ts b/frontend/src/lib/generated/message-bus/FolderRenamedData.ts new file mode 100644 index 00000000..fd71db4c --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/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/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/RtErrorMessage.ts b/frontend/src/lib/generated/message-bus/RtErrorMessage.ts new file mode 100644 index 00000000..69bb4a7b --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/RtErrorObject.ts b/frontend/src/lib/generated/message-bus/RtErrorObject.ts new file mode 100644 index 00000000..6c4b6eb2 --- /dev/null +++ b/frontend/src/lib/generated/message-bus/RtErrorObject.ts @@ -0,0 +1,9 @@ +import type RtErrorMessage from './RtErrorMessage'; +// AUTO-GENERATED — do not edit by hand. +// Regenerate with `just asyncapi-ts`. +interface RtErrorObject { + code: number; + data?: unknown; + message: RtErrorMessage; +} +export type { RtErrorObject as default }; diff --git a/frontend/src/lib/generated/message-bus/RtErrorResponseBody.ts b/frontend/src/lib/generated/message-bus/RtErrorResponseBody.ts new file mode 100644 index 00000000..d03d4832 --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/RtEventKind.ts b/frontend/src/lib/generated/message-bus/RtEventKind.ts new file mode 100644 index 00000000..df4aaab7 --- /dev/null +++ b/frontend/src/lib/generated/message-bus/RtEventKind.ts @@ -0,0 +1,15 @@ +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', + NOTIFICATION_RECEIVED = 'notification_received', + 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 new file mode 100644 index 00000000..5c485a8f --- /dev/null +++ b/frontend/src/lib/generated/message-bus/RtEventParams.ts @@ -0,0 +1,33 @@ +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 NotificationReceivedData from './NotificationReceivedData'; +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`. +interface RtEventParams { + data: + | FileCreatedData + | FileRenamedData + | FileMovedData + | FileDeletedData + | FolderCreatedData + | FolderRenamedData + | FolderMovedData + | FolderDeletedData + | NotificationReceivedData + | JobRunStartedData + | JobRunProgressData + | JobRunEndedData; + event: RtEventKind; + topic: string; +} +export type { RtEventParams as default }; diff --git a/frontend/src/lib/generated/message-bus/RtFolderEventBody.ts b/frontend/src/lib/generated/message-bus/RtFolderEventBody.ts new file mode 100644 index 00000000..e3c7ff9e --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/RtPingRequestBody.ts b/frontend/src/lib/generated/message-bus/RtPingRequestBody.ts new file mode 100644 index 00000000..38ffcf55 --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/RtPongResponseBody.ts b/frontend/src/lib/generated/message-bus/RtPongResponseBody.ts new file mode 100644 index 00000000..0380a3d5 --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/RtPongResult.ts b/frontend/src/lib/generated/message-bus/RtPongResult.ts new file mode 100644 index 00000000..7bb50eed --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/RtRevokedBody.ts b/frontend/src/lib/generated/message-bus/RtRevokedBody.ts new file mode 100644 index 00000000..15c2c177 --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/RtRevokedParams.ts b/frontend/src/lib/generated/message-bus/RtRevokedParams.ts new file mode 100644 index 00000000..fcec1c4a --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/RtRevokedReason.ts b/frontend/src/lib/generated/message-bus/RtRevokedReason.ts new file mode 100644 index 00000000..555ff99e --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/RtSubscribeParams.ts b/frontend/src/lib/generated/message-bus/RtSubscribeParams.ts new file mode 100644 index 00000000..810d1017 --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/RtSubscribeRequestBody.ts b/frontend/src/lib/generated/message-bus/RtSubscribeRequestBody.ts new file mode 100644 index 00000000..1d8ef139 --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/RtSuccessResponseBody.ts b/frontend/src/lib/generated/message-bus/RtSuccessResponseBody.ts new file mode 100644 index 00000000..69515b50 --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/RtUnsubscribeParams.ts b/frontend/src/lib/generated/message-bus/RtUnsubscribeParams.ts new file mode 100644 index 00000000..c8c83391 --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/message-bus/RtUnsubscribeRequestBody.ts b/frontend/src/lib/generated/message-bus/RtUnsubscribeRequestBody.ts new file mode 100644 index 00000000..6e67c2ae --- /dev/null +++ b/frontend/src/lib/generated/message-bus/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/frontend/src/lib/message-bus/client.svelte.ts b/frontend/src/lib/message-bus/client.svelte.ts new file mode 100644 index 00000000..c35f4f9d --- /dev/null +++ b/frontend/src/lib/message-bus/client.svelte.ts @@ -0,0 +1,754 @@ +// 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 { untrack } from 'svelte'; + +import { apiJson } from '$lib/api/client'; +import { getCsrfHeaders } from '$lib/api/csrf'; +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'; + +/** 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'); + +/** 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; + +/** 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 + * 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; + +/** 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. 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; + +/** 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; + 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; + /** 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; + /** 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(); + /** 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 + * ⇒ 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; + + // 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); + } + } + + /** + * 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`). + * + * 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 { + 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)); + }; + }); + } + + /** + * 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 + * 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); + } + + /** 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.#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; + } + this.state = 'idle'; + this.#subs.clear(); + 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 { + 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, [subprotocol]); + } 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); + } + + /** 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; + // 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. + for (const [topic, entry] of this.#subs) { + entry.acked = false; + this.#sendSubscribe(topic).catch((err) => + 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 { + 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; + } + // 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); + } 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(); + // 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 { + 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, + consecutiveFailures: this.#consecutiveFailures + }); + 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/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/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/lib/stores/serverConfig.svelte.ts b/frontend/src/lib/stores/serverConfig.svelte.ts new file mode 100644 index 00000000..29362bc2 --- /dev/null +++ b/frontend/src/lib/stores/serverConfig.svelte.ts @@ -0,0 +1,91 @@ +/** + * 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, + 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/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/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index e4d9056f..c68e1750 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,92 @@ } } + // ── Live folder updates (message bus) ──────────────────────────── + // 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 { + 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) => { + // 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 + // 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')); + }, + 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. See `project_message_bus_reconnect_gap` memory. + busLog.warn('reconnected — refetching folder'); + scheduleLiveReload('reconnect'); + } + }); + function openFolder(folder: FolderItem) { // Canonical single-id URL. Legacy `/files/A/B/C` still resolves // (canonicalize-on-load rewrites it inside `load()`), but new 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/justfile b/justfile index fe306d35..1608d376 100644 --- a/justfile +++ b/justfile @@ -194,6 +194,51 @@ 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`, `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 + +# 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/message-bus/`; consumers +# import from there but never edit those files. +asyncapi-ts: asyncapi + cd frontend && npm run gen:message-bus + +# 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-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/message-bus/; then + echo "" + 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/message-bus/" + echo " git commit -m 'chore(bus): regenerate spec + DTOs'" + exit 1 + fi + echo "✅ message-bus spec: committed files match generator output" + db: docker compose up -d postgres @@ -321,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/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. +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. @@ -341,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/message-bus` 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. @@ -389,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-message-bus-spec test test-integration fe-test build test-bundle test-api fe-build-e2e front-test 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/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/application/ports/message_bus_ports.rs b/src/application/ports/message_bus_ports.rs new file mode 100644 index 00000000..84e5ab9e --- /dev/null +++ b/src/application/ports/message_bus_ports.rs @@ -0,0 +1,833 @@ +//! Message-bus port — the seam every service publishes through and every WS +//! session subscribes on. +//! +//! # Design (see `docs/plan/message-bus.md`) +//! +//! - [`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 +//! [`MessageBus`]; 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: [`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}`, +//! `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 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 +/// topic-exchange routing keys or NATS subjects when the [`BusReplicator`] +/// seam is filled in later. +#[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. + 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), + + /// 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…) + /// 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 { + /// 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"), + Topic::UserNotifications(id) => format!("user:{id}:notifications"), + Topic::Job(name) => format!("job:{name}"), + } + } + + /// 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, suffix)) = rest.rsplit_once(':') + { + let id = Uuid::parse_str(id_str).map_err(|_| ParseTopicErr::BadUuid)?; + 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 + // `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) + } + + /// 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 }, + Topic::UserNotifications(id) => AuthzCheck::IdentityMatch { user_id: *id }, + Topic::Job(_) => AuthzCheck::RoleAdmin, + } + } +} + +/// 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. 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, +} + +// ════════════════════════════════════════════════════════════════════════════ +// MessageBusEvent — 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 MessageBusEvent { + /// A file was created inside `parent_id`. + FileCreated { + file_id: Uuid, + name: String, + 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, + name: String, + 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, + }, + /// 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 }, + + /// 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 + /// 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, + }, +} + +// ════════════════════════════════════════════════════════════════════════════ +// 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; +} + +// ════════════════════════════════════════════════════════════════════════════ +// MessageBus — 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 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: MessageBusEvent); + + /// 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 [`MessageBus::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 [`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. +/// +/// 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: &MessageBusEvent); + + /// 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: &MessageBusEvent) { + // 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 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()); + 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!( + 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 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" + ); + } + + #[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 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 + // the `rt.event` JSON-RPC notification's `params.event` field. Pin + // 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: &[(MessageBusEvent, &str)] = &[ + ( + MessageBusEvent::FileCreated { + file_id: Uuid::nil(), + name: "notes.md".into(), + parent_id: Uuid::nil(), + actor: Uuid::nil(), + }, + "file_created", + ), + ( + MessageBusEvent::FileRenamed { + file_id: Uuid::nil(), + old_name: "a.md".into(), + new_name: "b.md".into(), + parent_id: Uuid::nil(), + actor: Uuid::nil(), + }, + "file_renamed", + ), + ( + MessageBusEvent::FileMoved { + file_id: Uuid::nil(), + name: "a.md".into(), + from: Uuid::nil(), + to: Uuid::nil(), + actor: Uuid::nil(), + }, + "file_moved", + ), + ( + MessageBusEvent::FileDeleted { + file_id: Uuid::nil(), + parent_id: Uuid::nil(), + actor: Uuid::nil(), + }, + "file_deleted", + ), + ( + MessageBusEvent::FolderCreated { + folder_id: Uuid::nil(), + name: "docs".into(), + parent_id: Uuid::nil(), + actor: Uuid::nil(), + }, + "folder_created", + ), + ( + MessageBusEvent::FolderRenamed { + folder_id: Uuid::nil(), + old_name: "old".into(), + new_name: "new".into(), + parent_id: Uuid::nil(), + actor: Uuid::nil(), + }, + "folder_renamed", + ), + ( + MessageBusEvent::FolderMoved { + folder_id: Uuid::nil(), + name: "docs".into(), + from: Uuid::nil(), + to: Uuid::nil(), + actor: Uuid::nil(), + }, + "folder_moved", + ), + ( + MessageBusEvent::FolderDeleted { + folder_id: Uuid::nil(), + parent_id: Uuid::nil(), + actor: Uuid::nil(), + }, + "folder_deleted", + ), + ( + MessageBusEvent::AuthzChanged { + affected_folders: vec![Uuid::nil()], + }, + "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(), + 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(); + assert_eq!( + json["event"], *expected, + "wire discriminator mismatch for {ev:?}" + ); + } + } + + #[test] + fn event_roundtrip() { + let file_id = Uuid::new_v4(); + let parent_id = Uuid::new_v4(); + let actor = Uuid::new_v4(); + let original = MessageBusEvent::FileCreated { + file_id, + name: "a.txt".into(), + parent_id, + actor, + }; + let json = serde_json::to_string(&original).unwrap(); + let decoded: MessageBusEvent = 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()), + &MessageBusEvent::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/ports/mod.rs b/src/application/ports/mod.rs index 53c8e8db..3a2496c7 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -18,6 +18,7 @@ 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; diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 95352b55..b004e294 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>, + /// 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>, + /// 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 message bus. When set, delete / rename / move + /// mutations publish on the affected folder topics after commit. + pub fn with_message_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 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`). + /// + /// 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::message_bus_ports::{MessageBusEvent, Topic}; + bus.publish( + &Topic::Folder(parent_uuid), + MessageBusEvent::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?; + // 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 + // 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::message_bus_ports::{MessageBusEvent, Topic}; + let event = MessageBusEvent::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?; + + // 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)) = + (&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::message_bus_ports::{MessageBusEvent, Topic}; + bus.publish( + &Topic::Folder(parent_uuid), + MessageBusEvent::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/file_upload_service.rs b/src/application/services/file_upload_service.rs index 9b26acb7..1195f73e 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, + /// 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 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_message_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); + + // 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). + 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::message_bus_ports::{MessageBusEvent, Topic}; + bus.publish( + &Topic::Folder(parent_uuid), + MessageBusEvent::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..93c4bca2 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>, + /// 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 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_message_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::message_bus_ports::{MessageBusEvent, Topic}; + bus.publish( + &Topic::Folder(parent_uuid), + MessageBusEvent::FolderCreated { + folder_id: folder_uuid, + name: folder.name().to_owned(), + parent_id: parent_uuid, + actor: caller_id, + }, + ); + } + Ok(FolderDto::from(folder)) } @@ -765,6 +813,28 @@ impl FolderUseCase for FolderService { drive_repo.invalidate_default_drive_all(); } + // 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 + // 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::message_bus_ports::{MessageBusEvent, Topic}; + bus.publish( + &Topic::Folder(parent_uuid), + MessageBusEvent::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)) } @@ -890,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 @@ -902,6 +984,29 @@ impl FolderUseCase for FolderService { ) })?; + // 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 + // 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::message_bus_ports::{MessageBusEvent, Topic}; + let event = MessageBusEvent::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) @@ -980,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", @@ -991,6 +1106,22 @@ impl FolderUseCase for FolderService { self.file_lifecycle.on_file_deleted(file_id); } + // 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::message_bus_ports::{MessageBusEvent, Topic}; + bus.publish( + &Topic::Folder(parent_uuid), + MessageBusEvent::FolderDeleted { + folder_id: folder_uuid, + parent_id: parent_uuid, + actor: caller_id, + }, + ); + } + Ok(()) } } 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/application/services/trash_service.rs b/src/application/services/trash_service.rs index 0294ed16..0a62cb51 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,48 @@ 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={} (2 topics)", + folder_id, parent_uuid, 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={:?}", + 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 new file mode 100644 index 00000000..ef3c5f19 --- /dev/null +++ b/src/bin/generate-asyncapi.rs @@ -0,0 +1,856 @@ +//! 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`, `MessageBusEvent`, 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::message_bus_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 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" }, + }, + // 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 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", + "default": "cloud.example.com", + }, + }, + "protocolVersion": "13", + // 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.ticket.{ticket}" } + }, + // 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/ticketAuth" } + ], + } + }, + "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" }, + "PongResponse": { "$ref": "#/components/messages/RtPongResponse" }, + "SubscribedResponse": { "$ref": "#/components/messages/RtSubscribedResponse" }, + "ErrorResponse": { "$ref": "#/components/messages/RtErrorResponse" }, + "FolderEvent": { "$ref": "#/components/messages/RtFolderEventNotification" }, + "RevokedNotification": { "$ref": "#/components/messages/RtRevokedNotification" }, + } + }, + "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" }, + } + }, + "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.", + "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" }, + } + } + }) +} + +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" } + ] + }, + "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_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 + // 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" } + ] + } + } + }) +} + +fn components() -> Value { + let mut components = 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" }, + }, + "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", + "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": { + // 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(), + "FileDeletedData": file_deleted_schema(), + "FolderCreatedData": folder_created_schema(), + "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(), + }, + // 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. 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`.", + } + } + }); + + // 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 ──────────────────────────────────────────────────────── + +/// `$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": properties, + }) +} + +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"] }, + // 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. `result` is hoisted to +/// [`RtPongResult`] so Modelina gets a named type. +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": ref_schema("RtPongResult"), + } + }) +} + +fn rpc_pong_result_schema() -> Value { + json!({ + "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 + // `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!({ + "type": "object", + "required": ["jsonrpc", "id", "error"], + "properties": { + "jsonrpc": { "type": "string", "const": "2.0" }, + "id": { "type": ["integer", "string", "null"] }, + "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 { + // 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": full_description, + }) +} + +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\"`. `params` is hoisted to `RtEventParams`.", + "required": ["jsonrpc", "method", "params"], + "properties": { + "jsonrpc": { "type": "string", "const": "2.0" }, + "method": { "type": "string", "const": "rt.event" }, + "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 `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", + "notification_received", + "job_run_started", "job_run_progress", "job_run_ended", + ], + }) +} + +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"), + ref_schema("NotificationReceivedData"), + ref_schema("JobRunStartedData"), + ref_schema("JobRunProgressData"), + ref_schema("JobRunEndedData"), + ] + }) +} + +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 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", + "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" }, + } + }) +} + +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" }, + } + }) +} + +// ─────────────────── 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 +// 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 +/// 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\"`. `params` hoisted to `RtRevokedParams`.", + "required": ["jsonrpc", "method", "params"], + "properties": { + "jsonrpc": { "type": "string", "const": "2.0" }, + "method": { "type": "string", "const": "rt.revoked" }, + "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", + ] + }) +} diff --git a/src/bin/opaque-hurl-helper.rs b/src/bin/opaque-hurl-helper.rs index b7f2d974..de18f0ce 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 WebSocket ───────────── + // + // 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..db9d2170 --- /dev/null +++ b/src/bin/rt-hurl-helper.rs @@ -0,0 +1,520 @@ +//! 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: +//! 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, + /// 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 { + 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 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 + .next() + .ok_or_else(|| format!("flag {flag} requires a value"))?; + 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( + value + .parse::() + .map_err(|_| format!("--expect-events not a number: {value}"))?, + ); + } + "--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")?, + auth, + subscribe, + expect_events, + reason, + timeout, + output, + ready_file, + }) +} + +// ════════════════════════════════════════════════════════════════════════════ +// 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 [`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, + auth: &WsAuth, +) -> Result< + tokio_tungstenite::WebSocketStream>, + HelperError, +> { + let mut req = url + .into_client_request() + .map_err(|e| HelperError::Protocol(format!("bad url: {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}")))?; + 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.auth).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(); + // 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 + // the frame. + let mut pings_received: usize = 0; + 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 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}")))?; + + // 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); + } + // 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; + } + + // Notification (id-less)? + let method = value.get("method").and_then(|v| v.as_str()).unwrap_or(""); + 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. + } + } + } + + // 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, + "revoked": revoked, + "pings_received": pings_received, + "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.auth).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/src/common/config.rs b/src/common/config.rs index f006fb99..c094e294 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, @@ -2284,6 +2283,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 @@ -2297,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 @@ -2468,8 +2493,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 @@ -2483,7 +2507,9 @@ 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(), + notifications_retention_days: 30, // 30 days is the plan's default } } } @@ -3337,13 +3363,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 @@ -3357,6 +3376,28 @@ 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; + } + + // 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 d65377de..f2cff698 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()) + // Bus fan-out on `create_folder_with_perms` — the + // parent-folder subscribers see new sub-folders live. + .with_message_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(), - ); + ) + // 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_message_bus(bus_trait.clone()); if let Some(hook) = resource_access_hook.clone() { svc = svc.with_resource_access_hook(hook); } @@ -811,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()) + // 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_message_bus(bus_trait.clone()); if let Some(hook) = resource_access_hook.clone() { svc = svc.with_resource_access_hook(hook); } @@ -1022,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"); @@ -1030,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(), @@ -1041,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 @@ -1723,9 +1749,40 @@ 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), + ); + + // 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 + // 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 @@ -1786,6 +1843,7 @@ impl AppServiceFactory { plugin_dispatch.clone(), mount_router.clone(), Some(resource_access_hook.clone()), + &bus, ); // 5. Share service @@ -2284,6 +2342,9 @@ impl AppServiceFactory { db_pool: Some(pool.clone()), maintenance_pool: Some(maintenance_pool), 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, @@ -2357,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 @@ -2475,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 @@ -3177,6 +3275,39 @@ pub struct AppState { /// method (which still owns the authorization check). pub mount_router: Arc, + /// 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_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, + >, + /// 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 @@ -3347,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..727b69d5 --- /dev/null +++ b/src/domain/repositories/notification_repository.rs @@ -0,0 +1,99 @@ +//! 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 — 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 caps defensively at 500 so a runaway caller can't drag + /// the DB. + pub limit: Option, + /// `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] +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..8db6fd93 --- /dev/null +++ b/src/infrastructure/repositories/pg/notification_pg_repository.rs @@ -0,0 +1,212 @@ +//! 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> { + // 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 = 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() + } + + 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/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 new file mode 100644 index 00000000..a5646d73 --- /dev/null +++ b/src/infrastructure/services/in_process_message_bus.rs @@ -0,0 +1,398 @@ +//! 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`]. +//! +//! # 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::message_bus_ports::{ + BusReplicator, BusStream, MessageBus, MessageBusEvent, 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 [`MessageBus`]. +/// +/// Callers hold `Arc` (or `Arc`). +/// The struct owns its topic map and — when constructed via +/// [`InProcessMessageBus::with_replicator`] — an [`Arc`] +/// that gets fed every local publish for outbound broker forwarding. +pub struct InProcessMessageBus { + topics: DashMap>, + replicator: Arc, +} + +impl InProcessMessageBus { + /// Construct with a replicator. In v1 that's a + /// [`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 + /// 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 { + // `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.clone()) + .or_insert_with(|| broadcast::channel(BROADCAST_RING_CAPACITY).0) + .clone() + } +} + +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 + // 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. + 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() })) + } +} + +/// 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", + MessageBusEvent::NotificationReceived { .. } => "notification_received", + MessageBusEvent::JobRunStarted { .. } => "job_run_started", + MessageBusEvent::JobRunProgress { .. } => "job_run_progress", + MessageBusEvent::JobRunEnded { .. } => "job_run_ended", + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// Tests +// ════════════════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + 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 { + InProcessMessageBus::with_replicator(Arc::new(NoopReplicator)) + } + + fn folder_topic() -> Topic { + Topic::Folder(Uuid::new_v4()) + } + + fn file_created(parent_id: Uuid) -> MessageBusEvent { + MessageBusEvent::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: &MessageBusEvent) { + 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 = InProcessMessageBus::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..64a60d55 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_message_bus; pub mod jwt_service; pub mod last_seen_tracker; pub mod local_blob_backend; @@ -38,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; @@ -51,6 +53,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/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/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/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 new file mode 100644 index 00000000..798f600e --- /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, + // 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`. + 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, + 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/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index feb6f17e..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 @@ -504,6 +569,26 @@ pub async fn revoke_grant( self_revoke = (granter == caller_id), "🗑️ grant revoked", ); + + // 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 + // 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::message_bus_ports::{MessageBus, MessageBusEvent, Topic}; + MessageBus::publish( + state.bus.as_ref(), + &Topic::UserAuthz(target_user), + MessageBusEvent::AuthzChanged { + affected_folders: vec![folder_id], + }, + ); + } + StatusCode::NO_CONTENT.into_response() } diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index a6a945cf..875754a1 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; @@ -19,10 +20,13 @@ 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; pub mod recent_handler; +pub mod rt_ticket_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/notifications_handler.rs b/src/interfaces/api/handlers/notifications_handler.rs new file mode 100644 index 00000000..f9e9e13a --- /dev/null +++ b/src/interfaces/api/handlers/notifications_handler.rs @@ -0,0 +1,230 @@ +//! `/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, + /// 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, +} + +#[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 (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( + (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: 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?; + 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_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 new file mode 100644 index 00000000..a56f1d66 --- /dev/null +++ b/src/interfaces/api/handlers/rt_ws.rs @@ -0,0 +1,970 @@ +//! Message 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 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 +//! +//! 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 std::time::Duration; + +use axum::body::Bytes; +use axum::extract::State; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use futures::StreamExt; +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::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::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 +/// 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; + +/// 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_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; + +/// 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_MESSAGEBUS_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 +// ════════════════════════════════════════════════════════════════════════════ + +/// 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. Mounted outside the standard +/// `/api/*` middleware stack; self-authenticates via ticket +/// subprotocol OR bearer token (see the module doc). +/// +/// 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, + 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 +// ════════════════════════════════════════════════════════════════════════════ + +/// 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(); + } +} + +/// 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 [`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. +enum SessionOut { + Frame(String), + EvictFolders(Vec), +} + +/// 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)); + + // Snapshot the caller's role ONCE per session, so the Class-3 + // (`RoleAdmin`) AuthZ dispatch inside `handle_subscribe` doesn't + // pay a DB hop on every subscribe frame. `resolve_live_role` + // honours the short-TTL flags cache, and a demotion mid-session + // takes effect on the NEXT reconnect (bounded by + // USER_FLAGS_CACHE_TTL for the flags read at that point). If + // the auth service isn't wired (unusual test config) or the + // account is revoked, treat as non-admin — fail-closed for + // admin gates. Passing "user" as the claim role is fail-open + // for `resolve_live_role`'s non-admin fallback path. + let caller_role: String = match state.auth_service.as_ref() { + Some(auth) => { + 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 + // 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); + + // Auto-subscribe to the caller's private notifications topic — + // same identity-scoped invariant as `:authz`. Events on this + // stream (`MessageBusEvent::NotificationReceived`) forward + // through as an `rt.event` notification so the FE bell can flip + // its unread badge without a poll. The DB row is the truth (see + // `docs/plan/message-bus.md § Slice E`); a missed push recovers + // on the next `GET /api/notifications`. + install_subscription( + Topic::UserNotifications(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 + // (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 + // doesn't get overtaken by a control-frame handshake. + biased; + + outbound = out_rx.recv() => { + match outbound { + 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 + } + } + + _ = keepalive.tick() => { + // 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))) => { + if let Some(reply) = + handle_text_frame(&txt, caller_id, &caller_role, &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(_))) => { + // 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, + } + } + } + } + + // 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, + caller_role: &str, + 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, 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( + 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, + caller_role: &str, + 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 })), + ); + } + } + 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 + // that forwards bus events to the outbound channel as `rt.event` + // notifications. + install_subscription(topic, subs, out_tx, state); + + 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 })) +} + +// ════════════════════════════════════════════════════════════════════════════ +// 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 `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 +/// 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( + topic: Topic, + subs: &mut HashMap, + out_tx: &mpsc::Sender, + state: &Arc, +) { + let topic_wire = topic.to_wire_key(); + 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`. + 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 { + MessageBusEvent::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 +// ════════════════════════════════════════════════════════════════════════════ + +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 `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: &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("MessageBusEvent 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 `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). +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) +} + +/// 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 +// ════════════════════════════════════════════════════════════════════════════ + +fn audit_denied(caller_id: Uuid, topic: &str, reason: &'static str) { + tracing::info!( + target: "audit", + event = "message_bus.subscribe_denied", + reason = reason, + caller_id = %caller_id, + topic = %topic, + "👮🏻‍♂️ message-bus subscribe rejected", + ); +} + +/// 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 = "message_bus.subscription_evicted", + reason = reason, + caller_id = %caller_id, + topic = %topic, + "🚫 message-bus subscription evicted", + ); +} + +// ════════════════════════════════════════════════════════════════════════════ +// 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 = MessageBusEvent::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/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 10ad9281..d402460c 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> { 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. @@ -399,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. @@ -445,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 { @@ -652,7 +683,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, )) @@ -674,6 +705,30 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { .with_state(app_state.clone()); router = router.nest("/users", users_router); + // 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`. + // + // 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 + // `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/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 792cfa12..d5f4b041 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1056,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 new file mode 100755 index 00000000..3ed851f6 --- /dev/null +++ b/tests/api/rt_bus_check.sh @@ -0,0 +1,911 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# 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`). +# This script orchestrates it against a live oxicloud server: bootstraps +# state with curl, exercises the bus, asserts on the helper's JSON output. +# +# 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. +# 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. +# 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. +# 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. +# 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. +# 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. +# 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. +# 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. +# 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. +# 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. +# ───────────────────────────────────────────────────────────────────────────── + +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; } + +# ── 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() { + 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" +} + +# 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` +# 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" +# 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 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"). + # + # 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") + 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 ────────────────────────────────────────── +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=$! +# 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 + 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)" +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=$! +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 +# 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" + +# ── Scenario 5 — Server-initiated keepalive ───────────────────────────────── +# Verifies the WS handler sends RFC 6455 Ping control frames on the +# `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. +# (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)" +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" +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)" + +# ── 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)" +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=$! +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. +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)" +ready_s7="$(mktemp -t rtbus_s7_ready.XXXXXX)"; rm -f "$ready_s7" +"$HELPER_BIN" subscribe-and-collect \ + --url "$ws_url" \ + --token "$user1_token" \ + --subscribe "folder:$folder_a" \ + --subscribe "folder:$folder_b" \ + --expect-events 2 \ + --timeout 5s \ + --ready-file "$ready_s7" \ + --output "$out_s7" & +helper_pid=$! +wait_ready "$ready_s7" +# `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" + +# ── 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)" +ready_s8="$(mktemp -t rtbus_s8_ready.XXXXXX)"; rm -f "$ready_s8" +"$HELPER_BIN" subscribe-and-collect \ + --url "$ws_url" \ + --token "$user2_token" \ + --subscribe "folder:$folder_a" \ + --subscribe "folder:$folder_b" \ + --expect-events 1 \ + --timeout 6s \ + --ready-file "$ready_s8" \ + --output "$out_s8" & +helper_pid=$! +wait_ready "$ready_s8" # both subscribes installed before we revoke/upload + +# 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" + +# ── 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" + +# ── 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" + +# ── 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" + +# ── 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" + +# ── 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." diff --git a/tests/api/run.sh b/tests/api/run.sh index 7aa66375..6dfe31c9 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. 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 message-bus smoke test..." +BUILD_TARGET="$BUILD_TARGET" bash "$REPO_ROOT/tests/api/rt_bus_check.sh" \ + || 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, # 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 diff --git a/tests/common/server.env b/tests/common/server.env index 77c6760d..b2bf16f7 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_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 # pointer is set in the DB yet (fresh test DB). Its root_dir falls