Merge pull request #720 from EdouardVanbelle/feat/message-bus
This commit is contained in:
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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-*`.
|
||||
|
||||
Generated
+4110
-3
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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)`
|
||||
);
|
||||
@@ -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()]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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]: '' };
|
||||
|
||||
@@ -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];
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>>();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user