Merge pull request #720 from EdouardVanbelle/feat/message-bus

This commit is contained in:
Dionisio Pozo
2026-09-12 14:40:07 +02:00
committed by GitHub
105 changed files with 15828 additions and 190 deletions
+64
View File
@@ -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
Generated
+59 -14
View File
@@ -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]]
+31 -2
View File
@@ -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"
+1 -1
View File
@@ -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 |
+13 -1
View File
@@ -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/<uuid\|name>/…` targets a specific drive. (2) empty string `""` — `/webdav/` IS the drive listing, `/webdav/<uuid\|name>/…` 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.
File diff suppressed because it is too large Load Diff
+84 -3
View File
@@ -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)
# -----------------------------------------------------------------------------
+75
View File
@@ -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:<subsystem>`** 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:<namespace>']`. 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-*`.
+4110 -3
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -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",
+214
View File
@@ -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 <Ident> from '<relative-path>';` 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 <Ident>;`. 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/<Name>` 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("<Name>")\` 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)`
);
+12 -2
View File
@@ -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:<namespace>']` via loglevel.
//
// oxi.setLogLevel('oxi:upload', 'debug') // deep dive
@@ -118,5 +121,12 @@ export async function init(): Promise<void> {
// 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()]);
}
+6 -2
View File
@@ -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;
+19
View File
@@ -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<ServerConfig> {
return apiJson<ServerConfig>('/api/config');
}
@@ -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<NotificationListResponse> {
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<NotificationListResponse>(`/api/notifications${suffix ? `?${suffix}` : ''}`);
}
/** Badge-only fast path — no payloads fetched. */
export async function getUnreadCount(): Promise<number> {
const res = await apiJson<UnreadCountResponse>('/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<void> {
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<number> {
const res = await apiJson<MarkAllReadResponse>('/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<void> {
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 };
+93
View File
@@ -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<string, unknown>`) —
// 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<string, unknown>;
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;
@@ -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]: '' };
+211 -64
View File
@@ -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<NavLink[]>(() => {
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, unknown> }): 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.
}
}}
>
<Icon name="bell" />
{#if ui.unread > 0}<span class="notif-badge">{ui.unreadBadge}</span>{/if}
{#if totalUnread > 0}<span class="notif-badge">{totalUnreadBadge}</span>{/if}
</button>
<div class="notif-panel">
<div class="notif-panel-header">
@@ -813,7 +915,7 @@
{/if}
</div>
<div class="notif-panel-body">
{#if ui.notifications.length === 0}
{#if ui.notifications.length === 0 && persistentNotifications.items.length === 0}
<div class="notif-empty">
<Icon name="bell-slash" />
<span>{t('notifications.empty', 'No notifications')}</span>
@@ -855,6 +957,43 @@
</div>
</div>
{/each}
{#if persistentNotifications.items.length > 0}
{#if ui.notifications.length > 0}
<div
class="notif-section-divider"
role="separator"
aria-orientation="horizontal"
></div>
{/if}
{#each persistentNotifications.items as row (row.id)}
<div
class="notif-item notif-item--{row.kind}"
role="button"
tabindex="0"
data-testid="appshell-notif-persistent-item"
aria-label={persistentSummary(row)}
style:font-weight={row.read_at === null ? '500' : 'normal'}
style:cursor="pointer"
onclick={() => void persistentNotifications.markRead(row.id)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
void persistentNotifications.markRead(row.id);
}
}}
>
<span class="notif-item-icon">
<Icon name={persistentIcon(row.kind)} />
</span>
<div class="notif-item-body">
<div class="notif-item-text">{persistentSummary(row)}</div>
<div class="notif-item-time">
{formatPersistentTime(row.created_at)}
</div>
</div>
</div>
{/each}
{/if}
{/if}
</div>
</div>
@@ -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) {
@@ -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;
}
}
@@ -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;
}
}
@@ -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=<lastReceivedAt>&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<Notification[]>([]);
#unread = $state<number>(0);
#loading = $state<boolean>(false);
#error = $state<string | null>(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<void> {
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<void> {
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<void> {
try {
this.#unread = await getUnreadCount();
} catch (e) {
bellLog.warn('badge refresh failed', e);
}
}
async markRead(id: string): Promise<void> {
// 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<void> {
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<void> {
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=<lastReceivedAt>`
// 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();
};
});
}
@@ -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');
});
});
@@ -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();
});
}
@@ -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();
});
}
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -0,0 +1,4 @@
import type RtSuccessResponseBody from './RtSuccessResponseBody';
import type RtErrorResponseBody from './RtErrorResponseBody';
type Folder = RtSuccessResponseBody | RtErrorResponseBody;
export type { Folder as default };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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<EventHandler>;
revokedHandlers: Set<RevokedHandler>;
/** 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<ConnectionState>('idle');
/** Last observed round-trip in ms, updated on each `rt.pong`.
* `null` until the first ping completes. */
latencyMs = $state<number | null>(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<typeof setTimeout> | 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<ReconnectHandler>();
/** 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<typeof setTimeout> | 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<string, SubEntry>();
/** 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<number, PendingCall>();
#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<void> {
let subprotocol: string;
try {
const res = await apiJson<RtTicketResponse>('/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<void> {
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<void> {
// 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<number> {
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<unknown> {
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();
@@ -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<string, number>;
/** 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];
+107
View File
@@ -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();
}
});
});
+110
View File
@@ -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<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
@@ -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<string | null>(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<ServerFeatures>({ ...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<ServerStatus>({ ...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<void> {
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();
+79 -17
View File
@@ -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<Tab>(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.<key>` keeps translations discoverable.
*/
interface FeatureRow {
key: string;
label: string;
enabled: boolean;
}
const FEATURE_LABELS: Record<string, string> = {
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<FeatureRow[]>(() => {
const raw = serverConfig.features as unknown as Record<string, boolean>;
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 @@
</span>
{t('admin.online_sessions', 'Online sessions')}
</div>
<!-- WS-sessions card only rendered when the message bus is
enabled server-side. With `OXICLOUD_MESSAGEBUS_ENABLE=false`
the count is unconditionally 0, and showing "0 Live
WS sessions" reads like a bug when the feature simply
isn't running. `serverConfig.features.message_bus`
comes from `/api/config` at boot. -->
{#if serverConfig.features.message_bus}
<div
class="ds-card"
title={t(
'admin.active_ws_sessions_tooltip',
'Currently-connected message-bus WebSocket sessions — one per open browser tab reaching a folder view'
)}
>
<span class="ds-num ds-num--live">
<span class="presence-dot presence-dot--online" aria-hidden="true"></span>
{dashboard.active_ws_sessions}
</span>
{t('admin.active_ws_sessions', 'Live WS sessions')}
</div>
{/if}
</div>
<!-- Section 3: System — deployment flags + version. -->
<!-- Section 3: System — deployment flags + version.
Feature cards are data-driven from `/api/config`
(see `serverConfig.features` + `featureRows` derived).
Auth / OIDC / version still come from the dashboard
endpoint since those are per-deployment "system"
concerns not exposed on the public config surface.
Adding a new feature server-side flows into this grid
automatically — no template change needed. -->
<h2 class="ds-section-title">{t('admin.section_system', 'System')}</h2>
<div class="ds-grid">
<div class="ds-card">
<span class="ds-flag" class:ds-flag--on={dashboard.auth_enabled}>
{dashboard.auth_enabled
? t('admin.enabled', 'Enabled')
: t('admin.disabled', 'Disabled')}
</span>
{t('admin.auth', 'Authentication')}
</div>
<div class="ds-card">
<span class="ds-flag" class:ds-flag--on={dashboard.oidc_configured}>
{dashboard.oidc_configured ? t('admin.active', 'Active') : t('admin.off', 'Off')}
</span>
{t('admin.oidc', 'OIDC / SSO')}
</div>
<div class="ds-card">
<span class="ds-flag" class:ds-flag--on={dashboard.quotas_enabled}>
{dashboard.quotas_enabled
? t('admin.enabled', 'Enabled')
: t('admin.disabled', 'Disabled')}
</span>
{t('admin.quotas', 'Quotas')}
</div>
{#each featureRows as row (row.key)}
<div class="ds-card" data-testid="admin-feature-card-{row.key}">
<span class="ds-flag" class:ds-flag--on={row.enabled}>
{row.enabled ? t('admin.enabled', 'Enabled') : t('admin.disabled', 'Disabled')}
</span>
{row.label}
</div>
{/each}
<!-- Version card intentionally last — tertiary build
metadata (like a footer), least useful at a glance
compared to the feature-toggle cards above. -->
<div class="ds-card">
<span class="ds-num">v{dashboard.server_version}</span>{t('admin.version', 'Version')}
</div>
@@ -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
@@ -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
+10 -1
View File
@@ -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),
+69 -11
View File
@@ -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
@@ -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;
+8 -2
View File
@@ -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
+833
View File
@@ -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:<name>`.
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<Self, ParseTopicErr> {
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<Uuid>` 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<Uuid> },
/// 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<chrono::Utc>,
},
/// 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<chrono::Utc>,
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<u64>,
total: Option<u64>,
message: Option<String>,
},
/// 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/<name>` 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<String>,
ended_at: chrono::DateTime<chrono::Utc>,
},
}
// ════════════════════════════════════════════════════════════════════════════
// 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<Box<...>>` shape.
pub type BusStream = Pin<Box<dyn Stream<Item = MessageBusEvent> + 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<Self>, shutdown: Arc<Notify>) -> Result<(), DomainError>;
}
/// Day-1 replicator: does nothing. Wired unconditionally so callers hold
/// `Arc<dyn BusReplicator>` 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<Self>, shutdown: Arc<Notify>) -> 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:<uuid> 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::<chrono::Utc>::from_timestamp(0, 0).unwrap(),
},
"notification_received",
),
(
MessageBusEvent::JobRunStarted {
name: "backend_migration".into(),
started_at: chrono::DateTime::<chrono::Utc>::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::<chrono::Utc>::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();
}
}
+1
View File
@@ -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;
@@ -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<Arc<crate::application::services::storage_usage_service::StorageUsageService>>,
/// 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<Arc<dyn crate::application::ports::message_bus_ports::MessageBus>>,
/// 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<Arc<FileBlobReadRepository>>,
}
impl FileManagementService {
@@ -68,7 +82,7 @@ impl FileManagementService {
pub fn with_trash(
file_repository: Arc<FileBlobWriteRepository>,
trash_service: Option<Arc<TrashService>>,
_file_read: Option<Arc<FileBlobReadRepository>>,
file_read: Option<Arc<FileBlobReadRepository>>,
_folder_repo: Option<Arc<FolderDbRepository>>,
content_cache: Option<Arc<FileContentCache>>,
authz: Arc<PgAclEngine>,
@@ -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<dyn crate::application::ports::message_bus_ports::MessageBus>,
) -> Self {
self.bus = Some(bus);
self
}
/// Sets the lifecycle hook dispatcher (thumbnails, audio metadata, …).
pub fn with_file_lifecycle_hook(mut self, hook: Arc<dyn FileLifecycleHook>) -> 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
}
@@ -56,6 +56,13 @@ pub struct FileUploadService {
/// (`create_file_from_owned_blob_with_perms`); `None` in minimal test
/// wiring.
instant_upload: Option<InstantUploadDeps>,
/// 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<Arc<dyn crate::application::ports::message_bus_ports::MessageBus>>,
}
/// 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<dyn crate::application::ports::message_bus_ports::MessageBus>,
) -> 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)
}
+131
View File
@@ -49,6 +49,13 @@ pub struct FolderService {
/// on cross-drive MOVE. Silently skipped when unwired (stubs).
storage_usage:
Option<Arc<crate::application::services::storage_usage_service::StorageUsageService>>,
/// 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<Arc<dyn crate::application::ports::message_bus_ports::MessageBus>>,
}
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<dyn crate::application::ports::message_bus_ports::MessageBus>,
) -> 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(())
}
}
+1
View File
@@ -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;
@@ -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<dyn NotificationRepository>,
bus: Arc<dyn MessageBus>,
}
impl NotificationApplicationService {
pub fn new(repo: Arc<dyn NotificationRepository>, bus: Arc<dyn MessageBus>) -> 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<Notification, DomainError> {
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<Vec<Notification>, 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<i64, DomainError> {
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<bool, DomainError> {
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<u64, DomainError> {
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<bool, DomainError> {
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<Utc>,
) -> Result<u64, DomainError> {
self.repo.purge_read_before(cutoff).await
}
}
+85
View File
@@ -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<crate::infrastructure::repositories::pg::DrivePgRepository>,
/// 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<Arc<dyn MessageBus>>,
}
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<dyn MessageBus>) -> 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!(
+856
View File
@@ -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.<uuid>` — 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 <jwt>`, 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 <jwt>` 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.<uuid>`
// (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 <jwt>` 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.<uuid>` 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<string, unknown>`
// 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>) -> 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:<uuid>` or `user:<uuid>: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/<name>` 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",
]
})
}
+72 -1
View File
@@ -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(())
}
+520
View File
@@ -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 <jwt>` (Authorization: Bearer path — the original
/// helper flow) or `--ticket <uuid>` (Sec-WebSocket-Protocol path
/// — exercises F). Exactly one MUST be set; parse_args enforces.
auth: WsAuth,
subscribe: Vec<String>,
expect_events: Option<usize>,
reason: Option<String>,
timeout: Duration,
output: Option<String>,
/// 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<String>,
}
/// 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<Duration, String> {
// Accept `<n>s`, `<n>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::<u64>()
.map(Duration::from_millis)
.map_err(|_| format!("bad duration: {s}"))
} else if let Some(num) = s.strip_suffix('s') {
num.parse::<u64>()
.map(Duration::from_secs)
.map_err(|_| format!("bad duration: {s}"))
} else {
s.parse::<u64>()
.map(Duration::from_secs)
.map_err(|_| format!("bad duration: {s}"))
}
}
fn parse_args() -> Result<Args, String> {
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::<usize>()
.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<E: std::fmt::Display> From<E> 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 <jwt>` on the upgrade
/// — the programmatic-client path.
/// - `Ticket(uuid)` sets `Sec-WebSocket-Protocol: oxi.ticket.<uuid>`
/// — 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<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
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<String> = Vec::new();
let mut pending_subs: std::collections::HashMap<u64, String> = 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<Value> = 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<Value> = 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("<unknown>"),
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(());
}
}
+51 -10
View File
@@ -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::<bool>())
&& 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::<bool>())
&& 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::<bool>())
&& 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::<u32>()
{
config.features.notifications_retention_days = val.max(1);
}
if let Ok(enable_search) = env::var("OXICLOUD_ENABLE_SEARCH").map(|v| v.parse::<bool>())
&& let Ok(val) = enable_search
{
+147 -6
View File
@@ -703,7 +703,14 @@ impl AppServiceFactory {
resource_access_hook: Option<
Arc<dyn crate::application::ports::resource_access_hook::ResourceAccessHook>,
>,
bus: &Arc<crate::infrastructure::services::in_process_message_bus::InProcessMessageBus>,
) -> 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<dyn crate::application::ports::message_bus_ports::MessageBus> =
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<PgAclEngine>,
drive_repo: &Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
bus: &Arc<crate::infrastructure::services::in_process_message_bus::InProcessMessageBus>,
) -> Option<Arc<TrashService>> {
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<dyn crate::application::ports::message_bus_ports::MessageBus> =
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<dyn crate::application::ports::message_bus_ports::MessageBus> =
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<dyn crate::application::ports::message_bus_ports::MessageBus> =
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<crate::application::services::external_mount_router::MountRouter>,
/// 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<dyn MessageBus>`) so the
/// GC task's `Weak<Self>` lifecycle is legible from di.rs. Consumers
/// that only need the trait obtain it via
/// `Arc::clone(&state.bus) as Arc<dyn MessageBus>`.
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<std::sync::atomic::AtomicUsize>,
pub auth_service: Option<AuthServices>,
/// 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<crate::application::services::recipient_notification_service::RecipientNotificationService>,
>,
/// 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<crate::application::services::notification_application_service::NotificationApplicationService>,
>,
/// 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
+1
View File
@@ -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;
+70
View File
@@ -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/<name>`.
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<Utc>,
/// `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<DateTime<Utc>>,
}
/// 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,
}
+1
View File
@@ -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;
@@ -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<u32>,
/// `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<DateTime<Utc>>,
/// 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<DateTime<Utc>>,
}
#[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<Notification, DomainError>;
/// 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<Vec<Notification>, 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<i64, DomainError>;
/// 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<Utc>,
) -> Result<bool, DomainError>;
/// Bulk mark-all-read. Returns the number of rows updated.
async fn mark_all_read_for_user(
&self,
user_id: Uuid,
at: DateTime<Utc>,
) -> Result<u64, DomainError>;
/// 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<bool, DomainError>;
/// 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<Utc>) -> Result<u64, DomainError>;
}
@@ -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,
@@ -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<PgPool>,
}
impl NotificationPgRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
fn map_row(row: &sqlx::postgres::PgRow) -> Result<Notification, DomainError> {
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<Notification, DomainError> {
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<Vec<Notification>, 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<i64, DomainError> {
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::<i64, _>("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<Utc>,
) -> Result<bool, DomainError> {
// 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<Utc>,
) -> Result<u64, DomainError> {
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<bool, DomainError> {
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<Utc>) -> Result<u64, DomainError> {
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())
}
}
+63 -9
View File
@@ -94,8 +94,11 @@ async fn run(registry: Arc<JobRegistry>) {
// 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<JobRegistry>) {
/// `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<JobEntry>, args: &JobRunArgs) -> JobOutcome {
pub(super) async fn dispatch(
name: &str,
entry: Arc<JobEntry>,
args: &JobRunArgs,
bus: Option<std::sync::Arc<dyn crate::application::ports::message_bus_ports::MessageBus>>,
) -> 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<JobEntry>, 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<JobEntry>, 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/<name>`.
// 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();
+39 -1
View File
@@ -60,12 +60,24 @@ pub(super) struct JobState {
/// native services `register()` during DI wiring.
pub struct JobRegistry {
entries: RwLock<HashMap<String, Arc<JobEntry>>>,
/// 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<dyn crate::application::ports::message_bus_ports::MessageBus>,
>,
}
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<Self>, name: &str, args: &JobRunArgs) -> Option<JobOutcome> {
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<dyn crate::application::ports::message_bus_ports::MessageBus>,
) {
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<std::sync::Arc<dyn crate::application::ports::message_bus_ports::MessageBus>> {
self.message_bus.get().cloned()
}
}
@@ -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<InProcessMessageBus>` (or `Arc<dyn MessageBus>`).
/// The struct owns its topic map and — when constructed via
/// [`InProcessMessageBus::with_replicator`] — an [`Arc<dyn BusReplicator>`]
/// that gets fed every local publish for outbound broker forwarding.
pub struct InProcessMessageBus {
topics: DashMap<Topic, broadcast::Sender<MessageBusEvent>>,
replicator: Arc<dyn BusReplicator>,
}
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<dyn BusReplicator>) -> Arc<Self> {
let bus = Arc::new(Self {
topics: DashMap::new(),
replicator,
});
bus.spawn_gc();
bus
}
/// Spawn the periodic GC task. Holds `Weak<Self>` 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<Self>) {
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<MessageBusEvent> {
// `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<T, BroadcastStreamRecvError>`;
// 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> {
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<Self>,
shutdown: Arc<Notify>,
) -> 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);
}
}
+3
View File
@@ -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;
@@ -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<NotificationApplicationService>,
/// 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<NotificationApplicationService>, 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<Self>, registry: &JobRegistry) -> Arc<Self> {
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}")),
}
}
}
@@ -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.<uuid>`; 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.<uuid>` — 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<Uuid, Entry>,
}
impl RtTicketStore {
pub fn new() -> Arc<Self> {
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.<uuid>`.
///
/// 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<Uuid> {
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<Self>) -> 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<RtTicketStore>` shared across the axum-served tasks.
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Arc<RtTicketStore>>();
}
}
+40 -14
View File
@@ -53,18 +53,38 @@ struct AdminUsersPageResponse {
}
/// Admin API routes — all require admin role.
pub fn admin_routes() -> Router<Arc<AppState>> {
///
/// 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<AppState>) -> Router<Arc<AppState>> {
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<Arc<AppState>> {
.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"),
@@ -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<Arc<AppState>>) -> Json<ServerConfigDto> {
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),
})
}
@@ -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<uuid::Uuid> = 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()
}
+4
View File
@@ -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;
@@ -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<Utc>,
/// `null` = unread.
pub read_at: Option<DateTime<Utc>>,
}
impl From<Notification> 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<DateTime<Utc>>,
/// 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<DateTime<Utc>>,
/// Max rows returned. Server-side clamp at 500.
pub limit: Option<u32>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct ListResponseDto {
pub items: Vec<NotificationDto>,
/// 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<bool>, Query, description = "Only return unread rows"),
("before" = Option<DateTime<Utc>>, Query, description = "Cursor — rows strictly before this created_at (load-older pagination)"),
("after" = Option<DateTime<Utc>>, Query, description = "Cursor — rows strictly after this created_at (delta catch-up on WS reconnect / tab reactivation)"),
("limit" = Option<u32>, 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<Arc<NotificationApplicationService>>,
auth_user: AuthUser,
Query(query): Query<ListQuery>,
) -> Result<Json<ListResponseDto>, 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<Arc<NotificationApplicationService>>,
auth_user: AuthUser,
) -> Result<Json<UnreadCountDto>, 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<Arc<NotificationApplicationService>>,
auth_user: AuthUser,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
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<Arc<NotificationApplicationService>>,
auth_user: AuthUser,
) -> Result<Json<MarkAllReadResponseDto>, 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<Arc<NotificationApplicationService>>,
auth_user: AuthUser,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
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)
}
@@ -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.<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<Arc<AppState>>,
) -> Json<RtTicketResponse> {
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(),
})
}
+970
View File
@@ -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: "<topic>" }` or JSON-RPC error.
//! - `rt.unsubscribe { topic }` → `{ unsubscribed: "<topic>" }`.
//! - `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.<uuid>`) — 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 <jwt>`) — 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<String>,
/// `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<Value>,
method: Option<String>,
#[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<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<RpcError<'a>>,
}
#[derive(Debug, Serialize)]
struct RpcError<'a> {
code: i32,
message: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
data: Option<Value>,
}
/// 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<Arc<AppState>>,
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<String>,
}
/// 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<AppState>,
) -> Result<UpgradeAuth, &'static str> {
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<String> {
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 <token>` 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<Uuid>),
}
/// 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<std::sync::atomic::AtomicUsize>);
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<AppState>) {
// 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::<SessionOut>(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<String, Sub> = 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<SessionId, mpsc::Sender<()>>`
// 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<AppState>,
subs: &mut HashMap<String, Sub>,
out_tx: &mpsc::Sender<SessionOut>,
) -> Option<String> {
// 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<AppState>,
subs: &mut HashMap<String, Sub>,
out_tx: &mpsc::Sender<SessionOut>,
) -> 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, Sub>) -> 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<String, Sub>,
out_tx: &mpsc::Sender<SessionOut>,
state: &Arc<AppState>,
) {
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<Value>) -> 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");
}
}
+9
View File
@@ -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`.
+57 -2
View File
@@ -167,6 +167,16 @@ pub fn create_public_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppStat
router = router.route("/version", get(get_version));
router = router.route("/openapi.json", get(get_openapi_spec));
// Server-configuration discovery endpoint — public, unauthenticated.
// Returns feature flags, version, and a snapshot of the server-status
// header for one-shot boot hydration by the SPA. See
// `handlers/config_handler.rs` for the DTO shape and rationale.
router = router.route(
"/config",
get(crate::interfaces::api::handlers::config_handler::get_config)
.with_state(app_state.clone()),
);
router
}
@@ -185,6 +195,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
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<AppState>) -> Router<Arc<AppState>> {
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<AppState>) -> Router<Arc<AppState>> {
.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<AppState>) -> Router<Arc<AppState>> {
// 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<AppState>) -> Router<Arc<AppState>> {
.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

Some files were not shown because too many files have changed in this diff Show More