From ad9eab6f92e0b842da519a46b1e73d4427e01547 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 11 Sep 2026 00:33:45 +0200 Subject: [PATCH] refactor(msg-bus): prefer explicit enum on AsyncAPI error --- .../lib/generated/message-bus/RtErrorCode.ts | 14 ----- .../generated/message-bus/RtErrorObject.ts | 3 +- frontend/src/lib/message-bus/error-codes.ts | 55 +++++++++++++++++++ src/bin/generate-asyncapi.rs | 51 ++++++++++++----- 4 files changed, 93 insertions(+), 30 deletions(-) delete mode 100644 frontend/src/lib/generated/message-bus/RtErrorCode.ts create mode 100644 frontend/src/lib/message-bus/error-codes.ts diff --git a/frontend/src/lib/generated/message-bus/RtErrorCode.ts b/frontend/src/lib/generated/message-bus/RtErrorCode.ts deleted file mode 100644 index 967c4a18..00000000 --- a/frontend/src/lib/generated/message-bus/RtErrorCode.ts +++ /dev/null @@ -1,14 +0,0 @@ -enum RtErrorCode { - MINUS_32001 = -32001, - MINUS_32002 = -32002, - MINUS_32003 = -32003, - MINUS_32004 = -32004, - MINUS_32005 = -32005, - MINUS_32006 = -32006, - MINUS_32007 = -32007, - MINUS_32603 = -32603, - MINUS_32600 = -32600, - MINUS_32601 = -32601, - MINUS_32602 = -32602 -} -export type { RtErrorCode as default }; diff --git a/frontend/src/lib/generated/message-bus/RtErrorObject.ts b/frontend/src/lib/generated/message-bus/RtErrorObject.ts index 773876f2..6c4b6eb2 100644 --- a/frontend/src/lib/generated/message-bus/RtErrorObject.ts +++ b/frontend/src/lib/generated/message-bus/RtErrorObject.ts @@ -1,9 +1,8 @@ -import type RtErrorCode from './RtErrorCode'; import type RtErrorMessage from './RtErrorMessage'; // AUTO-GENERATED — do not edit by hand. // Regenerate with `just asyncapi-ts`. interface RtErrorObject { - code: RtErrorCode; + code: number; data?: unknown; message: RtErrorMessage; } diff --git a/frontend/src/lib/message-bus/error-codes.ts b/frontend/src/lib/message-bus/error-codes.ts new file mode 100644 index 00000000..766e338c --- /dev/null +++ b/frontend/src/lib/message-bus/error-codes.ts @@ -0,0 +1,55 @@ +// Named constants for JSON-RPC 2.0 error codes emitted on the message-bus +// WebSocket wire. Mirrors `application/ports/message_bus_ports.rs::error_code` +// on the server — the Rust module is the source of truth. +// +// Hand-written, deliberately not generated: Modelina projects a JSON-Schema +// `enum` of numeric values into a TS enum with mangled member names +// (`MINUS_32001 = -32001`), which reads worse than no enum at all. The wire +// type is just `number`; readable name-to-code lookup lives here. +// +// Values are frozen across releases — a new denial cause gets a new value, +// never repurposes an existing one. Adding a code: bump the Rust module and +// this file in the same commit; the wire spec's description text is a +// derivative of the Rust constants (see `generate-asyncapi.rs`). + +/** + * Application-defined codes live in the JSON-RPC 2.0 server-defined range + * `-32099..-32000`; standard envelope codes live in `-32700..-32600`. + */ +export const RtErrorCode = { + // ── Application-defined (subscribe / edit path denials) ───────────── + /** Resource-scoped topic, caller lacks Read on the resource (or the + * resource does not exist — the two outcomes are indistinguishable to + * the caller by design, to preserve anti-enumeration). */ + NO_READ: -32001, + /** Resource-scoped topic requires Share, caller has Read but not Share. + * Applies to `file:{id}:shares` (Phase B). */ + NO_SHARE: -32002, + /** Resource-scoped topic requires Comment (`file:{id}:comments`, Phase B). */ + NO_COMMENT: -32003, + /** Identity-scoped mismatch, OR unknown/malformed topic. Same wire code + * regardless of whether the target exists — anti-enum. */ + TOPIC_FORBIDDEN: -32004, + /** Per-connection subscription cap hit. */ + SUB_LIMIT: -32005, + /** Subscribe-frame token bucket exhausted. */ + RATE_LIMITED: -32006, + /** CRDT edit frame from a caller without Edit on the doc. Emitted as an + * `rt.write_denied` notification (not tied to a request id). */ + NO_EDIT: -32007, + + // ── JSON-RPC 2.0 standard envelope codes ──────────────────────────── + /** Server-side failure the client should retry. */ + INTERNAL_ERROR: -32603, + /** Malformed JSON-RPC envelope (missing `method`, wrong `jsonrpc` version). */ + INVALID_REQUEST: -32600, + /** Method outside the `rt.*` allow-list. */ + METHOD_NOT_FOUND: -32601, + /** Method known but `params` shape wrong (missing `topic`, unparseable). */ + INVALID_PARAMS: -32602 +} as const satisfies Record; + +/** Union of every named code's numeric value. Narrows a bare `number` on + * `RtErrorObject.code` to the eleven known literals for exhaustive + * `switch` blocks. */ +export type RtErrorCodeValue = (typeof RtErrorCode)[keyof typeof RtErrorCode]; diff --git a/src/bin/generate-asyncapi.rs b/src/bin/generate-asyncapi.rs index 91337f84..769a62d6 100644 --- a/src/bin/generate-asyncapi.rs +++ b/src/bin/generate-asyncapi.rs @@ -473,22 +473,45 @@ fn rpc_error_object_schema() -> Value { } 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": "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, - ], + "description": full_description, }) }