feat(msg-bus): wire jobs follow up

This commit is contained in:
Edouard Vanbelle
2026-09-11 19:41:18 +02:00
parent 84ea005b65
commit a6138aa4d9
16 changed files with 766 additions and 82 deletions
+164 -61
View File
@@ -75,18 +75,41 @@ smoke suite plus manual multi-user E2E. Live today:
- **Ticket tested** — S10 (happy path), S11 (single-use replay - **Ticket tested** — S10 (happy path), S11 (single-use replay
rejected). rejected).
Deferred and still open — see the Roadmap section and the Active — still under Phase A, ordered by priority:
- **Job dashboard live** (next) — `JobRegistry` publishes step
progress + terminal state on `job:{id}`; the admin jobs view
subscribes and drops its polling. Small; same shape as folder-live.
Value: an operator who triggers a long-running job (backend
migration, thumbnail import, etc.) can navigate to another admin
page and come back without losing progress visibility.
- **Notifications table + bell** (E) — topic + producer + auto-sub
land here. Same pattern as `:authz`. Larger; unblocks Phase-B
`@mentions`.
Deferred — see the Roadmap section's `## Deferred` block and the
`project_message_bus_reconnect_gap` memory: `project_message_bus_reconnect_gap` memory:
- **Notifications table + bell** (E) — topic + producer + auto-sub - **Workspace UX** (was Phase B) — presence, comments, reactions,
land here. Same pattern as `:authz`. `@mentions`, `NotificationService` as bus subscriber.
- **Presence** (Phase B) — `folder:{id}:presence` topic + awareness - **Infrastructure payoff** (was Phase C) — sync-client push
frames. invalidation, album live, slideshow sync.
- **Yjs collab** — `docs/plan/markdown-collab.md`, depends on the - **Yjs collab** — `docs/plan/markdown-collab.md`, depends on the
binary-frame routing this plan sketches but doesn't ship. binary-frame routing this plan sketches but doesn't ship.
- **Broker replicator** (Postgres LISTEN/NOTIFY or Redis) — for - **Broker replicator** (Postgres LISTEN/NOTIFY or Redis) — for
multi-instance and durable event log. `BusReplicator` port multi-instance and durable event log. `BusReplicator` port
declared, `NoopReplicator` wired today. declared, `NoopReplicator` wired today.
- **Session-resume tokens** — `rt.subscribe { since: N }` + a
server-side per-topic ring buffer with sequence numbers.
Replaces "full refetch on reconnect" with delta replay. Pairs
with the collab editor slice (Yjs) where refetch cost is high.
- **SharedWorker for multi-tab dedup** — one WS per user per
browser profile, shared across every same-origin tab. Turns "5
tabs open" into 1 WS instead of 5. Ship when the "Live WS
sessions" admin card sits persistently at N × user count.
- **Web Push for offline delivery** — pairs with Slice E
(notifications bell). Delivers to closed browsers via FCM /
Mozilla autopush / Apple Push through a service worker.
## Non-goals ## Non-goals
@@ -1219,9 +1242,13 @@ Ships the infrastructure and the two most visible consumers together.
`useReconnect` composable → folder view refetches after WS comes `useReconnect` composable → folder view refetches after WS comes
back. Bridges the in-memory-bus "events lost during outage" gap back. Bridges the in-memory-bus "events lost during outage" gap
(see `project_message_bus_reconnect_gap` memory). (see `project_message_bus_reconnect_gap` memory).
- **Job dashboard live** — TODO. `JobRegistry` publishes step - **Job dashboard live** — TODO (next slice). `JobRegistry`
progress and terminal state; FE job dashboard subscribes and publishes step progress and terminal state on `job:{id}`; FE
replaces polling. job dashboard subscribes and replaces polling. Operator value:
once a long-running job is triggered (backend migration, thumb
import, blobs consistency…), the admin can navigate to another
page and come back without losing progress visibility — the WS
push keeps whatever component is subscribed up-to-date.
- **Notifications table + bell** — TODO (Slice E). New - **Notifications table + bell** — TODO (Slice E). New
`notifications` table + `NotificationService` port; initial `notifications` table + `NotificationService` port; initial
ingesters for `share-granted`, `new-login-from-new-device`, ingesters for `share-granted`, `new-login-from-new-device`,
@@ -1238,83 +1265,159 @@ Deliverables sized ~4 weeks end-to-end. Slice D (folder-live) and
Slice F (ticket flow) landed 2026-09-11. Slices E + collab are the Slice F (ticket flow) landed 2026-09-11. Slices E + collab are the
open work in Phase A. open work in Phase A.
### Phase B — Presence + comments ### Deferred — everything below is on the shelf
Everything that turns OxiCloud from a file store into a shared None of these ship on a fixed date; each is triggered by a concrete
workspace. consumer need. Grouped by theme (workspace UX, infrastructure
payoff, replicator) so the reader still sees the connective tissue,
but there is no commitment to sequencing.
#### Workspace UX (formerly "Phase B")
Turns OxiCloud from a file store into a shared workspace. Ship when
a specific feature here graduates from "would be nice" to "the
product needs it".
- **Presence topics** — `folder:{id}:presence`, `file:{id}:presence`. - **Presence topics** — `folder:{id}:presence`, `file:{id}:presence`.
Awareness-style: joined/left/cursor. Ephemeral, not persisted. Awareness-style: joined/left/cursor. Ephemeral, not persisted.
- **FE presence UI**: "N people viewing" badge in folder header; - **FE presence UI** — "N people viewing" badge in folder header;
avatar rail; hover to highlight; "someone is previewing this photo avatar rail; hover to highlight; "someone is previewing this photo
right now" in the lightbox. right now" in the lightbox.
- **Comments on any file** — new `comments` table (threaded, per - **Comments on any file** — new `comments` table (threaded, per
file, supports reactions), `CommentService` port, file, supports reactions), `CommentService` port,
`file:{id}:comments` topic for live delivery. `file:{id}:comments` topic for live delivery.
- **@mentions**: mention autocomplete in the comment editor; - **@mentions** — mention autocomplete in the comment editor;
mention → notification into the mentioned user's mention → notification into the mentioned user's
`user:{u}:notifications` topic + `notifications` row + optional `user:{u}:notifications` topic + `notifications` row + optional
email (reuses existing `MagicLinkMailer`-style templating). email (reuses existing `MagicLinkMailer`-style templating).
- **Reactions**: 👍❤️🎉 on comments and on files themselves; live - **Reactions** — 👍❤️🎉 on comments and files. Live fan-out on the
fan-out on the same `file:{id}:comments` topic. same `file:{id}:comments` topic.
- **Comment resolutions**: Google-Docs-style thread markers. - **Comment resolutions** — Google-Docs-style thread markers.
- **NotificationService consumes bus events** — up to Phase A the - **`NotificationService` as a bus subscriber** (architectural
bus's publish calls sit inline in each mutation site pivot). Up to Phase A the bus's publish calls sit inline in each
(`FolderService::create_folder_with_perms`, mutation site (`FolderService::create_folder_with_perms`,
`FileUploadService::upload_file_streaming`, and — once folder-live `FileUploadService::upload_file_streaming`, and the delete /
rounds out — the delete / rename / move sites for both files and rename / move sites for both files and folders). That is the
folders). That is the right shape and stays: the bus is right shape and stays: the bus is location-keyed
location-keyed (`Topic::Folder(id)`, subscriber-scoped) and (`Topic::Folder(id)`, subscriber-scoped) and belongs at the
belongs at the mutation site. mutation site. When notifications ship, they sit on the **same
When Phase B ships, notifications sit on the **same axis** (also axis** (also location + actor + subscriber-driven) — not the
location + actor + subscriber-driven) — not the FileLifecycleHook `FileLifecycleHook` axis (which is server-internal, content-keyed,
axis (which is server-internal, content-keyed, fan-out-to-all). fan-out-to-all). So `NotificationService` becomes an in-process
So `NotificationService` becomes an in-process subscriber to the subscriber to the bus itself: it registers a `bus.subscribe(...)`
bus itself: it registers a `bus.subscribe(...)` on the topics it on the topics it cares about (`folder:{id}`, `file:{id}`,
cares about (`folder:{id}`, `file:{id}`, share-grant events), share-grant events), translates relevant events into
translates relevant events into `notif.notifications` rows, and `notif.notifications` rows, and re-publishes on
re-publishes on `user:{u}:notifications`. No new dispatcher, no `user:{u}:notifications`. No new dispatcher, no new hook trait,
new hook trait, no changes to existing mutation sites — the bus IS no changes to existing mutation sites — the bus IS the
the mutation-event pipeline for anything subscriber-driven. mutation-event pipeline for anything subscriber-driven. Contrast
Contrast with `FileLifecycleHook` (`src/application/ports/file_lifecycle.rs`): with `FileLifecycleHook` (`src/application/ports/file_lifecycle.rs`):
that stays focused on content transitions (blob_hash, content_type) that stays focused on content transitions (blob_hash,
and fires unconditionally to server-side workers (thumbnails, content_type) and fires unconditionally to server-side workers
audio metadata, plugins). Bus and lifecycle-hook are complementary (thumbnails, audio metadata, plugins). Bus and lifecycle-hook are
— same triggering moment, orthogonal fan-out shape and payload complementary — same triggering moment, orthogonal fan-out shape
discipline. Do NOT try to unify them; the two axes are genuinely and payload discipline. Do NOT try to unify them; the two axes
different (all-vs-subscribed × content-vs-location). are genuinely different (all-vs-subscribed × content-vs-location).
Deliverables sized ~3 weeks after Phase A. #### Infrastructure payoff (formerly "Phase C")
### Phase C — Sync client push + album live Where the bus starts paying for itself on operator cost. Ship when
sync-client PROPFIND traffic or the album-viewing experience
becomes a real bottleneck.
Where the bus starts paying for itself on infrastructure cost too. - **Sync-client push invalidation** — WebDAV / NextCloud DAV
handlers publish `file:{id}` and `folder:{id}` deltas after
- **Sync-client push invalidation**: WebDAV / NextCloud DAV handlers commit. Sync clients get a lightweight `Sync-Invalidate`
publish `file:{id}` and `folder:{id}` deltas after commit. Sync mechanism (or a dedicated WS endpoint for headless clients) so
clients get a lightweight `Sync-Invalidate` mechanism (or a they refetch only changed paths instead of polling PROPFIND.
dedicated WS endpoint for headless clients) so they refetch only Cuts a large chunk of Nextcloud-style client chatter.
changed paths instead of polling PROPFIND. Cuts a large chunk of - **Album live updates** — `folder:{album_id}` reused; as photos
Nextcloud-style client chatter. are added to an album, everyone viewing sees them appear.
- **Album live updates**: `folder:{album_id}` reused — as photos are - **Slideshow sync** — one presenter picks "Present"; other viewers
added to an album, everyone viewing sees them appear. of the album can opt-in to follow the presenter's current frame.
- **Slideshow sync**: one presenter picks "Present"; other viewers of
the album can opt-in to follow the presenter's current frame.
Uses `folder:{album_id}` with a `presenter_frame` event kind. Uses `folder:{album_id}` with a `presenter_frame` event kind.
Deliverables sized ~2–3 weeks after Phase B. #### Multi-instance & broker
### Later — multi-instance & broker Only invoked when the deployment actually needs it. Also the
mitigation for the "events lost during outage window" gap (see
Only invoked when the deployment actually needs it. Nothing above `project_message_bus_reconnect_gap` memory) if durable replay
depends on these landing on any fixed date. becomes important for collab or sync-push.
- **`PgListenReplicator`** — ship when we run more than one server - **`PgListenReplicator`** — ship when we run more than one server
instance. Same port, no consumer changes. instance. Same `BusReplicator` port, no consumer changes.
- **`BrokerReplicator`** for RabbitMQ or NATS — ship when either - **`BrokerReplicator`** for RabbitMQ or NATS — ship when either
cross-datacenter fan-out or a shared broker with other services cross-datacenter fan-out or a shared broker with other services
matters. Same port, no consumer changes. matters. Same port, no consumer changes.
#### Session-resume tokens (wire-protocol extension)
Replaces today's "full refetch on reconnect" workaround with a
delta-replay protocol: the client remembers the sequence number of
the last event it processed per topic; on reconnect, it says
"resume from N" and the server replays every event since N. The
canonical shape across the industry — Discord's `OP 6 Resume`,
Slack's sync API, Firestore's `resume_token`, Notion's sync-token
pattern. Cheaper than a REST refetch for high-fan-out topics (Yjs
CRDT deltas, notification streams) where the "catch-up" would
otherwise pull megabytes of state the client mostly already has.
Requires:
- **Server-side**: per-topic bounded ring buffer with monotonic
sequence numbers. Bounded because we're not building a durable
log — a hold-back of the last N events per topic is enough for
the common "closed laptop for 10 min" case. A resume request
older than the retention window falls through to a client-side
full refetch (same code path today's `onReconnect` uses), so
the client never fails hard — just degrades.
- **Wire**: `rt.subscribe` gains an optional `since: number` param
and the ack carries the current sequence number. `rt.event`
gains a `seq` field the client stores as `last_seq[topic]`.
- **Client**: `MessageBusClient` persists `last_seq[topic]` and
replays it on `#onOpen`'s subscribe-replay. `onReconnect`
handlers keep their fallback-to-refetch role for the
older-than-retention case.
Meaningful for the collab editor slice (Yjs) and for future
sync-client push. Not worth doing before either of those lands —
folder-view refetch is a folder-page fetch (small); Yjs
"refetch" would be the whole doc snapshot (potentially large).
See `project_message_bus_reconnect_gap` memory (option 2).
#### Client-side connection efficiency
Optimizations to how the SPA holds its WebSocket. Independent of
server changes; ship when the per-user connection count actually
becomes a load concern. Today's grace-period tab-hidden close
(closes the WS after 60 s hidden, reconnects on visibility return)
covers the low-hanging fruit; both items below layer on top.
- **SharedWorker for multi-tab dedup** — one WebSocket per user per
browser profile, shared across every same-origin tab via a
`SharedWorker`. All tabs `postMessage` through the worker
instead of holding independent `WebSocket` instances. Slack /
Gmail / Google Docs all do this. Turns "user has 5 folder tabs
open" from 5 WS into 1. Refactor cost: `MessageBusClient` moves
behind the worker boundary; every `useTopic` call becomes an
RPC to the worker instead of a direct method call. Payoff
scales with per-user tab count — worth doing if operators see
the "Live WS sessions" admin card sitting persistently at 5×
the user count. Not worth it otherwise; grace-close already
handles the common "background tab" case at ~30% of this
refactor's complexity.
- **Web Push for truly-offline delivery** — service-worker-backed
push notifications delivered by the browser vendor (FCM for
Chrome, Mozilla autopush for Firefox, Apple Push for Safari)
even when the user has no OxiCloud tab open. Complements the
WS-based notification stream: WS delivers to foreground tabs;
Web Push delivers to closed browsers. Requires server-side
push-subscription store, per-vendor endpoint delivery (usually
`web-push` crate), and a service worker in the FE. Meaningful
UX win alongside Slice E (notifications bell) — a share
arriving while the user is away actually reaches them.
Deferred until Slice E ships; the two form a natural pair.
## What this bus does NOT replace ## What this bus does NOT replace
- Message queue / job queue — jobs stay in `job_registry`; bus just - Message queue / job queue — jobs stay in `job_registry`; bus just
@@ -35,6 +35,8 @@
purgeJobRuns purgeJobRuns
} from '$lib/api/endpoints/adminJobs'; } from '$lib/api/endpoints/adminJobs';
import type { Finding, JobParam, JobSummary, RunSummary, RunStatus } from '$lib/api/types'; 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 ──────────────────────────────────────────────────────── // ─── State ────────────────────────────────────────────────────────
@@ -222,6 +224,68 @@
return () => stopPolling(); 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) { async function loadRuns(name: string) {
runsLoadingByJob = { ...runsLoadingByJob, [name]: true }; runsLoadingByJob = { ...runsLoadingByJob, [name]: true };
runsErrorByJob = { ...runsErrorByJob, [name]: '' }; runsErrorByJob = { ...runsErrorByJob, [name]: '' };
@@ -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,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 };
@@ -6,6 +6,9 @@ enum RtEventKind {
FOLDER_CREATED = 'folder_created', FOLDER_CREATED = 'folder_created',
FOLDER_RENAMED = 'folder_renamed', FOLDER_RENAMED = 'folder_renamed',
FOLDER_MOVED = 'folder_moved', FOLDER_MOVED = 'folder_moved',
FOLDER_DELETED = 'folder_deleted' FOLDER_DELETED = 'folder_deleted',
JOB_RUN_STARTED = 'job_run_started',
JOB_RUN_PROGRESS = 'job_run_progress',
JOB_RUN_ENDED = 'job_run_ended'
} }
export type { RtEventKind as default }; export type { RtEventKind as default };
@@ -6,6 +6,9 @@ import type FolderCreatedData from './FolderCreatedData';
import type FolderRenamedData from './FolderRenamedData'; import type FolderRenamedData from './FolderRenamedData';
import type FolderMovedData from './FolderMovedData'; import type FolderMovedData from './FolderMovedData';
import type FolderDeletedData from './FolderDeletedData'; import type FolderDeletedData from './FolderDeletedData';
import type JobRunStartedData from './JobRunStartedData';
import type JobRunProgressData from './JobRunProgressData';
import type JobRunEndedData from './JobRunEndedData';
import type RtEventKind from './RtEventKind'; import type RtEventKind from './RtEventKind';
// AUTO-GENERATED — do not edit by hand. // AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`. // Regenerate with `just asyncapi-ts`.
@@ -18,7 +21,10 @@ interface RtEventParams {
| FolderCreatedData | FolderCreatedData
| FolderRenamedData | FolderRenamedData
| FolderMovedData | FolderMovedData
| FolderDeletedData; | FolderDeletedData
| JobRunStartedData
| JobRunProgressData
| JobRunEndedData;
event: RtEventKind; event: RtEventKind;
topic: string; topic: string;
} }
+133 -3
View File
@@ -53,7 +53,7 @@ use crate::common::errors::DomainError;
/// Encodes to a stable dotted wire key that maps naturally onto RabbitMQ /// Encodes to a stable dotted wire key that maps naturally onto RabbitMQ
/// topic-exchange routing keys or NATS subjects when the [`BusReplicator`] /// topic-exchange routing keys or NATS subjects when the [`BusReplicator`]
/// seam is filled in later. /// seam is filled in later.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] #[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub enum Topic { pub enum Topic {
/// A folder's mutation stream — file/subfolder created/deleted/renamed/ /// A folder's mutation stream — file/subfolder created/deleted/renamed/
/// moved in or out. Consumed by the folder view for live refresh. /// moved in or out. Consumed by the folder view for live refresh.
@@ -63,6 +63,17 @@ pub enum Topic {
/// subscribe the caller and evict stale subs when its events fire once /// subscribe the caller and evict stale subs when its events fire once
/// the eviction wiring lands (Phase-A follow-up). /// the eviction wiring lands (Phase-A follow-up).
UserAuthz(Uuid), UserAuthz(Uuid),
/// A named background job's run lifecycle — start / progress /
/// end. Consumed by the admin job dashboard so operators who
/// trigger a long-running job (backend migration, thumb import…)
/// can navigate to other admin pages without losing progress
/// visibility. AuthZ: **admin-only** (Class 3 role-scoped).
/// Non-admins get `topic_forbidden` — indistinguishable on the
/// wire from an unknown topic. Job names are stable
/// scheduler-registered strings (e.g. `backend_migration`,
/// `thumb_derived_import`); the topic string is `job:<name>`.
Job(String),
} }
impl Topic { impl Topic {
@@ -72,6 +83,7 @@ impl Topic {
match self { match self {
Topic::Folder(id) => format!("folder:{id}"), Topic::Folder(id) => format!("folder:{id}"),
Topic::UserAuthz(id) => format!("user:{id}:authz"), Topic::UserAuthz(id) => format!("user:{id}:authz"),
Topic::Job(name) => format!("job:{name}"),
} }
} }
@@ -90,6 +102,22 @@ impl Topic {
let id = Uuid::parse_str(id_str).map_err(|_| ParseTopicErr::BadUuid)?; let id = Uuid::parse_str(id_str).map_err(|_| ParseTopicErr::BadUuid)?;
return Ok(Topic::UserAuthz(id)); return Ok(Topic::UserAuthz(id));
} }
if let Some(name) = s.strip_prefix("job:") {
// Job names are scheduler-registered short slugs — see
// `infrastructure/scheduler/registry.rs`. Validate here
// only that the name is non-empty and consists of
// `[a-z0-9_-]` chars — reject anything else as
// `Unknown` (indistinguishable to the caller from a
// topic shape we've never heard of).
if !name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
{
return Ok(Topic::Job(name.to_string()));
}
return Err(ParseTopicErr::Unknown);
}
Err(ParseTopicErr::Unknown) Err(ParseTopicErr::Unknown)
} }
@@ -107,6 +135,7 @@ impl Topic {
resource: BusResource::Folder(*id), resource: BusResource::Folder(*id),
}, },
Topic::UserAuthz(id) => AuthzCheck::IdentityMatch { user_id: *id }, Topic::UserAuthz(id) => AuthzCheck::IdentityMatch { user_id: *id },
Topic::Job(_) => AuthzCheck::RoleAdmin,
} }
} }
} }
@@ -152,8 +181,12 @@ pub enum AuthzCheck {
/// Class 2 — Identity-scoped. `caller_id` must equal `user_id`. /// Class 2 — Identity-scoped. `caller_id` must equal `user_id`.
/// No admin bypass — privacy is a hard rule. /// No admin bypass — privacy is a hard rule.
IdentityMatch { user_id: Uuid }, IdentityMatch { user_id: Uuid },
// Class 3 (role-scoped `admin:*`) and the bespoke `job:{id}` check
// land with their topic variants. /// Class 3 — Role-scoped. Caller must hold the admin role. Used
/// by `Topic::Job(_)` today; future `admin:*` topics land here.
/// Non-admin subscriber gets `topic_forbidden` on the wire —
/// same anti-enum shape as unknown-topic denial.
RoleAdmin,
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
@@ -257,6 +290,50 @@ pub enum MessageBusEvent {
/// the payload extends with additional resource classes — see the /// the payload extends with additional resource classes — see the
/// plan's Phase-B roadmap. /// plan's Phase-B roadmap.
AuthzChanged { affected_folders: Vec<Uuid> }, AuthzChanged { affected_folders: Vec<Uuid> },
/// 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>,
},
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
@@ -420,6 +497,33 @@ mod tests {
assert_eq!(Topic::parse(&wire).unwrap(), t); 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] #[test]
fn parse_rejects_bad_uuid() { fn parse_rejects_bad_uuid() {
assert_eq!( assert_eq!(
@@ -547,6 +651,32 @@ mod tests {
}, },
"authz_changed", "authz_changed",
), ),
(
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 { for (ev, expected) in cases {
let json = serde_json::to_value(ev).unwrap(); let json = serde_json::to_value(ev).unwrap();
+68
View File
@@ -140,6 +140,21 @@ fn channels() -> Value {
"SubscribeRequest": { "$ref": "#/components/messages/RtSubscribeRequest" }, "SubscribeRequest": { "$ref": "#/components/messages/RtSubscribeRequest" },
"UnsubscribeRequest": { "$ref": "#/components/messages/RtUnsubscribeRequest" }, "UnsubscribeRequest": { "$ref": "#/components/messages/RtUnsubscribeRequest" },
} }
},
"Job": {
"address": "job:{jobName}",
"description": "A named background job's run lifecycle — Started / Progress / Ended. Consumed by the admin dashboard so operators who trigger a long-running job (backend migration, thumb import…) can navigate off the admin page and come back without losing progress. AuthZ: admin-only (Class 3 role-scoped) — non-admin gets `topic_forbidden`, indistinguishable on the wire from an unknown topic.",
"parameters": {
"jobName": { "description": "Scheduler-registered short slug (e.g. `backend_migration`); `[a-z0-9_-]` chars only" }
},
"messages": {
"SubscribeRequest": { "$ref": "#/components/messages/RtSubscribeRequest" },
"UnsubscribeRequest": { "$ref": "#/components/messages/RtUnsubscribeRequest" },
"SubscribedResponse": { "$ref": "#/components/messages/RtSubscribedResponse" },
"ErrorResponse": { "$ref": "#/components/messages/RtErrorResponse" },
"JobEvent": { "$ref": "#/components/messages/RtFolderEventNotification" },
"RevokedNotification": { "$ref": "#/components/messages/RtRevokedNotification" },
}
} }
}) })
} }
@@ -301,6 +316,9 @@ fn components() -> Value {
"FolderRenamedData": folder_renamed_schema(), "FolderRenamedData": folder_renamed_schema(),
"FolderMovedData": folder_moved_schema(), "FolderMovedData": folder_moved_schema(),
"FolderDeletedData": folder_deleted_schema(), "FolderDeletedData": folder_deleted_schema(),
"JobRunStartedData": job_run_started_schema(),
"JobRunProgressData": job_run_progress_schema(),
"JobRunEndedData": job_run_ended_schema(),
}, },
// How the client authenticates. Handler side is `auth_middleware` // How the client authenticates. Handler side is `auth_middleware`
// — the same middleware every `/api/*` request goes through, so // — the same middleware every `/api/*` request goes through, so
@@ -580,6 +598,7 @@ fn event_kind_schema() -> Value {
"enum": [ "enum": [
"file_created", "file_renamed", "file_moved", "file_deleted", "file_created", "file_renamed", "file_moved", "file_deleted",
"folder_created", "folder_renamed", "folder_moved", "folder_deleted", "folder_created", "folder_renamed", "folder_moved", "folder_deleted",
"job_run_started", "job_run_progress", "job_run_ended",
], ],
}) })
} }
@@ -596,6 +615,9 @@ fn event_data_union_schema() -> Value {
ref_schema("FolderRenamedData"), ref_schema("FolderRenamedData"),
ref_schema("FolderMovedData"), ref_schema("FolderMovedData"),
ref_schema("FolderDeletedData"), ref_schema("FolderDeletedData"),
ref_schema("JobRunStartedData"),
ref_schema("JobRunProgressData"),
ref_schema("JobRunEndedData"),
] ]
}) })
} }
@@ -710,6 +732,52 @@ fn folder_deleted_schema() -> Value {
}) })
} }
// ─────────────────── Job event data payloads ─────────────────────
// Published on `Topic::Job(name)`. AuthZ is Class-3 (admin-only) —
// non-admins get `topic_forbidden` on subscribe, so these payloads
// only ever reach admin subscribers. See `handlers/rt_ws.rs`.
fn job_run_started_schema() -> Value {
json!({
"type": "object",
"description": "A background job's run started. `name` matches the scheduler-registered job name (e.g. `backend_migration`). `actor` is `00000000-0000-0000-0000-000000000000` today — the scheduler doesn't yet thread the trigger caller through.",
"required": ["name", "started_at", "actor"],
"properties": {
"name": { "type": "string" },
"started_at": { "type": "string", "format": "date-time" },
"actor": { "type": "string", "format": "uuid" },
}
})
}
fn job_run_progress_schema() -> Value {
json!({
"type": "object",
"description": "A background job made progress. Throttled at the publish site to at most one per 3 s per job (see scheduler engine). `step` / `total` populate a progress bar; all three fields are optional because different jobs report different granularities.",
"required": ["name"],
"properties": {
"name": { "type": "string" },
"step": { "type": ["integer", "null"], "minimum": 0 },
"total": { "type": ["integer", "null"], "minimum": 0 },
"message": { "type": ["string", "null"] },
}
})
}
fn job_run_ended_schema() -> Value {
json!({
"type": "object",
"description": "A background job's run ended. `success = true` for a normal completion; `false` for failure / timeout / cancelled / paused-with-unhandled-outcome. `reason` populates the toast text on the `false` branch and links to `/admin/jobs/<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 /// `rt.revoked` notification body — server tells the client that a
/// specific subscription has been evicted. `topic` is the wire-form /// specific subscription has been evicted. `topic` is the wire-form
/// string the client originally subscribed to. `reason` is the stable /// string the client originally subscribed to. `reason` is the stable
+11
View File
@@ -1759,6 +1759,17 @@ impl AppServiceFactory {
Arc::new(crate::application::ports::message_bus_ports::NoopReplicator), 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 // WebSocket ticket store — see `rt_ticket_store` module doc for
// why this exists (DPoP-bound sessions can't be re-proofed on // why this exists (DPoP-bound sessions can't be re-proofed on
// a browser-issued WS upgrade). Reaper task runs for the app // a browser-issued WS upgrade). Reaper task runs for the app
+63 -9
View File
@@ -94,8 +94,11 @@ async fn run(registry: Arc<JobRegistry>) {
// Fire and forget from the supervisor's perspective — we // Fire and forget from the supervisor's perspective — we
// don't care about the outcome, `dispatch` records it on the // don't care about the outcome, `dispatch` records it on the
// entry and emits the log line itself. Periodic ticks never // entry and emits the log line itself. Periodic ticks never
// force — that's an admin-trigger-only affordance. // force — that's an admin-trigger-only affordance. Pass the
let _ = dispatch(&name, entry, &JobRunArgs::default()).await; // 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 /// `args` is passed through to `JobHandler::run`. The supervisor's
/// periodic ticks pass `JobRunArgs::default()`; the admin trigger /// periodic ticks pass `JobRunArgs::default()`; the admin trigger
/// endpoint forwards parsed query params such as `?force=true`. /// 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 // Try to acquire the single-permit gate. `try_acquire` is
// non-blocking — if held, we know the previous run is still // non-blocking — if held, we know the previous run is still
// executing and skip this tick. // 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 started_wall = Utc::now();
let start_instant = Instant::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 // Spawn so panics land as `JoinError::is_panic()` instead of
// unwinding into the supervisor loop. Args cloned into the spawn // unwinding into the supervisor loop. Args cloned into the spawn
// scope so the borrow doesn't outlive the caller. // 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. // the diagnostic `cause` field.
log_outcome(name, &outcome, cause, elapsed_ms); 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); drop(permit);
outcome outcome
} }
@@ -421,16 +476,15 @@ mod tests {
// Kick off dispatch 1 in the background — it holds the permit // Kick off dispatch 1 in the background — it holds the permit
// for ~200 ms. // for ~200 ms.
let entry_bg = entry.clone(); let entry_bg = entry.clone();
let bg = let bg = tokio::spawn(async move {
tokio::spawn( dispatch("overrun", entry_bg, &JobRunArgs::default(), None).await
async move { dispatch("overrun", entry_bg, &JobRunArgs::default()).await }, });
);
// Give dispatch 1 time to grab the permit. // Give dispatch 1 time to grab the permit.
tokio::time::sleep(Duration::from_millis(50)).await; tokio::time::sleep(Duration::from_millis(50)).await;
// Dispatch 2 should observe the permit taken and skip. // 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. // Only dispatch 1's handler should have actually run so far.
assert_eq!(calls.load(Ordering::SeqCst), 1); assert_eq!(calls.load(Ordering::SeqCst), 1);
@@ -458,7 +512,7 @@ mod tests {
.await; .await;
let entry = registry.get("slow").await.unwrap(); 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. // The timeout fired; last_outcome must be Err.
let state = entry.state.lock().unwrap(); let state = entry.state.lock().unwrap();
+39 -1
View File
@@ -60,12 +60,24 @@ pub(super) struct JobState {
/// native services `register()` during DI wiring. /// native services `register()` during DI wiring.
pub struct JobRegistry { pub struct JobRegistry {
entries: RwLock<HashMap<String, Arc<JobEntry>>>, 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 { impl JobRegistry {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
entries: RwLock::new(HashMap::new()), 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()`. /// that just want a plain run pass `JobRunArgs::default()`.
pub async fn trigger(self: &Arc<Self>, name: &str, args: &JobRunArgs) -> Option<JobOutcome> { pub async fn trigger(self: &Arc<Self>, name: &str, args: &JobRunArgs) -> Option<JobOutcome> {
let entry = self.get(name).await?; 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()
} }
} }
@@ -112,8 +112,13 @@ impl InProcessMessageBus {
/// (for the receiver) — one code path for the map insert avoids a race /// (for the receiver) — one code path for the map insert avoids a race
/// where publish creates a sender concurrent subscribers miss. /// where publish creates a sender concurrent subscribers miss.
fn sender_for(&self, topic: &Topic) -> broadcast::Sender<MessageBusEvent> { 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 self.topics
.entry(*topic) .entry(topic.clone())
.or_insert_with(|| broadcast::channel(BROADCAST_RING_CAPACITY).0) .or_insert_with(|| broadcast::channel(BROADCAST_RING_CAPACITY).0)
.clone() .clone()
} }
@@ -194,6 +199,9 @@ fn event_kind(event: &MessageBusEvent) -> &'static str {
MessageBusEvent::FolderMoved { .. } => "folder_moved", MessageBusEvent::FolderMoved { .. } => "folder_moved",
MessageBusEvent::FolderDeleted { .. } => "folder_deleted", MessageBusEvent::FolderDeleted { .. } => "folder_deleted",
MessageBusEvent::AuthzChanged { .. } => "authz_changed", MessageBusEvent::AuthzChanged { .. } => "authz_changed",
MessageBusEvent::JobRunStarted { .. } => "job_run_started",
MessageBusEvent::JobRunProgress { .. } => "job_run_progress",
MessageBusEvent::JobRunEnded { .. } => "job_run_ended",
} }
} }
+63 -4
View File
@@ -338,6 +338,48 @@ async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc<AppSt
.fetch_add(1, std::sync::atomic::Ordering::Relaxed); .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let _session_count_guard = SessionCountGuard(Arc::clone(&state.active_ws_sessions)); 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 // Outbound queue — every path that produces a client-bound frame
// enqueues here; the writer half of the select drains. Also // enqueues here; the writer half of the select drains. Also
// carries internal `EvictFolders` control signals from the // carries internal `EvictFolders` control signals from the
@@ -450,7 +492,7 @@ async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc<AppSt
match incoming { match incoming {
Some(Ok(Message::Text(txt))) => { Some(Ok(Message::Text(txt))) => {
if let Some(reply) = if let Some(reply) =
handle_text_frame(&txt, caller_id, &state, &mut subs, &out_tx).await handle_text_frame(&txt, caller_id, &caller_role, &state, &mut subs, &out_tx).await
&& socket.send(Message::Text(reply.into())).await.is_err() { && socket.send(Message::Text(reply.into())).await.is_err() {
break; break;
} }
@@ -487,6 +529,7 @@ async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc<AppSt
async fn handle_text_frame( async fn handle_text_frame(
text: &str, text: &str,
caller_id: Uuid, caller_id: Uuid,
caller_role: &str,
state: &Arc<AppState>, state: &Arc<AppState>,
subs: &mut HashMap<String, Sub>, subs: &mut HashMap<String, Sub>,
out_tx: &mpsc::Sender<SessionOut>, out_tx: &mpsc::Sender<SessionOut>,
@@ -516,9 +559,9 @@ async fn handle_text_frame(
}; };
match method.as_str() { match method.as_str() {
"rt.subscribe" => { "rt.subscribe" => Some(
Some(handle_subscribe(id, req.params, caller_id, state, subs, out_tx).await) handle_subscribe(id, req.params, caller_id, caller_role, state, subs, out_tx).await,
} ),
"rt.unsubscribe" => Some(handle_unsubscribe(id, req.params, subs)), "rt.unsubscribe" => Some(handle_unsubscribe(id, req.params, subs)),
"rt.ping" => Some(success_response(id, serde_json::json!({ "pong": true }))), "rt.ping" => Some(success_response(id, serde_json::json!({ "pong": true }))),
_ => Some(error_response( _ => Some(error_response(
@@ -534,6 +577,7 @@ async fn handle_subscribe(
id: Value, id: Value,
params: Value, params: Value,
caller_id: Uuid, caller_id: Uuid,
caller_role: &str,
state: &Arc<AppState>, state: &Arc<AppState>,
subs: &mut HashMap<String, Sub>, subs: &mut HashMap<String, Sub>,
out_tx: &mpsc::Sender<SessionOut>, out_tx: &mpsc::Sender<SessionOut>,
@@ -622,6 +666,21 @@ async fn handle_subscribe(
); );
} }
} }
AuthzCheck::RoleAdmin => {
// Class 3 — role-scoped. Caller must be admin. `caller_role`
// was snapshotted at session start (see `handle_session`),
// so no per-subscribe DB hit. A demotion mid-session
// takes effect on the caller's next reconnect.
if caller_role != "admin" {
audit_denied(caller_id, &topic_str, "role_denied");
return error_response(
id,
error_code::TOPIC_FORBIDDEN,
"topic_forbidden",
Some(serde_json::json!({ "topic": topic_str })),
);
}
}
} }
// AuthZ passed — install the subscription and spawn a reader task // AuthZ passed — install the subscription and spawn a reader task
+39 -1
View File
@@ -52,6 +52,18 @@
# again. Guards replay: a captured token # again. Guards replay: a captured token
# outside its 30 s TTL, or one already # outside its 30 s TTL, or one already
# consumed, MUST fail the upgrade with 401. # consumed, MUST fail the upgrade with 401.
# S12 Job topic admin-only — user1 (non-admin) subscribes to
# `job:<any-name>`; server must reject
# with `topic_forbidden` (audit reason
# `role_denied`). Guards the Class-3
# role-scoped AuthZ gate on
# `Topic::Job` — admin-only, no bypass,
# anti-enumeration parity with unknown
# topics. The allow side is covered by
# the Rust `required_perm` + dispatch
# unit tests; the seeded suite has no
# admin token, and minting one here
# would pollute state for other files.
# #
# Exit non-zero on any failure — run.sh treats that as a suite failure. # Exit non-zero on any failure — run.sh treats that as a suite failure.
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -641,4 +653,30 @@ set -e
|| die "S11: expected exit 2 (connect refused), got $reuse_exit" || die "S11: expected exit 2 (connect refused), got $reuse_exit"
log "S11 OK" log "S11 OK"
log "All eleven message-bus scenarios passed." # ── Scenario 12 — Job topic is admin-only ───────────────────────────────────
# `Topic::Job("<name>")` maps to `AuthzCheck::RoleAdmin` in
# `application/ports/message_bus_ports.rs::required_perm`, and
# `handle_subscribe` denies any caller whose snapshotted role at
# session open is not "admin". user1 is a plain account, so this
# subscribe MUST land on the deny arm.
#
# The wire response uses `topic_forbidden` (same shape as an unknown
# topic — anti-enumeration: a non-admin cannot probe which job names
# are registered). The audit reason `role_denied` is asserted at the
# Rust unit-test layer.
#
# If this ever accepts and delivers events, someone weakened the
# Class-3 gate (dropped the role-snapshot check in the Job arm,
# widened `required_perm`, or reused a permissive dispatch branch).
log "S12: user1 subscribes to job:whatever; expect topic_forbidden."
if ! "$HELPER_BIN" expect-denied \
--url "$ws_url" \
--token "$user1_token" \
--subscribe "job:whatever" \
--reason topic_forbidden \
--timeout 3s; then
die "S12: user1 was NOT denied on job topic (admin gate broken?)"
fi
log "S12 OK"
log "All twelve message-bus scenarios passed."