2026-09-11 00:25:25 +02:00
//! AsyncAPI 3.0 spec generator for the message bus.
2026-09-10 01:04:01 +02:00
//!
//! Mirrors `generate-openapi.rs`: constructs the spec from the same
2026-09-11 00:25:25 +02:00
//! Rust enums the server uses (`Topic`, `MessageBusEvent`, JSON-RPC
2026-09-10 01:04:01 +02:00
//! 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 ;
2026-09-11 00:25:25 +02:00
use oxicloud ::application ::ports ::message_bus_ports ::error_code ;
2026-09-10 01:04:01 +02:00
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" : {
2026-09-11 00:25:25 +02:00
"title" : "OxiCloud message bus" ,
2026-09-10 01:04:01 +02:00
"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" },
},
2026-09-10 01:33:37 +02:00
// 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" ,
2026-09-10 01:04:01 +02:00
"servers" : {
"default" : {
"host" : "{host}" ,
"pathname" : "/api/rt/ws" ,
"protocol" : "wss" ,
2026-09-11 00:25:25 +02:00
"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`." ,
2026-09-10 01:04:01 +02:00
"variables" : {
"host" : {
"description" : "Server host — replace with the deployment domain" ,
"default" : "cloud.example.com" ,
},
},
"protocolVersion" : "13" ,
// Subprotocol advertised in the WS handshake. Handler
// accepts `oxi.rt.v1` and the optional bearer element
// `authorization.bearer.<jwt>` alongside it.
"bindings" : {
"ws" : { "subProtocol" : "oxi.rt.v1" }
},
2026-09-10 01:33:37 +02:00
// Every request MUST be authenticated. Programmatic
// clients set `Authorization: Bearer <jwt>` on the WS
// upgrade (same header the REST API uses); browser
// clients — which can't set headers on `new WebSocket()`
// — will use the deferred ticket flow (a plain HTTP
// POST issues a short-lived one-shot ticket bound to
// the WS URL, see the plan's DPoP-gap section).
"security" : [
{ "$ref" : "#/components/securitySchemes/bearerAuth" }
],
2026-09-10 01:04:01 +02:00
}
},
"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" },
2026-09-10 01:33:37 +02:00
"PongResponse" : { "$ref" : "#/components/messages/RtPongResponse" },
2026-09-10 01:04:01 +02:00
"SubscribedResponse" : { "$ref" : "#/components/messages/RtSubscribedResponse" },
"ErrorResponse" : { "$ref" : "#/components/messages/RtErrorResponse" },
"FolderEvent" : { "$ref" : "#/components/messages/RtFolderEventNotification" },
2026-09-10 08:36:49 +02:00
"RevokedNotification" : { "$ref" : "#/components/messages/RtRevokedNotification" },
2026-09-10 01:04:01 +02:00
}
},
"UserAuthz" : {
"address" : "user:{userId}:authz" ,
"description" : "A user's private AuthZ-change channel. Identity-scoped: caller_id must equal userId (no admin bypass)." ,
"parameters" : {
"userId" : { "description" : "User UUID — must match the authenticated caller" }
},
"messages" : {
"SubscribeRequest" : { "$ref" : "#/components/messages/RtSubscribeRequest" },
"UnsubscribeRequest" : { "$ref" : "#/components/messages/RtUnsubscribeRequest" },
}
}
})
}
fn operations () -> Value {
json! ({
"subscribeFolder" : {
"action" : "send" ,
"channel" : { "$ref" : "#/channels/Folder" },
"summary" : "Subscribe to a folder's mutation stream" ,
"messages" : [
{ "$ref" : "#/channels/Folder/messages/SubscribeRequest" }
],
"reply" : {
"channel" : { "$ref" : "#/channels/Folder" },
"messages" : [
{ "$ref" : "#/channels/Folder/messages/SubscribedResponse" },
{ "$ref" : "#/channels/Folder/messages/ErrorResponse" },
]
}
},
"unsubscribeFolder" : {
"action" : "send" ,
"channel" : { "$ref" : "#/channels/Folder" },
"summary" : "Unsubscribe from a folder's mutation stream" ,
"messages" : [
{ "$ref" : "#/channels/Folder/messages/UnsubscribeRequest" }
]
},
"receiveFolderEvent" : {
"action" : "receive" ,
"channel" : { "$ref" : "#/channels/Folder" },
"summary" : "Server-pushed `rt.event` notification for a folder mutation" ,
"messages" : [
{ "$ref" : "#/channels/Folder/messages/FolderEvent" }
]
2026-09-10 01:33:37 +02:00
},
2026-09-10 08:36:49 +02:00
"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" }
]
},
2026-09-10 01:33:37 +02:00
// Application-layer keepalive. Separate from the RFC 6455 Ping
// control frame the server sends on `OXICLOUD_RT_WS_KEEPALIVE_SECONDS`
// (which is transport-level and not modelled in AsyncAPI). This
// operation lets a client actively confirm the socket is
// end-to-end alive when transport-level Pings alone can't rule
// out a proxy black-hole.
"ping" : {
"action" : "send" ,
"channel" : { "$ref" : "#/channels/Folder" },
"summary" : "Application-level keepalive; `rt.pong` reply confirms end-to-end liveness" ,
"messages" : [
{ "$ref" : "#/channels/Folder/messages/PingRequest" }
],
"reply" : {
"channel" : { "$ref" : "#/channels/Folder" },
"messages" : [
{ "$ref" : "#/channels/Folder/messages/PongResponse" }
]
}
2026-09-10 01:04:01 +02:00
}
})
}
fn components () -> Value {
2026-09-10 21:33:24 +02:00
let mut components = json! ({
2026-09-10 01:04:01 +02:00
"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" },
},
2026-09-10 01:33:37 +02:00
"RtPongResponse" : {
"name" : "rt.pong" ,
"title" : "Reply to rt.ping — `result.pong == true`" ,
"contentType" : "application/json" ,
"payload" : { "$ref" : "#/components/schemas/RtPongResponseBody" },
},
2026-09-10 01:04:01 +02:00
// ── Notifications (server → client) ─────────────────────
"RtFolderEventNotification" : {
"name" : "rt.event" ,
"title" : "Folder mutation event" ,
"contentType" : "application/json" ,
"payload" : { "$ref" : "#/components/schemas/RtFolderEventBody" },
2026-09-10 08:36:49 +02:00
},
"RtRevokedNotification" : {
"name" : "rt.revoked" ,
"title" : "Subscription evicted" ,
"contentType" : "application/json" ,
"payload" : { "$ref" : "#/components/schemas/RtRevokedBody" },
2026-09-10 01:04:01 +02:00
}
},
"schemas" : {
2026-09-10 21:33:24 +02:00
// 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 ),
2026-09-10 01:04:01 +02:00
"RtSuccessResponseBody" : rpc_success_response_schema (),
2026-09-10 01:33:37 +02:00
"RtPongResponseBody" : rpc_pong_response_schema (),
2026-09-10 01:04:01 +02:00
"RtErrorResponseBody" : rpc_error_response_schema (),
"RtFolderEventBody" : folder_event_notification_schema (),
2026-09-10 08:36:49 +02:00
"RtRevokedBody" : revoked_notification_schema (),
2026-09-10 21:33:24 +02:00
// 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).
2026-09-10 01:04:01 +02:00
"FileCreatedData" : file_created_schema (),
2026-09-10 07:15:09 +02:00
"FileRenamedData" : file_renamed_schema (),
"FileMovedData" : file_moved_schema (),
"FileDeletedData" : file_deleted_schema (),
2026-09-10 01:04:01 +02:00
"FolderCreatedData" : folder_created_schema (),
2026-09-10 07:15:09 +02:00
"FolderRenamedData" : folder_renamed_schema (),
"FolderMovedData" : folder_moved_schema (),
"FolderDeletedData" : folder_deleted_schema (),
2026-09-10 01:33:37 +02:00
},
// 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. Browsers, which cannot set headers on `new WebSocket()`, will use the deferred ticket flow (`POST /api/rt/ticket` → short-lived one-shot ticket in the WS URL); see the plan's DPoP-gap section." ,
}
2026-09-10 01:04:01 +02:00
}
2026-09-10 21:33:24 +02:00
});
// 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 ));
}
2026-09-10 01:04:01 +02:00
}
// ─── Schema builders ────────────────────────────────────────────────────────
2026-09-10 21:33:24 +02:00
/// `$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 ;
}
2026-09-10 01:04:01 +02:00
json! ({
"type" : "object" ,
"required" : [ "jsonrpc" , "id" , "method" ],
2026-09-10 21:33:24 +02:00
"properties" : properties ,
2026-09-10 01:04:01 +02:00
})
}
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" ] },
2026-09-10 21:33:24 +02:00
// 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" ],
},
2026-09-10 01:04:01 +02:00
}
})
}
2026-09-10 01:33:37 +02:00
/// Reply to `rt.ping` — the shape pins `result.pong == true` so
2026-09-10 21:33:24 +02:00
/// contract tests can assert on it directly. `result` is hoisted to
/// [`RtPongResult`] so Modelina gets a named type.
2026-09-10 01:33:37 +02:00
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" ] },
2026-09-10 21:33:24 +02:00
"result" : ref_schema ( "RtPongResult" ),
}
})
}
fn rpc_pong_result_schema () -> Value {
json! ({
"type" : "object" ,
"required" : [ "pong" ],
"properties" : {
"pong" : { "type" : "boolean" , "const" : true }
2026-09-10 01:33:37 +02:00
}
})
}
2026-09-10 01:04:01 +02:00
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
2026-09-11 00:25:25 +02:00
// `application/ports/message_bus_ports.rs::error_code`. The inner
2026-09-10 21:33:24 +02:00
// error object is hoisted to `RtErrorObject` so Modelina emits a
// named type instead of `AnonymousSchema_N`.
2026-09-10 01:04:01 +02:00
json! ({
"type" : "object" ,
"required" : [ "jsonrpc" , "id" , "error" ],
"properties" : {
"jsonrpc" : { "type" : "string" , "const" : "2.0" },
"id" : { "type" : [ "integer" , "string" , "null" ] },
2026-09-10 21:33:24 +02:00
"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" ],
2026-09-10 01:04:01 +02:00
}
}
})
}
2026-09-10 21:33:24 +02:00
fn rpc_error_code_schema () -> Value {
json! ({
"type" : "integer" ,
"description" : "Stable integer error code. Values are frozen across releases — a new denial cause gets a new value, never repurposes an existing one." ,
"enum" : [
error_code ::NO_READ ,
error_code ::NO_SHARE ,
error_code ::NO_COMMENT ,
error_code ::TOPIC_FORBIDDEN ,
error_code ::SUB_LIMIT ,
error_code ::RATE_LIMITED ,
error_code ::NO_EDIT ,
error_code ::INTERNAL_ERROR ,
error_code ::INVALID_REQUEST ,
error_code ::METHOD_NOT_FOUND ,
error_code ::INVALID_PARAMS ,
],
})
}
fn rpc_error_message_schema () -> Value {
json! ({
"type" : "string" ,
"description" : "Stable wire vocabulary; matches the corresponding `code`." ,
"enum" : [
"no_read" , "no_share" , "no_comment" , "topic_forbidden" ,
"sub_limit" , "rate_limited" , "no_edit" ,
"internal_error" , "invalid_request" ,
"method_not_found" , "invalid_params" ,
],
})
}
2026-09-10 01:04:01 +02:00
fn folder_event_notification_schema () -> Value {
json! ({
"type" : "object" ,
2026-09-10 21:33:24 +02:00
"description" : "JSON-RPC notification (no `id`). `method = \" rt.event \" `. `params` is hoisted to `RtEventParams`." ,
2026-09-10 01:04:01 +02:00
"required" : [ "jsonrpc" , "method" , "params" ],
"properties" : {
"jsonrpc" : { "type" : "string" , "const" : "2.0" },
"method" : { "type" : "string" , "const" : "rt.event" },
2026-09-10 21:33:24 +02:00
"params" : ref_schema ( "RtEventParams" ),
2026-09-10 01:04:01 +02:00
}
})
}
2026-09-10 21:33:24 +02:00
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" ,
2026-09-11 00:25:25 +02:00
"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." ,
2026-09-10 21:33:24 +02:00
"enum" : [
"file_created" , "file_renamed" , "file_moved" , "file_deleted" ,
"folder_created" , "folder_renamed" , "folder_moved" , "folder_deleted" ,
],
})
}
fn event_data_union_schema () -> Value {
json! ({
"description" : "Tagged union of every possible `rt.event` payload. Discriminated by the sibling `event` field (see `RtEventKind`)." ,
"oneOf" : [
ref_schema ( "FileCreatedData" ),
ref_schema ( "FileRenamedData" ),
ref_schema ( "FileMovedData" ),
ref_schema ( "FileDeletedData" ),
ref_schema ( "FolderCreatedData" ),
ref_schema ( "FolderRenamedData" ),
ref_schema ( "FolderMovedData" ),
ref_schema ( "FolderDeletedData" ),
]
})
}
2026-09-10 01:04:01 +02:00
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" },
}
})
}
2026-09-10 07:15:09 +02:00
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" },
}
})
}
2026-09-10 01:04:01 +02:00
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" },
}
})
}
2026-09-10 07:15:09 +02:00
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" },
}
})
}
2026-09-10 08:36:49 +02:00
/// `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" ,
2026-09-10 21:33:24 +02:00
"description" : "JSON-RPC notification (no `id`). `method = \" rt.revoked \" `. `params` hoisted to `RtRevokedParams`." ,
2026-09-10 08:36:49 +02:00
"required" : [ "jsonrpc" , "method" , "params" ],
"properties" : {
"jsonrpc" : { "type" : "string" , "const" : "2.0" },
"method" : { "type" : "string" , "const" : "rt.revoked" },
2026-09-10 21:33:24 +02:00
"params" : ref_schema ( "RtRevokedParams" ),
}
})
}
fn revoked_params_schema () -> Value {
json! ({
"type" : "object" ,
"required" : [ "topic" , "reason" ],
"properties" : {
"topic" : { "type" : "string" },
"reason" : ref_schema ( "RtRevokedReason" ),
2026-09-10 08:36:49 +02:00
}
})
}
2026-09-10 21:33:24 +02:00
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" ,
]
})
}