proto: freeze the wire contract at 1.0.0

Schemas for the whole v1 surface: 38 methods, 9 events, 25 named types and the
JSON-RPC envelope, with x-privileged / x-transports / x-deadlineMs / x-errors
annotations that both generators emit as data rather than prose.

Four generators over one IR (contracts/codegen/schema_ir.py), so the C++ structs,
the TypeScript types and the OpenRPC document cannot disagree about what the
contract says:

  gen_cpp.py             -> core/generated/velox_proto.{hpp,cpp}
  gen_ts.py              -> extension/src/shared/protocol/
  gen_openrpc.py         -> contracts/openrpc.json
  gen_cpp_conformance.py -> tests/conformance/cpp/fixture_dispatcher.hpp

Inbound parsing never throws: parse<T>() returns std::expected<T, ParseError> and
nlohmann's throwing ADL from_json is deliberately not emitted. Schema constraints
(minimum, maxLength, pattern, ...) become real runtime checks in both languages —
the daemon does not trust the extension and the extension does not trust the
daemon.

59 golden fixtures: a success case per method, 12 error cases, 9 events. Replayed
by tests/conformance/ against both the generated C++ and a live server over both
transports. tools/mockd serves the same fixtures with unhappy-path flags so the
GUI and EXT lanes never wait for veloxd.

run.sh also proves capture.offer fails open: with a daemon answering slower than
750 ms the client gives up and lets Firefox take the download.

core/generated/ is libveloxproto, a separate target from libveloxcore, which
still never sees JSON — see docs/adr/0009.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
This commit is contained in:
2026-09-09 19:55:54 +04:00
co-authored by Claude Opus 5
parent a40585f419
commit 53421d6cb8
171 changed files with 29275 additions and 51 deletions
+45 -4
View File
@@ -3,21 +3,45 @@
**This directory is the interface between every lane.** Owner: agent **PROTO**.
Nobody else commits here. Everybody else *generates from* here.
> ## Status: **v1.0.0 — FROZEN** (2026-09-09)
>
> The surface below is complete and generated from: 38 methods, 9 events, 25 named types,
> 59 fixtures. See `docs/adr/0005-protocol-1.0.0-freeze.md` for the versioning rule.
>
> **What each lane can rely on, starting now:**
>
> | You need | It is here |
> |---|---|
> | C++ types, parsing, dispatch | `core/generated/velox_proto.{hpp,cpp}` (target `libveloxproto`) |
> | TypeScript types, typed client, runtime validators | `extension/src/shared/protocol/` |
> | The API document to read | `contracts/openrpc.json` |
> | A daemon to build against today | `tools/mockd` — both transports, 4 Hz progress, unhappy-path flags |
> | Proof you have not drifted | `./tests/conformance/run.sh` |
>
> **Changing this is a PR to `contracts/` alone.** Optional field or new method → minor.
> Rename, remove or retype → major, plus an ADR. File a request; do not add a field locally.
```
contracts/
├── VERSION # protocol semver, e.g. 1.0.0
├── VERSION # protocol semver — frozen at 1.0.0
├── openrpc.json # human-readable API doc (generated from schema/)
├── schema/
│ ├── envelope.schema.json # JSON-RPC 2.0 envelope + our error codes
│ ├── types/ # Task, Segment, Category, Queue, Settings, CaptureOffer
│ ├── types/ # Task, Segment, Category, Queue, Settings, CaptureRules
│ ├── methods/ # one file per method: params + result
│ └── events/ # one file per server→client notification
├── fixtures/ # golden request/response pairs, replayed by conformance
└── codegen/
├── gen_cpp.py # → core/generated/ (structs + to_json/from_json)
── gen_ts.py # → extension/src/shared/protocol/ (types + client)
├── schema_ir.py # the one loader/IR all generators share
── gen_cpp.py # → core/generated/ (structs + to_json + parse + dispatch)
├── gen_ts.py # → extension/src/shared/protocol/ (types, client, validators)
├── gen_openrpc.py # → contracts/openrpc.json
└── gen_cpp_conformance.py # → tests/conformance/cpp/fixture_dispatcher.hpp
```
Each subdirectory has its own README: `codegen/` documents the supported JSON Schema
subset, `fixtures/` documents the fixture shape and the placeholder rules.
## Rules
1. **Generated code is committed.** No lane may be blocked because it can't run Python.
@@ -32,6 +56,23 @@ contracts/
regenerated code + `VERSION` bump. Lanes rebase onto it. This is the only synchronization
point in the whole project — keep it cheap and frequent rather than big and rare.
## Per-method annotations
Every method schema carries these, and both generators emit them as data the code can act
on rather than as prose a reader has to honour:
| Key | Meaning |
|---|---|
| `x-privileged` | refused over the WebSocket transport with `-32003` |
| `x-transports` | which listeners serve it (`uds`, `ws`) |
| `x-deadlineMs` | how long a client waits before giving up |
| `x-errors` | the error codes this method is documented to return |
| `x-wsRestrictions` | extra limits when the call arrives from the extension |
19 of the 38 methods are privileged: everything that reconfigures the daemon, destroys user
data, or names an arbitrary destination path. The extension may *request* a download; it
may not choose where the bytes land.
## Transport framing
| Client | Transport | Framing |
+1 -1
View File
@@ -1 +1 @@
1.0.0-draft
1.0.0
+64
View File
@@ -0,0 +1,64 @@
# contracts/codegen — the generators
Four generators, one IR. Everything is derived from `contracts/schema/`; nothing here is
a second source of truth.
```
schema_ir.py loads schema/ and lowers it to a small IR
├── gen_cpp.py -> core/generated/velox_proto.{hpp,cpp}
├── gen_ts.py -> extension/src/shared/protocol/*.ts
├── gen_openrpc.py -> contracts/openrpc.json
└── gen_cpp_conformance.py -> tests/conformance/cpp/fixture_dispatcher.hpp
```
Regenerate everything:
```sh
for g in gen_cpp gen_ts gen_openrpc gen_cpp_conformance; do
python3 contracts/codegen/$g.py
done
```
`tests/conformance/check_contract.py` re-runs all four and fails if any committed output
differs, so stale generated code cannot be merged.
## The supported JSON Schema subset
The generators refuse to guess. Anything outside this subset raises `SchemaError` at
generation time rather than emitting subtly wrong code — a schema that cannot be generated
from is a contract bug, and it should stop the build.
| Supported | Emitted as |
|---|---|
| `object` + `properties` | struct / interface |
| `object` + typed `additionalProperties` | `std::map<std::string, T>` / `Record<string, T>` |
| `string` + `enum` | `enum class` / string-literal union |
| `integer` + `enum` + `x-enum` | `enum class : int32_t` / `as const` object |
| `array` + `items` | `std::vector<T>` / `T[]` |
| `$ref` to a `types/*.schema.json` | the named type |
| `["X", "null"]`, or `oneOf: [X, {type: null}]` | `std::optional<T>` / `T \| null` |
| `{}` | `nlohmann::json` / `unknown` |
| `minimum` `maximum` `minLength` `maxLength` `pattern` `minItems` `maxItems` | runtime checks in both languages |
Deliberately unsupported: `allOf`, `anyOf`, general `oneOf`, `patternProperties`, tuple
`items`, recursive types. If the contract needs one, extend `schema_ir.py` in the same PR
that needs it.
## Two rules the generated code follows
**Nothing throws on the inbound path.** `gen_cpp.py` emits `parse<T>() ->
std::expected<T, ParseError>` and deliberately does *not* emit nlohmann's ADL `from_json`,
whose failure mode is an exception. A malformed frame off the wire is an ordinary value the
RPC loop handles, not a throw unwinding through the daemon.
**Constraints are checked, not just documented.** A `maximum` in a schema becomes an `if`
in both languages. The daemon is not allowed to trust the extension and the extension is
not allowed to trust the daemon — `ws://127.0.0.1` is reachable by every local process, so
a type declaration proves nothing at runtime.
## Absent and null mean the same thing
Both generators treat a missing field and an explicit `null` identically. A client that
omits a nullable field and one that sends `null` get the same result, in both languages.
This is stated here because it is the kind of asymmetry that otherwise surfaces as a
cross-language bug six months later.
+604
View File
@@ -0,0 +1,604 @@
#!/usr/bin/env python3
"""Emit core/generated/velox_proto.{hpp,cpp} from contracts/schema/.
Design notes that matter to the CORE and DAEMON lanes:
* **No exceptions.** nlohmann's own throwing `get<T>()` / ADL `from_json` are deliberately
not used and not emitted. Parsing goes through `velox::proto::parse<T>(json)` which
returns `std::expected<T, ParseError>`, so a malformed frame from the wire is an ordinary
value the RPC loop handles, not a throw unwinding through the transfer path.
* **Serialisation is ADL `to_json`,** so `nlohmann::json j = task;` works as expected.
Only the outbound direction is allowed to be implicit; the wire is never trusted.
* **`libveloxproto`, not `libveloxcore`.** This code includes nlohmann/json, which
CLAUDE.md forbids inside core. It is a separate target that core and daemon both link;
the layering rule constrains `libveloxcore`, and `core/generated/` is not part of it.
See docs/adr/0009-generated-protocol-library.md.
Run: python3 contracts/codegen/gen_cpp.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from schema_ir import Contract, Field, TypeDef, TypeRef, load, pascal, topo_sorted # noqa: E402
OUT_DIR = Path(__file__).resolve().parent.parent.parent / "core" / "generated"
BANNER = """// ---------------------------------------------------------------------------
// GENERATED FILE — DO NOT EDIT.
//
// Source: contracts/schema/**
// Generator: contracts/codegen/gen_cpp.py
// Contract: v{version}
//
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
// python3 contracts/codegen/gen_cpp.py
// Only lane PROTO commits to contracts/.
// ---------------------------------------------------------------------------
"""
def cpp_type(ref: TypeRef) -> str:
if ref.kind == "named":
return ref.name or "void"
if ref.kind == "string":
return "std::string"
if ref.kind == "integer":
return "std::int64_t"
if ref.kind == "number":
return "double"
if ref.kind == "boolean":
return "bool"
if ref.kind == "json":
return "nlohmann::json"
if ref.kind == "array":
return f"std::vector<{cpp_type(ref.inner)}>"
if ref.kind == "map":
return f"std::map<std::string, {cpp_type(ref.inner)}>"
raise AssertionError(ref.kind)
def cpp_constraints(ref: TypeRef, var: str, path: str, indent: str) -> list[str]:
"""Range, length and pattern checks. The wire is untrusted: a `maximum` in the schema
has to be a check here, or -32602 would never fire for an out-of-range value."""
lim = ref.limits
if not lim:
return []
i = indent
o: list[str] = []
def bail(msg: str) -> str:
return f'return std::unexpected(ParseError{{std::string({path}), "{msg}"}});'
if ref.kind in ("integer", "number"):
if "minimum" in lim:
o.append(f'{i}if ({var} < {lim["minimum"]}) {bail("value is below the minimum of " + str(lim["minimum"]))}')
if "maximum" in lim:
o.append(f'{i}if ({var} > {lim["maximum"]}) {bail("value is above the maximum of " + str(lim["maximum"]))}')
elif ref.kind == "string":
if "minLength" in lim:
o.append(f'{i}if ({var}.size() < {lim["minLength"]}u) {bail("value is shorter than " + str(lim["minLength"]) + " characters")}')
if "maxLength" in lim:
o.append(f'{i}if ({var}.size() > {lim["maxLength"]}u) {bail("value is longer than " + str(lim["maxLength"]) + " characters")}')
if "pattern" in lim:
lit = json.dumps(lim["pattern"])
o.append(f"{i}{{")
o.append(f"{i} static const std::regex re({lit}, std::regex::ECMAScript);")
o.append(f'{i} if (!std::regex_match({var}, re)) {bail("value does not match the required pattern")}')
o.append(f"{i}}}")
elif ref.kind == "array":
if "minItems" in lim:
o.append(f'{i}if ({var}.size() < {lim["minItems"]}u) {bail("fewer than " + str(lim["minItems"]) + " items")}')
if "maxItems" in lim:
o.append(f'{i}if ({var}.size() > {lim["maxItems"]}u) {bail("more than " + str(lim["maxItems"]) + " items")}')
return o
def field_type(f: Field) -> str:
inner = cpp_type(f.type)
return f"std::optional<{inner}>" if f.optional else inner
def ident(name: str) -> str:
"""A JSON property name as a C++ member name."""
out = name.replace(".", "_").replace("-", "_")
reserved = {"class", "delete", "namespace", "operator", "template", "new", "auto"}
return out + "_" if out in reserved else out
def doc_comment(text: str, indent: str = "") -> list[str]:
if not text:
return []
lines: list[str] = []
words = text.split()
cur = ""
for w in words:
if len(cur) + len(w) + 1 > 92:
lines.append(cur)
cur = w
else:
cur = f"{cur} {w}".strip()
if cur:
lines.append(cur)
return [f"{indent}/// {ln}" for ln in lines]
def method_ident(name: str) -> str:
return pascal(name)
def handler_name(name: str) -> str:
return "on_" + name.replace(".", "_")
# ------------------------------------------------------------------ header
def emit_header(c: Contract) -> str:
o: list[str] = [BANNER.format(version=c.version), "#pragma once", ""]
o += [
"#include <cstdint>",
"#include <expected>",
"#include <map>",
"#include <optional>",
"#include <string>",
"#include <string_view>",
"#include <vector>",
"",
"#include <nlohmann/json.hpp>",
"",
"// This is libveloxproto, NOT libveloxcore. The layering rule in CLAUDE.md forbids",
"// JSON inside the engine; the engine does not link this target. See",
"// docs/adr/0009-generated-protocol-library.md.",
"namespace velox::proto {",
"",
f'inline constexpr std::string_view kProtocolVersion = "{c.version}";',
"",
"/// Why a payload could not be turned into a typed value. `path` is a JSON Pointer",
"/// into the offending document, so a conformance failure names the exact field.",
"struct ParseError {",
" std::string path;",
" std::string message;",
"};",
"",
"/// Every parse in this file returns one of these. Nothing here throws.",
"template <class T>",
"using Result = std::expected<T, ParseError>;",
"",
"/// Which listener a request arrived on. Decides whether a privileged method is",
"/// allowed: see `is_allowed_on`.",
"enum class Transport { Uds, Ws };",
"",
]
# ---- enums
for t in topo_sorted(c.types):
if t.kind == "string_enum":
o += doc_comment(t.doc)
o.append(f"enum class {t.name} {{")
for v in t.values:
o.append(f" {v.name}, // \"{v.wire}\"")
o.append("};")
o.append(f"std::string_view to_string({t.name} v) noexcept;")
o.append(f"Result<{t.name}> parse_{t.name}(std::string_view s);")
o.append("")
elif t.kind == "int_enum":
o += doc_comment(t.doc)
o.append(f"enum class {t.name} : std::int32_t {{")
for v in t.values:
o += doc_comment(v.doc, " ")
o.append(f" {v.name} = {v.wire},")
o.append("};")
o.append(f"std::string_view to_string({t.name} v) noexcept;")
o.append(f"std::optional<{t.name}> {t.name.lower()}_from_int(std::int32_t v) noexcept;")
o.append("")
elif t.kind == "map_alias":
o += doc_comment(t.doc)
o.append(f"using {t.name} = {cpp_type(t.alias)};")
o.append("")
# ---- structs
for t in topo_sorted(c.types):
if t.kind != "struct":
continue
o += doc_comment(t.doc)
o.append(f"struct {t.name} {{")
if not t.fields:
o.append(" // No fields: this method takes no parameters.")
for f in t.fields:
o += doc_comment(f.doc, " ")
o.append(f" {field_type(f)} {ident(f.name)}{{}};")
o.append("};")
o.append("")
o += [
"// --- serialisation ---------------------------------------------------------",
"// ADL hooks, so `nlohmann::json j = value;` works. Outbound only: there is no",
"// generated from_json, because nlohmann's inbound path throws and the wire is",
"// never trusted. Use parse<T> below.",
"",
]
for t in topo_sorted(c.types):
if t.kind in ("struct", "string_enum", "int_enum"):
o.append(f"void to_json(nlohmann::json& j, const {t.name}& v);")
o.append("")
o += [
"// --- parsing ---------------------------------------------------------------",
"",
"/// Turn an untrusted JSON value into a typed one. Specialised below for every",
"/// contract type; the primary template is intentionally not defined, so asking",
"/// for a type the contract does not have is a compile error, not a runtime one.",
"template <class T>",
"Result<T> parse(const nlohmann::json& j, std::string_view path = \"\");",
"",
]
for t in topo_sorted(c.types):
if t.kind in ("struct", "string_enum", "int_enum", "map_alias"):
o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view path);")
o.append("")
# ---- method / event enums
o += [
"// --- method surface --------------------------------------------------------",
"",
"/// Every method in the contract. Generated, so a daemon cannot answer a method",
"/// the contract does not define, and cannot silently fail to answer one it does.",
"enum class Method {",
]
for m in c.methods:
o.append(f" {method_ident(m.name)}, // {m.name}")
o += ["};", "",
"inline constexpr std::size_t kMethodCount = " + str(len(c.methods)) + ";",
"",
"std::string_view to_string(Method m) noexcept;",
"std::optional<Method> method_from_string(std::string_view s) noexcept;",
"",
"/// True for methods refused over the WebSocket transport with -32003. The",
"/// extension is not allowed to reconfigure the daemon or destroy user data.",
"bool is_privileged(Method m) noexcept;",
"bool is_allowed_on(Method m, Transport t) noexcept;",
"",
"/// The contract's answer deadline. capture.offer's 750 ms is the one that",
"/// matters: past it the extension has already let Firefox take the download.",
"std::int32_t deadline_ms(Method m) noexcept;",
""]
o += ["/// Server-to-client notifications.", "enum class Event {"]
for e in c.events:
o.append(f" {pascal(e.name[len('event.'):])}, // {e.name}")
o += ["};", "",
"std::string_view to_string(Event e) noexcept;",
"std::optional<Event> event_from_string(std::string_view s) noexcept;",
"",
"// --- dispatch --------------------------------------------------------------",
"",
"/// Build a JSON-RPC error response. `id` may be null for a request that could",
"/// not be parsed far enough to have one.",
"nlohmann::json make_error(const nlohmann::json& id, ErrorCode code, std::string_view message,",
" nlohmann::json data = nullptr);",
"nlohmann::json make_result(const nlohmann::json& id, nlohmann::json result);",
"nlohmann::json make_notification(Event e, nlohmann::json params);",
"",
"/// One virtual per method. The daemon implements this; `dispatch` below does the",
"/// envelope handling, the transport check and the parameter parsing, so a handler",
"/// only ever sees a validated, typed params struct.",
"class Dispatcher {",
"public:",
" virtual ~Dispatcher() = default;",
""]
for m in c.methods:
o += doc_comment(m.doc, " ")
o.append(f" virtual Result<{cpp_type(m.result)}> {handler_name(m.name)}(const {cpp_type(m.params)}& params) = 0;")
o.append("")
o += ["};", "",
"/// Parse one JSON-RPC request, route it, and return the response to write back.",
"/// Never throws. Returns a null json for a notification that needs no reply.",
"nlohmann::json dispatch(Dispatcher& handler, Transport transport, const nlohmann::json& request);",
"",
"} // namespace velox::proto",
""]
return "\n".join(o)
# ------------------------------------------------------------------ source
def emit_source(c: Contract) -> str:
o: list[str] = [BANNER.format(version=c.version),
'#include "velox_proto.hpp"', "", "#include <algorithm>", "#include <regex>", "",
"namespace velox::proto {", "", "namespace {", "",
'std::string join(std::string_view path, std::string_view key) {',
' std::string out(path);',
' out += "/";',
' out += key;',
' return out;',
'}', "", "} // namespace", ""]
# enum conversions
for t in topo_sorted(c.types):
if t.kind == "string_enum":
o.append(f"std::string_view to_string({t.name} v) noexcept {{")
o.append(" switch (v) {")
for v in t.values:
o.append(f' case {t.name}::{v.name}: return "{v.wire}";')
o.append(" }")
o.append(' return "";')
o.append("}")
o.append("")
o.append(f"Result<{t.name}> parse_{t.name}(std::string_view s) {{")
for v in t.values:
o.append(f' if (s == "{v.wire}") return {t.name}::{v.name};')
o.append(f' return std::unexpected(ParseError{{"", "not a valid {t.name}: \'" + std::string(s) + "\'"}});')
o.append("}")
o.append("")
o.append(f"void to_json(nlohmann::json& j, const {t.name}& v) {{ j = to_string(v); }}")
o.append("")
o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view path) {{")
o.append(' if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"});')
o.append(f" auto r = parse_{t.name}(j.get_ref<const std::string&>());")
o.append(" if (!r) return std::unexpected(ParseError{std::string(path), r.error().message});")
o.append(" return *r;")
o.append("}")
o.append("")
elif t.kind == "int_enum":
o.append(f"std::string_view to_string({t.name} v) noexcept {{")
o.append(" switch (v) {")
for v in t.values:
o.append(f' case {t.name}::{v.name}: return "{v.name}";')
o.append(" }")
o.append(' return "";')
o.append("}")
o.append("")
o.append(f"std::optional<{t.name}> {t.name.lower()}_from_int(std::int32_t v) noexcept {{")
o.append(" switch (v) {")
for v in t.values:
o.append(f" case {v.wire}: return {t.name}::{v.name};")
o.append(" default: return std::nullopt;")
o.append(" }")
o.append("}")
o.append("")
o.append(f"void to_json(nlohmann::json& j, const {t.name}& v) {{ j = static_cast<std::int32_t>(v); }}")
o.append("")
o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view path) {{")
o.append(' if (!j.is_number_integer()) return std::unexpected(ParseError{std::string(path), "expected an integer"});')
o.append(f" auto v = {t.name.lower()}_from_int(j.get<std::int32_t>());")
o.append(' if (!v) return std::unexpected(ParseError{std::string(path), "not a contract error code"});')
o.append(" return *v;")
o.append("}")
o.append("")
# map aliases: a parse specialisation only. The alias is a std::map, which nlohmann
# already serialises, so an emitted to_json here would be an ambiguous overload.
for t in topo_sorted(c.types):
if t.kind != "map_alias":
continue
o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view path) {{")
o += emit_value_parse(t.alias, "j", "out", "path", " ")
o.append(" return out;")
o.append("}")
o.append("")
# struct to_json / parse
for t in topo_sorted(c.types):
if t.kind != "struct":
continue
# A no-parameter method's struct has nothing to read, so the parameter goes
# unnamed: the project builds with -Wall -Wextra -Werror.
vname = "v" if t.fields else "/*v*/"
o.append(f"void to_json(nlohmann::json& j, const {t.name}& {vname}) {{")
o.append(" j = nlohmann::json::object();")
for f in t.fields:
m = ident(f.name)
if f.optional:
o.append(f' if (v.{m}.has_value()) j["{f.name}"] = *v.{m};')
if f.required:
o.append(f' else j["{f.name}"] = nullptr;')
else:
o.append(f' j["{f.name}"] = v.{m};')
o.append("}")
o.append("")
pname = "path" if t.fields else "/*path*/"
o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view {pname}) {{")
if t.fields:
o.append(' if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"});')
else:
o.append(' if (!j.is_object()) return std::unexpected(ParseError{"", "expected an object"});')
o.append(f" {t.name} out;")
for f in t.fields:
o += emit_field_parse(f, indent=" ")
o.append(" return out;")
o.append("}")
o.append("")
o += emit_tables(c)
o += emit_dispatch(c)
o += ["} // namespace velox::proto", ""]
return "\n".join(o)
def emit_value_parse(ref: TypeRef, src: str, dst: str, path: str, indent: str) -> list[str]:
"""Statements that parse json expression `src` into a fresh variable named `dst`."""
i = indent
o: list[str] = []
if ref.kind == "named":
o.append(f"{i}auto {dst}_r = parse<{ref.name}>({src}, {path});")
o.append(f"{i}if (!{dst}_r) return std::unexpected({dst}_r.error());")
o.append(f"{i}auto {dst} = std::move(*{dst}_r);")
elif ref.kind == "string":
o.append(f"{i}if (!{src}.is_string()) return std::unexpected(ParseError{{std::string({path}), \"expected a string\"}});")
o.append(f"{i}auto {dst} = {src}.get<std::string>();")
elif ref.kind == "integer":
o.append(f"{i}if (!{src}.is_number_integer()) return std::unexpected(ParseError{{std::string({path}), \"expected an integer\"}});")
o.append(f"{i}auto {dst} = {src}.get<std::int64_t>();")
elif ref.kind == "number":
o.append(f"{i}if (!{src}.is_number()) return std::unexpected(ParseError{{std::string({path}), \"expected a number\"}});")
o.append(f"{i}auto {dst} = {src}.get<double>();")
elif ref.kind == "boolean":
o.append(f"{i}if (!{src}.is_boolean()) return std::unexpected(ParseError{{std::string({path}), \"expected a boolean\"}});")
o.append(f"{i}auto {dst} = {src}.get<bool>();")
elif ref.kind == "json":
o.append(f"{i}auto {dst} = {src};")
elif ref.kind == "array":
et = cpp_type(ref.inner)
o.append(f"{i}if (!{src}.is_array()) return std::unexpected(ParseError{{std::string({path}), \"expected an array\"}});")
o.append(f"{i}std::vector<{et}> {dst};")
o.append(f"{i}{dst}.reserve({src}.size());")
o.append(f"{i}for (std::size_t idx = 0; idx < {src}.size(); ++idx) {{")
o.append(f'{i} const std::string ip = join({path}, std::to_string(idx));')
o += emit_value_parse(ref.inner, f"{src}[idx]", f"{dst}_e", "ip", i + " ")
o.append(f"{i} {dst}.push_back(std::move({dst}_e));")
o.append(f"{i}}}")
elif ref.kind == "map":
et = cpp_type(ref.inner)
o.append(f"{i}if (!{src}.is_object()) return std::unexpected(ParseError{{std::string({path}), \"expected an object\"}});")
o.append(f"{i}std::map<std::string, {et}> {dst};")
o.append(f"{i}for (const auto& [mk, mv] : {src}.items()) {{")
o.append(f'{i} const std::string mp = join({path}, mk);')
o += emit_value_parse(ref.inner, "mv", f"{dst}_e", "mp", i + " ")
o.append(f"{i} {dst}.emplace(mk, std::move({dst}_e));")
o.append(f"{i}}}")
else:
raise AssertionError(ref.kind)
o += cpp_constraints(ref, dst, path, indent)
return o
def emit_field_parse(f: Field, indent: str) -> list[str]:
i = indent
m = ident(f.name)
o = [f'{i}{{',
f'{i} const std::string fp = join(path, "{f.name}");',
f'{i} const auto it = j.find("{f.name}");']
if f.optional:
# Absent and null mean the same thing: the field is not set. A client that omits
# a nullable field and one that sends null are treated identically on purpose.
o.append(f"{i} if (it != j.end() && !it->is_null()) {{")
o += emit_value_parse(f.type, "(*it)", "val", "fp", i + " ")
o.append(f"{i} out.{m} = std::move(val);")
o.append(f"{i} }}")
else:
o.append(f"{i} if (it == j.end() || it->is_null())")
o.append(f'{i} return std::unexpected(ParseError{{fp, "required field is missing"}});')
o += emit_value_parse(f.type, "(*it)", "val", "fp", i + " ")
o.append(f"{i} out.{m} = std::move(val);")
o.append(f"{i}}}")
return o
def emit_tables(c: Contract) -> list[str]:
o = ["std::string_view to_string(Method m) noexcept {", " switch (m) {"]
for m in c.methods:
o.append(f' case Method::{method_ident(m.name)}: return "{m.name}";')
o += [" }", ' return "";', "}", ""]
o += ["std::optional<Method> method_from_string(std::string_view s) noexcept {"]
for m in c.methods:
o.append(f' if (s == "{m.name}") return Method::{method_ident(m.name)};')
o += [" return std::nullopt;", "}", ""]
o += ["bool is_privileged(Method m) noexcept {", " switch (m) {"]
for m in c.methods:
o.append(f" case Method::{method_ident(m.name)}: return {str(m.privileged).lower()};")
o += [" }", " return true; // unknown means refuse", "}", ""]
o += ["bool is_allowed_on(Method m, Transport t) noexcept {", " switch (m) {"]
for m in c.methods:
uds = "true" if "uds" in m.transports else "false"
ws = "true" if "ws" in m.transports else "false"
o.append(f" case Method::{method_ident(m.name)}: return t == Transport::Uds ? {uds} : {ws};")
o += [" }", " return false;", "}", ""]
o += ["std::int32_t deadline_ms(Method m) noexcept {", " switch (m) {"]
for m in c.methods:
o.append(f" case Method::{method_ident(m.name)}: return {m.deadline_ms};")
o += [" }", " return 5000;", "}", ""]
o += ["std::string_view to_string(Event e) noexcept {", " switch (e) {"]
for e in c.events:
o.append(f' case Event::{pascal(e.name[len("event."):])}: return "{e.name}";')
o += [" }", ' return "";', "}", ""]
o += ["std::optional<Event> event_from_string(std::string_view s) noexcept {"]
for e in c.events:
o.append(f' if (s == "{e.name}") return Event::{pascal(e.name[len("event."):])};')
o += [" return std::nullopt;", "}", ""]
return o
def emit_dispatch(c: Contract) -> list[str]:
o = [
"nlohmann::json make_error(const nlohmann::json& id, ErrorCode code, std::string_view message,",
" nlohmann::json data) {",
" nlohmann::json err = {{\"code\", static_cast<std::int32_t>(code)}, {\"message\", std::string(message)}};",
" if (!data.is_null()) err[\"data\"] = std::move(data);",
" return {{\"jsonrpc\", \"2.0\"}, {\"id\", id}, {\"error\", std::move(err)}};",
"}",
"",
"nlohmann::json make_result(const nlohmann::json& id, nlohmann::json result) {",
" return {{\"jsonrpc\", \"2.0\"}, {\"id\", id}, {\"result\", std::move(result)}};",
"}",
"",
"nlohmann::json make_notification(Event e, nlohmann::json params) {",
" return {{\"jsonrpc\", \"2.0\"}, {\"method\", std::string(to_string(e))}, {\"params\", std::move(params)}};",
"}",
"",
"nlohmann::json dispatch(Dispatcher& handler, Transport transport, const nlohmann::json& request) {",
" const nlohmann::json id = request.contains(\"id\") ? request.at(\"id\") : nlohmann::json(nullptr);",
"",
" if (!request.is_object() || request.value(\"jsonrpc\", \"\") != \"2.0\" || !request.contains(\"method\"))",
" return make_error(id, ErrorCode::InvalidRequest, \"not a JSON-RPC 2.0 request\");",
" if (!request.at(\"method\").is_string())",
" return make_error(id, ErrorCode::InvalidRequest, \"method must be a string\");",
"",
" const auto method = method_from_string(request.at(\"method\").get_ref<const std::string&>());",
" if (!method)",
" return make_error(id, ErrorCode::MethodNotFound, \"no such method\");",
" if (!is_allowed_on(*method, transport))",
" return make_error(id, ErrorCode::TransportForbidden,",
" \"method is not permitted on this transport\");",
"",
" const nlohmann::json params =",
" request.contains(\"params\") ? request.at(\"params\") : nlohmann::json::object();",
"",
" switch (*method) {",
]
for m in c.methods:
pt, rt = cpp_type(m.params), cpp_type(m.result)
o += [
f" case Method::{method_ident(m.name)}: {{",
f' auto p = parse<{pt}>(params, "params");',
" if (!p)",
" return make_error(id, ErrorCode::InvalidParams, p.error().message,",
' nlohmann::json{{"path", p.error().path}});',
f" auto r = handler.{handler_name(m.name)}(*p);",
" if (!r)",
" return make_error(id, ErrorCode::InternalError, r.error().message,",
' nlohmann::json{{"path", r.error().path}});',
" nlohmann::json out = *r;",
" return make_result(id, std::move(out));",
" }",
]
o += [" }", "",
" return make_error(id, ErrorCode::MethodNotFound, \"no such method\");",
"}", ""]
return o
def main() -> int:
c = load()
OUT_DIR.mkdir(parents=True, exist_ok=True)
(OUT_DIR / "velox_proto.hpp").write_text(emit_header(c))
(OUT_DIR / "velox_proto.cpp").write_text(emit_source(c))
print(f"gen_cpp: {len(c.types)} types, {len(c.methods)} methods, {len(c.events)} events -> {OUT_DIR}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Emit tests/conformance/cpp/fixture_dispatcher.hpp.
velox::proto::Dispatcher has one pure virtual per method, on purpose: a daemon that
forgets to implement a method does not compile. That is exactly what a conformance runner
needs, and also what makes one tedious to hand-write — so it is generated.
The dispatcher answers each method from that method's golden fixture, which lets the C++
side exercise the real dispatch path: envelope handling, the transport check, parameter
parsing, and result serialisation.
Run: python3 contracts/codegen/gen_cpp_conformance.py
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from gen_cpp import BANNER, cpp_type, handler_name # noqa: E402
from schema_ir import load # noqa: E402
OUT = Path(__file__).resolve().parent.parent.parent / "tests" / "conformance" / "cpp" / "fixture_dispatcher.hpp"
def main() -> int:
c = load()
o = [BANNER.format(version=c.version), "#pragma once", "",
'#include "velox_proto.hpp"', "",
"#include <functional>", "#include <string>", "", "namespace velox::conformance {", "",
"/// Answers every method from its golden fixture, so the generated dispatch path",
"/// itself is under test: envelope, transport check, param parse, result serialise.",
"class FixtureDispatcher final : public proto::Dispatcher {",
"public:",
" /// `results` maps a method name to that method's golden result JSON.",
" explicit FixtureDispatcher(std::function<const nlohmann::json*(const std::string&)> results)",
" : results_(std::move(results)) {}",
""]
for m in c.methods:
# Every method's params and result is a named struct, and this class lives in
# velox::conformance, so the names need qualifying.
pt, rt = "proto::" + cpp_type(m.params), "proto::" + cpp_type(m.result)
o += [
f" proto::Result<{rt}> {handler_name(m.name)}(const {pt}& params) override {{",
" (void)params;",
f' return golden<{rt}>("{m.name}");',
" }",
"",
]
o += [
"private:",
" template <class T>",
" proto::Result<T> golden(const std::string& method) {",
" const nlohmann::json* value = results_(method);",
" if (value == nullptr)",
' return std::unexpected(proto::ParseError{method, "no fixture for this method"});',
" return proto::parse<T>(*value, method);",
" }",
"",
" std::function<const nlohmann::json*(const std::string&)> results_;",
"};",
"",
"} // namespace velox::conformance",
"",
]
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text("\n".join(o))
print(f"gen_cpp_conformance: {len(c.methods)} handlers -> {OUT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Emit contracts/openrpc.json from contracts/schema/.
This is the document humans read. It is generated, not written, so it cannot drift from
the schemas the code is generated from — the failure mode where the docs say one thing and
the wire does another is designed out rather than policed.
JSON-RPC named parameters are modelled as OpenRPC `by-name` params: each property of a
method's params object becomes one entry, which is what a reader expects to see.
Server-to-client notifications are not expressible in OpenRPC 1.2, so they are emitted
under a top-level `x-events` key alongside their payload schemas.
Run: python3 contracts/codegen/gen_openrpc.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from schema_ir import ID_PREFIX, SCHEMA_ROOT, Loader # noqa: E402
ROOT = SCHEMA_ROOT.parent
OUT = ROOT / "openrpc.json"
STRIP = {"$schema", "$id", "title"}
def rewrite(node: object) -> object:
"""Point every $ref at #/components/schemas/<Name> and drop per-file keywords."""
if isinstance(node, list):
return [rewrite(n) for n in node]
if not isinstance(node, dict):
return node
out: dict[str, object] = {}
for k, v in node.items():
if k == "$ref" and isinstance(v, str):
if v.startswith("#/$defs/"):
# envelope.schema.json's internal refs land under the x-envelope key.
out["$ref"] = "#/x-envelope/" + v[len("#/$defs/"):]
continue
if not v.startswith(ID_PREFIX + "types/"):
raise SystemExit(f"openrpc: unexpected $ref target {v}")
name = v[len(ID_PREFIX + "types/"):].removesuffix(".schema.json")
out["$ref"] = f"#/components/schemas/{name}"
continue
if k in STRIP:
continue
out[k] = rewrite(v)
return out
def main() -> int:
loader = Loader()
version = (ROOT / "VERSION").read_text().strip()
schemas: dict[str, object] = {}
for sid, doc in sorted(loader.by_id.items()):
if "/types/" not in sid:
continue
name = doc["title"]
body = rewrite({k: v for k, v in doc.items() if k not in STRIP})
assert isinstance(body, dict)
body["title"] = name
schemas[name] = body
methods = []
for sid, doc in sorted(loader.by_id.items()):
if "/methods/" not in sid:
continue
params_schema = doc["properties"]["params"]
# Expand a params object into by-name entries. A $ref'd params object is resolved
# first so the reader sees the fields, not just a type name.
resolved = params_schema
if "$ref" in resolved:
resolved = loader.by_id[resolved["$ref"]]
params = []
required = set(resolved.get("required", []))
for prop, sub in resolved.get("properties", {}).items():
entry: dict[str, object] = {"name": prop, "schema": rewrite(sub)}
if prop in required:
entry["required"] = True
if isinstance(sub, dict) and sub.get("description"):
entry["description"] = sub["description"]
params.append(entry)
method: dict[str, object] = {
"name": doc["title"],
"summary": doc.get("description", "").split(".")[0] + ".",
"description": doc.get("description", ""),
"paramStructure": "by-name",
"params": params,
"result": {"name": f"{doc['title']}Result", "schema": rewrite(doc["properties"]["result"])},
"x-privileged": doc.get("x-privileged", False),
"x-transports": doc.get("x-transports", []),
"x-deadlineMs": doc.get("x-deadlineMs"),
}
if doc.get("x-errors"):
code_doc = {e["value"]: e["doc"] for e in
loader.by_id[ID_PREFIX + "types/ErrorCode.schema.json"]["x-enum"]}
method["errors"] = [{"code": c, "message": code_doc.get(c, "")} for c in doc["x-errors"]]
if doc.get("x-wsRestrictions"):
method["x-wsRestrictions"] = doc["x-wsRestrictions"]
methods.append(method)
events = []
for sid, doc in sorted(loader.by_id.items()):
if "/events/" not in sid:
continue
events.append({
"name": doc["title"],
"description": doc.get("description", ""),
"params": rewrite(doc["properties"]["params"]),
"x-maxRateHz": doc.get("x-maxRateHz"),
})
envelope = loader.by_id[ID_PREFIX + "envelope.schema.json"]
out = {
"openrpc": "1.2.6",
"info": {
"title": "Velox Download Manager",
"version": version,
"description": (
"The wire contract between veloxd and every client: the Qt GUI, the CLI, "
"the native-messaging host and the Firefox extension. One JSON-RPC 2.0 "
"payload set over four framings; only the framing differs.\n\n"
"GENERATED from contracts/schema/ by contracts/codegen/gen_openrpc.py. "
"Do not edit by hand."
),
"license": {"name": "See repository LICENSE"},
},
"servers": [
{"name": "unix-socket", "url": "unix:$XDG_RUNTIME_DIR/velox/velox.sock",
"description": "NDJSON. GUI, CLI and nmhost. Peer credentials checked via SO_PEERCRED; same UID only, no token."},
{"name": "loopback-ws", "url": "ws://127.0.0.1:52000",
"description": "One JSON message per text frame. Extension fallback. Bound to 127.0.0.1 only, Origin-checked, token-authenticated, rate-limited. Port is the first free one in 52000-52016."},
],
"methods": methods,
"components": {"schemas": schemas},
"x-events": events,
"x-envelope": rewrite(envelope.get("$defs", {})),
"x-transports": {
"uds": "Unix domain socket, newline-delimited JSON.",
"ws": "Loopback WebSocket, one JSON message per text frame. Privileged methods are refused here with -32003.",
},
}
OUT.write_text(json.dumps(out, indent=2) + "\n")
print(f"gen_openrpc: {len(methods)} methods, {len(events)} events, {len(schemas)} schemas -> {OUT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+619
View File
@@ -0,0 +1,619 @@
#!/usr/bin/env python3
"""Emit extension/src/shared/protocol/ from contracts/schema/.
What the EXT and GUI-adjacent lanes get:
* `types.ts` — every contract type as a TS interface or string-literal union.
* `methods.ts` — the `MethodMap`, a typed `call<M>()` signature, and per-method
metadata (privileged, transports, deadlineMs). capture.offer's
750 ms budget is a generated constant, not a number typed twice.
* `events.ts` — event payload types and a discriminated union of notifications.
* `validate.ts` — runtime validators for everything crossing the wire.
* `index.ts` — the public surface.
The validators exist because **the extension is not allowed to trust the daemon and the
daemon is not allowed to trust the extension.** A `ws://127.0.0.1` socket is reachable by
any local process, so a TypeScript type alone proves nothing at runtime.
Run: python3 contracts/codegen/gen_ts.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from schema_ir import Contract, Field, TypeDef, TypeRef, load, pascal, topo_sorted # noqa: E402
OUT_DIR = Path(__file__).resolve().parent.parent.parent / "extension" / "src" / "shared" / "protocol"
BANNER = """// ---------------------------------------------------------------------------
// GENERATED FILE — DO NOT EDIT.
//
// Source: contracts/schema/**
// Generator: contracts/codegen/gen_ts.py
// Contract: v{version}
//
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
// python3 contracts/codegen/gen_ts.py
// Only lane PROTO commits to contracts/.
// ---------------------------------------------------------------------------
"""
IDENT_OK = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$")
def prop_key(name: str) -> str:
"""Property names such as `general.launchOnLogin` must be quoted."""
if name and name[0].isalpha() and all(ch in IDENT_OK for ch in name):
return name
return json.dumps(name)
def ts_type(ref: TypeRef) -> str:
if ref.kind == "named":
return ref.name or "never"
if ref.kind == "string":
return "string"
if ref.kind in ("integer", "number"):
return "number"
if ref.kind == "boolean":
return "boolean"
if ref.kind == "json":
return "unknown"
if ref.kind == "array":
inner = ts_type(ref.inner)
return f"Array<{inner}>" if not inner.isidentifier() else f"{inner}[]"
if ref.kind == "map":
return f"Record<string, {ts_type(ref.inner)}>"
raise AssertionError(ref.kind)
def doc_block(text: str, indent: str = "") -> list[str]:
if not text:
return []
words, lines, cur = text.split(), [], ""
for w in words:
if len(cur) + len(w) + 1 > 88:
lines.append(cur)
cur = w
else:
cur = f"{cur} {w}".strip()
if cur:
lines.append(cur)
if len(lines) == 1:
return [f"{indent}/** {lines[0]} */"]
return [f"{indent}/**"] + [f"{indent} * {ln}" for ln in lines] + [f"{indent} */"]
def event_ident(name: str) -> str:
return pascal(name[len("event."):]) + "Event"
def method_key(name: str) -> str:
return json.dumps(name)
# ------------------------------------------------------------------- types.ts
def emit_types(c: Contract) -> str:
o = [BANNER.format(version=c.version), ""]
o.append(f'export const PROTOCOL_VERSION = {json.dumps(c.version)};')
o.append("")
for t in topo_sorted(c.types):
if t.kind == "string_enum":
o += doc_block(t.doc)
union = " | ".join(json.dumps(v.wire) for v in t.values)
o.append(f"export type {t.name} = {union};")
o.append(f"export const {upper_snake(t.name)}_VALUES = [")
for v in t.values:
o.append(f" {json.dumps(v.wire)},")
o.append(f"] as const satisfies readonly {t.name}[];")
o.append("")
elif t.kind == "int_enum":
o += doc_block(t.doc)
o.append(f"export const {t.name} = {{")
for v in t.values:
if v.doc:
o += doc_block(v.doc, " ")
o.append(f" {v.name}: {v.wire},")
o.append("} as const;")
o.append(f"export type {t.name} = (typeof {t.name})[keyof typeof {t.name}];")
o.append("")
elif t.kind == "map_alias":
o += doc_block(t.doc)
o.append(f"export type {t.name} = {ts_type(t.alias)};")
o.append("")
elif t.kind == "struct":
o += doc_block(t.doc)
if not t.fields:
o.append(f"/** No parameters. */")
o.append(f"export type {t.name} = Record<string, never>;")
o.append("")
continue
o.append(f"export interface {t.name} {{")
for f in t.fields:
o += doc_block(f.doc, " ")
opt = "?" if not f.required else ""
null = " | null" if f.nullable else ""
o.append(f" {prop_key(f.name)}{opt}: {ts_type(f.type)}{null};")
o.append("}")
o.append("")
o += [
"/** JSON-RPC error as it appears on the wire. */",
"export interface RpcError {",
" code: ErrorCode;",
" message: string;",
" data?: Record<string, unknown> | null;",
"}",
"",
"/** A response is one or the other, never both — narrow on `error`. */",
"export type RpcResponse<T> =",
" | { jsonrpc: '2.0'; id: number | string; result: T; error?: undefined }",
" | { jsonrpc: '2.0'; id: number | string; result?: undefined; error: RpcError };",
"",
]
return "\n".join(o)
def upper_snake(name: str) -> str:
out = []
for i, ch in enumerate(name):
if ch.isupper() and i and not name[i - 1].isupper():
out.append("_")
out.append(ch.upper())
return "".join(out)
# ----------------------------------------------------------------- methods.ts
def emit_methods(c: Contract) -> str:
imports = sorted({r.name for m in c.methods for r in (m.params, m.result) if r.kind == "named"})
o = [BANNER.format(version=c.version), ""]
o.append("import type {")
for name in imports:
o.append(f" {name},")
o.append("} from './types.js';")
o.append("")
o += [
"/** Params and result for every method, keyed by its wire name. */",
"export interface MethodMap {",
]
for m in c.methods:
o += doc_block(m.doc, " ")
o.append(f" {method_key(m.name)}: {{ params: {ts_type(m.params)}; result: {ts_type(m.result)} }};")
o += ["}", "",
"export type MethodName = keyof MethodMap;",
"export type Params<M extends MethodName> = MethodMap[M]['params'];",
"export type Result<M extends MethodName> = MethodMap[M]['result'];",
"",
"export type Transport = 'uds' | 'ws';",
"",
"export interface MethodMeta {",
" /** Refused over the WebSocket transport with -32003. */",
" readonly privileged: boolean;",
" readonly transports: readonly Transport[];",
" /** How long a client waits before giving up on this call. */",
" readonly deadlineMs: number;",
" /** Error codes this method is documented to return. */",
" readonly errors: readonly number[];",
"}",
"",
"export const METHODS: { readonly [M in MethodName]: MethodMeta } = {"]
for m in c.methods:
transports = ", ".join(f"'{t}'" for t in m.transports)
errors = ", ".join(str(e) for e in m.errors)
o.append(f" {method_key(m.name)}: {{ privileged: {str(m.privileged).lower()}, "
f"transports: [{transports}], deadlineMs: {m.deadline_ms}, errors: [{errors}] }},")
o += ["} as const;", "",
"export const METHOD_NAMES = Object.keys(METHODS) as MethodName[];",
"",
"export function isMethodName(v: unknown): v is MethodName {",
" return typeof v === 'string' && Object.prototype.hasOwnProperty.call(METHODS, v);",
"}",
"",
"/** Methods this transport may call. The extension checks before sending so a",
" * privileged call fails in one place rather than as a puzzling -32003. */",
"export function isAllowedOn(method: MethodName, transport: Transport): boolean {",
" return (METHODS[method].transports as readonly string[]).includes(transport);",
"}",
"",
"/**",
" * The typed client surface. Every transport implements this; the generated",
" * signature is what stops a caller passing download.add's params to download.get.",
" */",
"export interface VeloxClient {",
" call<M extends MethodName>(method: M, params: Params<M>): Promise<Result<M>>;",
"}",
""]
return "\n".join(o)
# ------------------------------------------------------------------ events.ts
def emit_events(c: Contract) -> str:
imports = sorted({e.params.name for e in c.events if e.params.kind == "named"})
o = [BANNER.format(version=c.version), ""]
o.append("import type {")
for name in imports:
o.append(f" {name},")
o.append("} from './types.js';")
o.append("")
o.append("/** Payload for each server-to-client notification, keyed by its wire name. */")
o.append("export interface EventMap {")
for e in c.events:
o += doc_block(e.doc, " ")
o.append(f" {method_key(e.name)}: {ts_type(e.params)};")
o += ["}", "",
"export type EventName = keyof EventMap;",
"export type EventPayload<E extends EventName> = EventMap[E];",
"",
"/**",
" * Discriminated on `method`: narrowing an incoming notification gives the",
" * correctly typed params with no cast at the call site.",
" */",
"export type ServerNotification = {",
" [E in EventName]: { jsonrpc: '2.0'; method: E; params: EventMap[E] };",
"}[EventName];",
"",
"export interface EventMeta {",
" /** Upper bound on emission rate, where the contract sets one. */",
" readonly maxRateHz: number | null;",
"}",
"",
"export const EVENTS: { readonly [E in EventName]: EventMeta } = {"]
for e in c.events:
rate = "null" if e.max_rate_hz is None else str(e.max_rate_hz)
o.append(f" {method_key(e.name)}: {{ maxRateHz: {rate} }},")
o += ["} as const;", "",
"export const EVENT_NAMES = Object.keys(EVENTS) as EventName[];",
"",
"export function isEventName(v: unknown): v is EventName {",
" return typeof v === 'string' && Object.prototype.hasOwnProperty.call(EVENTS, v);",
"}",
""]
return "\n".join(o)
# ---------------------------------------------------------------- validate.ts
PRELUDE = """
/**
* Runtime validation for everything that crosses the wire.
*
* The daemon does not trust the extension and the extension does not trust the daemon:
* `ws://127.0.0.1` is reachable by any local process, so a TypeScript type proves nothing
* at runtime. Every inbound payload goes through one of these before it is used.
*
* Validators mirror the C++ side exactly, including the rule that an absent field and an
* explicit null mean the same thing.
*/
export type Validated<T> =
| { ok: true; value: T }
| { ok: false; path: string; message: string };
export type Validator<T> = (v: unknown, path: string) => Validated<T>;
function fail(path: string, message: string): Validated<never> {
return { ok: false, path, message };
}
function join(path: string, key: string): string {
return path ? `${path}/${key}` : `/${key}`;
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
export const vString: Validator<string> = (v, p) =>
typeof v === 'string' ? { ok: true, value: v } : fail(p, 'expected a string');
export const vNumber: Validator<number> = (v, p) =>
typeof v === 'number' && Number.isFinite(v) ? { ok: true, value: v } : fail(p, 'expected a number');
export const vInteger: Validator<number> = (v, p) =>
typeof v === 'number' && Number.isInteger(v) ? { ok: true, value: v } : fail(p, 'expected an integer');
export const vBoolean: Validator<boolean> = (v, p) =>
typeof v === 'boolean' ? { ok: true, value: v } : fail(p, 'expected a boolean');
export const vUnknown: Validator<unknown> = (v) => ({ ok: true, value: v });
function vArray<T>(inner: Validator<T>): Validator<T[]> {
return (v, p) => {
if (!Array.isArray(v)) return fail(p, 'expected an array');
const out: T[] = [];
for (let i = 0; i < v.length; i += 1) {
const r = inner(v[i], join(p, String(i)));
if (!r.ok) return r;
out.push(r.value);
}
return { ok: true, value: out };
};
}
function vRecord<T>(inner: Validator<T>): Validator<Record<string, T>> {
return (v, p) => {
if (!isPlainObject(v)) return fail(p, 'expected an object');
const out: Record<string, T> = {};
for (const [k, raw] of Object.entries(v)) {
const r = inner(raw, join(p, k));
if (!r.ok) return r;
out[k] = r.value;
}
return { ok: true, value: out };
};
}
/**
* Range, length and pattern checks. The wire is untrusted, so a `maximum` in the schema
* has to be a check at runtime — a TypeScript type cannot enforce one.
*/
interface Limits {
readonly minimum?: number;
readonly maximum?: number;
readonly minLength?: number;
readonly maxLength?: number;
readonly pattern?: RegExp;
readonly minItems?: number;
readonly maxItems?: number;
}
function vLimited<T>(inner: Validator<T>, limits: Limits): Validator<T> {
return (v, p) => {
const r = inner(v, p);
if (!r.ok) return r;
const value = r.value;
if (typeof value === 'number') {
if (limits.minimum !== undefined && value < limits.minimum)
return fail(p, `value is below the minimum of ${limits.minimum}`);
if (limits.maximum !== undefined && value > limits.maximum)
return fail(p, `value is above the maximum of ${limits.maximum}`);
} else if (typeof value === 'string') {
if (limits.minLength !== undefined && value.length < limits.minLength)
return fail(p, `value is shorter than ${limits.minLength} characters`);
if (limits.maxLength !== undefined && value.length > limits.maxLength)
return fail(p, `value is longer than ${limits.maxLength} characters`);
if (limits.pattern !== undefined && !limits.pattern.test(value))
return fail(p, 'value does not match the required pattern');
} else if (Array.isArray(value)) {
if (limits.minItems !== undefined && value.length < limits.minItems)
return fail(p, `fewer than ${limits.minItems} items`);
if (limits.maxItems !== undefined && value.length > limits.maxItems)
return fail(p, `more than ${limits.maxItems} items`);
}
return r;
};
}
function vEnum<T extends string>(values: readonly T[], name: string): Validator<T> {
return (v, p) =>
typeof v === 'string' && (values as readonly string[]).includes(v)
? { ok: true, value: v as T }
: fail(p, `not a valid ${name}`);
}
function vIntEnum<T extends number>(values: readonly T[], name: string): Validator<T> {
return (v, p) =>
typeof v === 'number' && (values as readonly number[]).includes(v)
? { ok: true, value: v as T }
: fail(p, `not a valid ${name}`);
}
/** Required: must be present and non-null. */
function req<T>(
obj: Record<string, unknown>,
key: string,
path: string,
inner: Validator<T>,
out: Record<string, unknown>,
): Validated<null> {
const raw = obj[key];
if (raw === undefined || raw === null) return fail(join(path, key), 'required field is missing');
const r = inner(raw, join(path, key));
if (!r.ok) return r;
out[key] = r.value;
return { ok: true, value: null };
}
/** Optional: absent and null are the same thing, exactly as on the C++ side. */
function opt<T>(
obj: Record<string, unknown>,
key: string,
path: string,
inner: Validator<T>,
out: Record<string, unknown>,
): Validated<null> {
const raw = obj[key];
if (raw === undefined || raw === null) return { ok: true, value: null };
const r = inner(raw, join(path, key));
if (!r.ok) return r;
out[key] = r.value;
return { ok: true, value: null };
}
"""
def ts_limits(ref: TypeRef) -> str:
"""The Limits object literal for a TypeRef, or "" when it is unconstrained."""
lim = ref.limits
parts = []
for key in ("minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"):
if key in lim:
parts.append(f"{key}: {lim[key]}")
if "pattern" in lim:
parts.append("pattern: " + js_regex(str(lim["pattern"])))
return "{ " + ", ".join(parts) + " }" if parts else ""
def js_regex(pattern: str) -> str:
return "/" + pattern.replace("/", "\\/") + "/"
def validator_expr(ref: TypeRef) -> str:
base = _validator_base(ref)
limits = ts_limits(ref)
return f"vLimited({base}, {limits})" if limits else base
def _validator_base(ref: TypeRef) -> str:
if ref.kind == "named":
return f"validate{ref.name}"
if ref.kind == "string":
return "vString"
if ref.kind == "integer":
return "vInteger"
if ref.kind == "number":
return "vNumber"
if ref.kind == "boolean":
return "vBoolean"
if ref.kind == "json":
return "vUnknown"
if ref.kind == "array":
return f"vArray({validator_expr(ref.inner)})"
if ref.kind == "map":
return f"vRecord({validator_expr(ref.inner)})"
raise AssertionError(ref.kind)
def emit_validate(c: Contract) -> str:
# An int enum is exported from types.ts as a const *and* a type under one name, so a
# value import already brings the type with it. Importing it twice is a TS2300.
value_imported = {t.name for t in c.types if t.kind == "int_enum"}
type_names = [t.name for t in c.types if t.name not in value_imported]
o = [BANNER.format(version=c.version), ""]
o.append("import type {")
for name in sorted(type_names):
o.append(f" {name},")
o.append("} from './types.js';")
o.append("import {")
for t in sorted(c.types, key=lambda t: t.name):
if t.kind == "string_enum":
o.append(f" {upper_snake(t.name)}_VALUES,")
elif t.kind == "int_enum":
o.append(f" {t.name},")
o.append("} from './types.js';")
o.append("import { isEventName, type EventMap, type EventName } from './events.js';")
o.append("import { isMethodName, type MethodMap, type MethodName } from './methods.js';")
o.append(PRELUDE)
for t in c.types:
if t.kind == "string_enum":
o.append(f"export const validate{t.name}: Validator<{t.name}> = "
f"vEnum({upper_snake(t.name)}_VALUES, '{t.name}');")
o.append("")
elif t.kind == "int_enum":
o.append(f"const {upper_snake(t.name)}_VALUES = Object.values({t.name}) as {t.name}[];")
o.append(f"export const validate{t.name}: Validator<{t.name}> = "
f"vIntEnum({upper_snake(t.name)}_VALUES, '{t.name}');")
o.append("")
elif t.kind == "map_alias":
o.append(f"export const validate{t.name}: Validator<{t.name}> = "
f"{validator_expr(t.alias)};")
o.append("")
elif t.kind == "struct":
o += doc_block(f"Validate an untrusted value as {t.name}.")
o.append(f"export function validate{t.name}(v: unknown, path = ''): Validated<{t.name}> {{")
o.append(" if (!isPlainObject(v)) return fail(path, 'expected an object');")
if not t.fields:
o.append(f" return {{ ok: true, value: {{}} as {t.name} }};")
o.append("}")
o.append("")
continue
o.append(" const out: Record<string, unknown> = {};")
o.append(" let r: Validated<null>;")
for f in t.fields:
fn = "opt" if f.optional else "req"
o.append(f" r = {fn}(v, {json.dumps(f.name)}, path, {validator_expr(f.type)}, out);")
o.append(" if (!r.ok) return r;")
o.append(f" return {{ ok: true, value: out as unknown as {t.name} }};")
o.append("}")
o.append("")
# dispatch tables
o += ["// --- by-name entry points --------------------------------------------------",
"",
"const PARAMS_VALIDATORS: { [M in MethodName]: Validator<MethodMap[M]['params']> } = {"]
for m in c.methods:
o.append(f" {method_key(m.name)}: {validator_expr(m.params)},")
o += ["};", "",
"const RESULT_VALIDATORS: { [M in MethodName]: Validator<MethodMap[M]['result']> } = {"]
for m in c.methods:
o.append(f" {method_key(m.name)}: {validator_expr(m.result)},")
o += ["};", "",
"const EVENT_VALIDATORS: { [E in EventName]: Validator<EventMap[E]> } = {"]
for e in c.events:
o.append(f" {method_key(e.name)}: {validator_expr(e.params)},")
o += ["};", "",
"/** Validate params the daemon is about to receive for `method`. */",
"export function validateParams<M extends MethodName>(method: M, v: unknown): Validated<MethodMap[M]['params']> {",
" return PARAMS_VALIDATORS[method](v, 'params');",
"}",
"",
"/** Validate a result the client just received for `method`. */",
"export function validateResult<M extends MethodName>(method: M, v: unknown): Validated<MethodMap[M]['result']> {",
" return RESULT_VALIDATORS[method](v, 'result');",
"}",
"",
"/** Validate a notification payload. */",
"export function validateEventParams<E extends EventName>(event: E, v: unknown): Validated<EventMap[E]> {",
" return EVENT_VALIDATORS[event](v, 'params');",
"}",
"",
"/**",
" * Validate a whole inbound notification frame, including its method name.",
" * Anything unrecognised is rejected rather than passed on: an unknown method on a",
" * loopback socket is either a version skew or another local process probing us.",
" */",
"export function validateNotification(",
" frame: unknown,",
"): Validated<{ method: EventName; params: EventMap[EventName] }> {",
" if (!isPlainObject(frame)) return fail('', 'expected an object');",
" if (frame['jsonrpc'] !== '2.0') return fail('/jsonrpc', \"expected '2.0'\");",
" const method = frame['method'];",
" if (!isEventName(method)) return fail('/method', 'unknown event');",
" const params = validateEventParams(method, frame['params']);",
" if (!params.ok) return params;",
" return { ok: true, value: { method, params: params.value } };",
"}",
"",
"export { isEventName, isMethodName };",
""]
return "\n".join(o)
def emit_index(c: Contract) -> str:
return "\n".join([
BANNER.format(version=c.version),
"",
"export * from './types.js';",
"export * from './methods.js';",
"export * from './events.js';",
"export * from './validate.js';",
"",
])
def main() -> int:
c = load()
OUT_DIR.mkdir(parents=True, exist_ok=True)
(OUT_DIR / "types.ts").write_text(emit_types(c))
(OUT_DIR / "methods.ts").write_text(emit_methods(c))
(OUT_DIR / "events.ts").write_text(emit_events(c))
(OUT_DIR / "validate.ts").write_text(emit_validate(c))
(OUT_DIR / "index.ts").write_text(emit_index(c))
print(f"gen_ts: {len(c.types)} types, {len(c.methods)} methods, {len(c.events)} events -> {OUT_DIR}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+454
View File
@@ -0,0 +1,454 @@
"""Load contracts/schema/ and lower it into a small IR the generators emit from.
There is deliberately only one of these. gen_cpp.py, gen_ts.py and gen_openrpc.py all
consume the same IR, so the C++ structs, the TypeScript types and the human-readable
API document cannot disagree about what the contract says.
The IR covers exactly the JSON Schema subset the contract is allowed to use. Anything
outside it raises SchemaError at generation time rather than producing subtly wrong code:
a contract that cannot be generated from is a contract bug, and it should stop the build.
Supported subset
----------------
type: object / string / integer / number / boolean / array, and ["X", "null"]
object with `properties` -> struct
object with `additionalProperties: <schema>` -> map<string, T>
string with `enum` -> enum
integer with `enum` + x-enum -> named integer enum
array with `items` -> vector<T>
$ref to a types/*.schema.json -> named type
oneOf: [{$ref}, {type: null}] -> nullable named type
{} -> opaque JSON
Not supported, on purpose: allOf, anyOf, general oneOf, patternProperties, tuple items,
recursive types. If the contract needs one of these, extend this file in the same PR.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
SCHEMA_ROOT = Path(__file__).resolve().parent.parent / "schema"
ID_PREFIX = "https://velox.dev/schema/"
class SchemaError(Exception):
"""A schema the generators refuse to guess about."""
# --------------------------------------------------------------------------- IR
# Value constraints the generators enforce at runtime. The wire is never trusted, so a
# `maximum` in the schema has to be a check in the generated code, not just documentation.
CONSTRAINT_KEYS = ("minimum", "maximum", "minLength", "maxLength", "pattern",
"minItems", "maxItems")
@dataclass(frozen=True)
class TypeRef:
"""A reference to a type from a field. `kind` drives every emitter's switch."""
kind: str # named | string | integer | number | boolean | array | map | json
name: str | None = None # kind == "named"
inner: "TypeRef | None" = None # kind in ("array", "map")
constraints: tuple[tuple[str, object], ...] = ()
@property
def limits(self) -> dict[str, object]:
return dict(self.constraints)
@dataclass
class Field:
name: str
type: TypeRef
required: bool
nullable: bool
doc: str = ""
@property
def optional(self) -> bool:
"""Whether the emitted field needs an optional/undefined-able representation."""
return (not self.required) or self.nullable
@dataclass
class EnumValue:
name: str # identifier form, e.g. RetryWait
wire: object # the value on the wire: "retry_wait" or -32010
doc: str = ""
@dataclass
class TypeDef:
name: str
kind: str # struct | string_enum | int_enum | map_alias
doc: str = ""
fields: list[Field] = field(default_factory=list)
values: list[EnumValue] = field(default_factory=list)
alias: TypeRef | None = None
source: str = "" # relative path, for the "do not edit" banner
@dataclass
class Method:
name: str # download.addBatch
doc: str
params: TypeRef
result: TypeRef
privileged: bool
transports: list[str]
deadline_ms: int
errors: list[int] = field(default_factory=list)
ws_restrictions: list[str] = field(default_factory=list)
@dataclass
class Event:
name: str # event.task.progress
doc: str
params: TypeRef
max_rate_hz: float | None = None
@dataclass
class Contract:
version: str
types: list[TypeDef]
methods: list[Method]
events: list[Event]
error_codes: list[EnumValue]
# ------------------------------------------------------------------ name helpers
def pascal(text: str) -> str:
"""download.addBatch -> DownloadAddBatch ; retry_wait -> RetryWait."""
parts = re.split(r"[.\-_ ]+", text)
out = []
for part in parts:
if not part:
continue
out.append(part[0].upper() + part[1:])
return "".join(out)
def enum_ident(value: str) -> str:
ident = pascal(value)
if not ident:
raise SchemaError(f"cannot derive an identifier from enum value {value!r}")
if ident[0].isdigit():
ident = "V" + ident
return ident
# ---------------------------------------------------------------------- loading
class Loader:
def __init__(self, root: Path = SCHEMA_ROOT):
self.root = root
self.by_id: dict[str, dict] = {}
self.source_of: dict[str, str] = {}
for path in sorted(root.rglob("*.schema.json")):
with path.open() as fh:
doc = json.load(fh)
sid = doc.get("$id")
if not sid:
raise SchemaError(f"{path} has no $id")
if sid in self.by_id:
raise SchemaError(f"duplicate $id {sid} in {path}")
self.by_id[sid] = doc
self.source_of[sid] = str(path.relative_to(root.parent))
self.types: dict[str, TypeDef] = {}
self._order: list[str] = []
# -- ref handling -------------------------------------------------------
def _resolve_ref(self, ref: str) -> dict:
if ref.startswith("#"):
raise SchemaError(f"local $ref {ref} is not supported outside envelope.schema.json")
if ref not in self.by_id:
raise SchemaError(f"unknown $ref {ref}")
return self.by_id[ref]
@staticmethod
def _split_nullable(node: dict) -> tuple[dict, bool]:
"""Normalise the two ways the contract spells 'or null'."""
if "oneOf" in node:
branches = node["oneOf"]
non_null = [b for b in branches if b.get("type") != "null"]
nulls = [b for b in branches if b.get("type") == "null"]
if len(branches) != 2 or len(non_null) != 1 or len(nulls) != 1:
raise SchemaError(
"oneOf is only supported as [<schema>, {type: null}]; got "
+ json.dumps(branches)[:200]
)
merged = dict(non_null[0])
for key in ("description",):
if key in node and key not in merged:
merged[key] = node[key]
return merged, True
t = node.get("type")
if isinstance(t, list):
non_null = [x for x in t if x != "null"]
if len(non_null) != 1:
raise SchemaError(f"union type {t} is only supported as [X, 'null']")
node = dict(node)
node["type"] = non_null[0]
# An enum listing null alongside its values means the same thing.
if "enum" in node:
node["enum"] = [v for v in node["enum"] if v is not None]
return node, True
if "enum" in node and None in node["enum"]:
node = dict(node)
node["enum"] = [v for v in node["enum"] if v is not None]
return node, True
return node, False
# -- lowering -----------------------------------------------------------
def type_ref(self, node: dict, hint: str) -> tuple[TypeRef, bool]:
"""Lower a schema node to a TypeRef. `hint` names any struct we must synthesise."""
node, nullable = self._split_nullable(node)
limits = tuple((k, node[k]) for k in CONSTRAINT_KEYS if k in node)
if "$ref" in node:
target = self._resolve_ref(node["$ref"])
return TypeRef("named", name=self.named_type(node["$ref"], target)), nullable
if not node or node.keys() <= {"description"}:
return TypeRef("json"), True
t = node.get("type")
if t == "string":
if "enum" in node:
return TypeRef("named", name=self._synth_string_enum(hint, node)), nullable
return TypeRef("string", constraints=limits), nullable
if t == "integer":
return TypeRef("integer", constraints=limits), nullable
if t == "number":
return TypeRef("number", constraints=limits), nullable
if t == "boolean":
return TypeRef("boolean", constraints=limits), nullable
if t == "array":
items = node.get("items")
if items is None:
raise SchemaError(f"array without items at {hint}")
inner, _ = self.type_ref(items, hint + "Item")
return TypeRef("array", inner=inner, constraints=limits), nullable
if t == "object":
if "properties" in node:
return TypeRef("named", name=self._synth_struct(hint, node)), nullable
ap = node.get("additionalProperties")
if isinstance(ap, dict):
inner, _ = self.type_ref(ap, hint + "Value")
return TypeRef("map", inner=inner), nullable
return TypeRef("json"), nullable
if "const" in node:
return TypeRef("string", constraints=limits), nullable
raise SchemaError(f"unsupported schema node at {hint}: {json.dumps(node)[:200]}")
def _register(self, td: TypeDef) -> str:
existing = self.types.get(td.name)
if existing is not None:
if existing.kind != td.kind:
raise SchemaError(f"type name collision on {td.name}")
return td.name
self.types[td.name] = td
self._order.append(td.name)
return td.name
def _synth_string_enum(self, name: str, node: dict) -> str:
values = [
EnumValue(name=enum_ident(v), wire=v)
for v in node["enum"]
if v is not None
]
return self._register(TypeDef(name=name, kind="string_enum",
doc=node.get("description", ""), values=values))
def _synth_struct(self, name: str, node: dict) -> str:
if node.get("additionalProperties", False) is not False:
raise SchemaError(
f"{name}: object schemas must set additionalProperties:false — the daemon is "
"not allowed to trust unknown fields on the wire"
)
required = set(node.get("required", []))
fields: list[Field] = []
for prop, sub in node.get("properties", {}).items():
ref, nullable = self.type_ref(sub, name + pascal(prop))
fields.append(Field(name=prop, type=ref, required=prop in required,
nullable=nullable, doc=sub.get("description", "")))
return self._register(TypeDef(name=name, kind="struct",
doc=node.get("description", ""), fields=fields))
def named_type(self, sid: str, doc: dict) -> str:
"""Lower a top-level types/*.schema.json into a TypeDef and return its name."""
name = doc.get("title")
if not name:
raise SchemaError(f"{sid} has no title")
if name in self.types:
return name
source = self.source_of.get(sid, "")
node, _ = self._split_nullable(doc)
t = node.get("type")
if t == "string" and "enum" in node:
# Placeholder first: enums cannot recurse, but registering early keeps the
# ordering stable and mirrors the struct path below.
values = [EnumValue(name=enum_ident(v), wire=v) for v in node["enum"] if v is not None]
td = TypeDef(name, "string_enum", node.get("description", ""), values=values, source=source)
return self._register(td)
if t == "integer" and "x-enum" in node:
values = [EnumValue(name=e["name"], wire=e["value"], doc=e.get("doc", ""))
for e in node["x-enum"]]
td = TypeDef(name, "int_enum", node.get("description", ""), values=values, source=source)
return self._register(td)
if t == "object" and "properties" not in node:
ap = node.get("additionalProperties")
if not isinstance(ap, dict):
raise SchemaError(f"{sid}: object type with neither properties nor a typed additionalProperties")
inner, _ = self.type_ref(ap, name + "Value")
td = TypeDef(name, "map_alias", node.get("description", ""),
alias=TypeRef("map", inner=inner), source=source)
return self._register(td)
if t == "object":
# Reserve the name before descending so a nested synth cannot steal it.
self.types[name] = TypeDef(name, "struct", node.get("description", ""), source=source)
self._order.append(name)
required = set(node.get("required", []))
if node.get("additionalProperties", False) is not False:
raise SchemaError(f"{sid}: object schemas must set additionalProperties:false")
fields = []
for prop, sub in node.get("properties", {}).items():
ref, nullable = self.type_ref(sub, name + pascal(prop))
fields.append(Field(prop, ref, prop in required, nullable, sub.get("description", "")))
self.types[name].fields = fields
return name
raise SchemaError(f"{sid}: unsupported top-level type {t!r}")
# ---------------------------------------------------------------------- driver
def load() -> Contract:
loader = Loader()
root = loader.root.parent
version = (root / "VERSION").read_text().strip()
# Named types first, so their names win over any synthesised ones.
for sid in sorted(loader.by_id):
if "/types/" in sid:
loader.named_type(sid, loader.by_id[sid])
methods: list[Method] = []
for sid in sorted(loader.by_id):
if "/methods/" not in sid:
continue
doc = loader.by_id[sid]
name = doc["title"]
base = pascal(name)
props = doc.get("properties", {})
for half in ("params", "result"):
if half not in props:
raise SchemaError(f"{sid}: method schema must define both params and result")
params, _ = loader.type_ref(props["params"], base + "Params")
result, _ = loader.type_ref(props["result"], base + "Result")
transports = doc.get("x-transports")
if not transports:
raise SchemaError(f"{sid}: x-transports is required")
if "x-privileged" not in doc:
raise SchemaError(f"{sid}: x-privileged is required")
methods.append(Method(
name=name, doc=doc.get("description", ""), params=params, result=result,
privileged=bool(doc["x-privileged"]), transports=list(transports),
deadline_ms=int(doc.get("x-deadlineMs", 5000)),
errors=list(doc.get("x-errors", [])),
ws_restrictions=list(doc.get("x-wsRestrictions", [])),
))
events: list[Event] = []
for sid in sorted(loader.by_id):
if "/events/" not in sid:
continue
doc = loader.by_id[sid]
name = doc["title"]
ident = pascal(name[len("event."):] if name.startswith("event.") else name) + "Event"
params, _ = loader.type_ref(doc["properties"]["params"], ident)
events.append(Event(name=name, doc=doc.get("description", ""), params=params,
max_rate_hz=doc.get("x-maxRateHz")))
error_codes = loader.types["ErrorCode"].values
ordered = [loader.types[n] for n in loader._order]
return Contract(version=version, types=ordered, methods=methods, events=events,
error_codes=error_codes)
def dependencies(td: TypeDef) -> set[str]:
"""Named types `td` mentions directly."""
out: set[str] = set()
def walk(ref: TypeRef | None) -> None:
if ref is None:
return
if ref.kind == "named" and ref.name:
out.add(ref.name)
walk(ref.inner)
for f in td.fields:
walk(f.type)
walk(td.alias)
return out
def topo_sorted(types: list[TypeDef]) -> list[TypeDef]:
"""Definition order for languages that need a type declared before it is used.
The contract forbids recursive types, so a cycle here means a schema bug and is
raised rather than broken arbitrarily.
"""
by_name = {t.name: t for t in types}
state: dict[str, int] = {}
order: list[TypeDef] = []
def visit(name: str, trail: list[str]) -> None:
mark = state.get(name, 0)
if mark == 2:
return
if mark == 1:
raise SchemaError("recursive type: " + " -> ".join(trail + [name]))
state[name] = 1
for dep in sorted(dependencies(by_name[name])):
if dep in by_name:
visit(dep, trail + [name])
state[name] = 2
order.append(by_name[name])
for t in types:
visit(t.name, [])
return order
if __name__ == "__main__":
c = load()
print(f"contract {c.version}: {len(c.types)} types, {len(c.methods)} methods, {len(c.events)} events")
for t in c.types:
detail = f"{len(t.fields)} fields" if t.kind == "struct" else f"{len(t.values)} values"
print(f" {t.kind:12} {t.name:34} {detail}")
+83
View File
@@ -0,0 +1,83 @@
# contracts/fixtures — golden request/response pairs
Every method has at least one success fixture. A method with no fixture is not done.
These files are replayed by `tests/conformance/` against **both** the generated C++ and a
live server, which is what lets four lanes build in parallel and still be compatible: green
fixtures mean the C++ daemon and the TypeScript extension agree, without either having ever
run against the other. `tools/mockd` also answers from them, so the GUI and extension are
developed against the same bytes conformance asserts.
## Layout
```
fixtures/
├── *.json one success fixture per method
├── errors/ error cases: auth, transport, not-found, bad path, timeout
└── events/ one fixture per server-to-client notification
```
## Shape
```jsonc
{
"name": "download.add — start an ISO now, into the Programs category",
"description": "Why this case is worth pinning.",
"transport": "uds", // optional: replay only on this transport
"requires": "...", // optional: a condition a plain server cannot produce
"kind": "timeout", // optional: the correct behaviour is *no reply*
"request": { "jsonrpc": "2.0", "id": 11, "method": "download.add", "params": { } },
"response": { "jsonrpc": "2.0", "id": 11, "result": { } },
"assertions": [ "things a runner or a reviewer should check" ]
}
```
An event fixture carries `notification` instead of `request`/`response`.
`assertions` are prose, for the human writing the implementation. The runners check the
machine-checkable parts: schema validity, error codes, shape, and the timeout.
## Placeholders
Some values cannot be pinned in a golden file. These stand in for them, and the runners
treat them as "any value of the right shape":
| Placeholder | Means |
|---|---|
| `$uuid` | any UUID |
| `$isoDate` | any RFC 3339 date-time |
| `$opaque` | a credential-shaped string (a token) |
| `$any` | any value |
| `$taskId`, `$taskId2` | a task the runner creates during setup, and binds before replaying |
`$taskId` exists so a fixture never depends on a task id that only happens to exist in a
seeded mock. The same fixture then runs against an empty `veloxd` and a populated `mockd`.
## Values are matched by shape, not by equality
A live daemon returns its own task ids and its own clock. Demanding byte-identical results
would only teach the suite to lie, so the runners assert:
* the payload passes the **generated validator** — this is the real cross-language check;
* the **key structure** matches the golden file, with no extra and no missing fields;
* **error codes** match exactly.
A `null` where the golden shows a value is accepted: the validator has already ruled on
whether null is legal there, and a golden file shows one plausible value, not the only one.
## `requires`: fixtures a mock cannot produce
Most error fixtures are *intrinsic* — a path outside the allowed roots, an out-of-range
parameter, an unknown task id — and any correct server produces them from the request
alone. Those are replayed everywhere.
Four are environmental: a 403 from an origin server, a full disk, a pairing lockout, a
wedged daemon. They carry `requires`, are skipped by default, and are exercised where the
condition can actually be arranged — `run.sh` starts a deliberately slow `mockd` to prove
`capture.offer` fails open, and lane PKG/QA's `tools/testserver` covers the hostile-server
cases in `tests/integration/`.
`errors/capture.offer.timeout.json` is the most important file in this directory. Its
correct response is *no response*: past 750 ms the extension must abandon the offer and let
Firefox download normally. A download manager that eats downloads when its daemon is down
is worse than no download manager.
+52
View File
@@ -0,0 +1,52 @@
{
"name": "capture.getRules \u2014 the extension mirrors the daemon's policy",
"description": "Fetched on connect and whenever event.settings.changed names a capture.* key, so the two can never disagree about what should be intercepted.",
"request": {
"jsonrpc": "2.0",
"id": 60,
"method": "capture.getRules",
"params": {}
},
"response": {
"jsonrpc": "2.0",
"id": 60,
"result": {
"enabled": true,
"monitoredExtensions": [
"iso",
"zip",
"tar",
"gz",
"xz",
"7z",
"rar",
"deb",
"rpm",
"exe",
"msi",
"appimage",
"mkv",
"mp4",
"flac",
"pdf"
],
"monitoredMimeTypes": [
"application/octet-stream",
"application/x-iso9660-image",
"application/zip",
"video/x-matroska"
],
"minSizeBytes": 1048576,
"excludedHosts": [
"*.corp.internal",
"localhost"
],
"bypassModifier": "alt",
"rulesVersion": 7
}
},
"assertions": [
"if this call fails the extension keeps its last known rules and stays fail-open",
"rulesVersion increases on every change so the extension knows when to re-fetch"
]
}
+111
View File
@@ -0,0 +1,111 @@
{
"name": "category.list \u2014 the five built-in categories",
"description": "Read-only and available over both transports: the extension's default-category picker needs it.",
"request": {
"jsonrpc": "2.0",
"id": 30,
"method": "category.list",
"params": {}
},
"response": {
"jsonrpc": "2.0",
"id": 30,
"result": {
"items": [
{
"categoryId": "compressed",
"name": "Compressed",
"saveDir": "/home/sami/Downloads/Compressed",
"extensions": [
"zip",
"tar",
"gz",
"xz",
"zst",
"7z",
"rar"
],
"mimeTypes": [
"application/zip"
],
"builtin": true,
"sortOrder": 0
},
{
"categoryId": "documents",
"name": "Documents",
"saveDir": "/home/sami/Downloads/Documents",
"extensions": [
"pdf",
"epub",
"odt",
"docx"
],
"mimeTypes": [
"application/pdf"
],
"builtin": true,
"sortOrder": 1
},
{
"categoryId": "music",
"name": "Music",
"saveDir": "/home/sami/Downloads/Music",
"extensions": [
"mp3",
"flac",
"ogg",
"opus",
"wav"
],
"mimeTypes": [
"audio/flac"
],
"builtin": true,
"sortOrder": 2
},
{
"categoryId": "programs",
"name": "Programs",
"saveDir": "/home/sami/Downloads/Programs",
"extensions": [
"exe",
"msi",
"deb",
"rpm",
"appimage",
"iso",
"dmg"
],
"mimeTypes": [
"application/x-iso9660-image"
],
"builtin": true,
"sortOrder": 3
},
{
"categoryId": "video",
"name": "Video",
"saveDir": "/home/sami/Downloads/Video",
"extensions": [
"mkv",
"mp4",
"avi",
"webm",
"mov"
],
"mimeTypes": [
"video/mp4",
"video/x-matroska"
],
"builtin": true,
"sortOrder": 4
}
]
}
},
"assertions": [
"built-in categories always exist and cannot be removed",
"extensions are lowercase and carry no leading dot"
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "category.remove \u2014 delete a user category and refile its tasks",
"description": "No task is ever orphaned: everything filed under the removed category moves to reassignTo.",
"request": {
"jsonrpc": "2.0",
"id": 32,
"method": "category.remove",
"params": {
"categoryId": "firmware",
"reassignTo": "programs"
}
},
"response": {
"jsonrpc": "2.0",
"id": 32,
"result": {
"removed": true,
"reassignedTaskIds": [
"3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071"
]
}
},
"assertions": [
"removing a builtin category is refused with -32602",
"reassignTo null moves tasks to the default category rather than leaving them dangling"
]
}
+47
View File
@@ -0,0 +1,47 @@
{
"name": "category.upsert \u2014 create a user category for firmware images",
"description": "Omitting categoryId creates; the daemon assigns the id and echoes the stored row back.",
"request": {
"jsonrpc": "2.0",
"id": 31,
"method": "category.upsert",
"params": {
"category": {
"categoryId": "firmware",
"name": "Firmware",
"saveDir": "/home/sami/Downloads/Firmware",
"extensions": [
"bin",
"img",
"fw"
],
"mimeTypes": [],
"builtin": false,
"sortOrder": 5
}
}
},
"response": {
"jsonrpc": "2.0",
"id": 31,
"result": {
"category": {
"categoryId": "firmware",
"name": "Firmware",
"saveDir": "/home/sami/Downloads/Firmware",
"extensions": [
"bin",
"img",
"fw"
],
"mimeTypes": [],
"builtin": false,
"sortOrder": 5
}
}
},
"assertions": [
"builtin is forced false on a created category regardless of what was sent",
"changing saveDir never moves existing files \u2014 the GUI asks and issues download.update per task"
]
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "download.add \u2014 start an ISO now, into the Programs category",
"description": "The ordinary add path. saveDir is canonicalized and checked against the allowed roots before anything is written.",
"request": {
"jsonrpc": "2.0",
"id": 11,
"method": "download.add",
"params": {
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"filename": "ubuntu-26.04-desktop-amd64.iso",
"saveDir": "/home/sami/Downloads/Programs",
"categoryId": "programs",
"segments": 8,
"startMode": "now"
}
},
"response": {
"jsonrpc": "2.0",
"id": 11,
"result": {
"taskId": "$uuid",
"state": "connecting",
"duplicate": null
}
},
"assertions": [
"the .veloxpart file is created sparse and preallocated at the final size",
"saveDir resolves inside saveTo.allowedRoots, or the call fails -32011 having written nothing",
"event.task.added is emitted to every subscriber before this reply is sent"
]
}
+51
View File
@@ -0,0 +1,51 @@
{
"name": "download.addBatch \u2014 three URLs, one bad, added together",
"description": "Partial success is normal: the two good items become tasks and the third is reported per-item rather than failing the batch.",
"request": {
"jsonrpc": "2.0",
"id": 12,
"method": "download.addBatch",
"params": {
"items": [
{
"url": "https://example.org/a.zip"
},
{
"url": "https://example.org/b.zip"
},
{
"url": "https://example.org/c.zip",
"saveDir": "/etc"
}
],
"defaults": {
"url": "https://example.org/",
"categoryId": "compressed",
"startMode": "queue",
"queueId": "main"
}
}
},
"response": {
"jsonrpc": "2.0",
"id": 12,
"result": {
"taskIds": [
"$uuid",
"$uuid"
],
"failed": [
{
"index": 2,
"code": -32011,
"message": "destination is outside the allowed roots"
}
]
}
},
"assertions": [
"defaults fill only fields an item left unset; the defaults' own url is ignored",
"one rejected item never rolls back the accepted ones",
"failed[].index refers to params.items, so the caller can map it back to its own list"
]
}
+39
View File
@@ -0,0 +1,39 @@
{
"name": "download.cancel \u2014 applied to a two-task selection",
"description": "The part file is kept: cancel stops the transfer, download.remove is what deletes bytes.",
"request": {
"jsonrpc": "2.0",
"id": 23,
"method": "download.cancel",
"params": {
"taskIds": [
"$taskId",
"$taskId2"
]
}
},
"response": {
"jsonrpc": "2.0",
"id": 23,
"result": {
"updated": [
{
"taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"state": "cancelled",
"changed": true
},
{
"taskId": "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
"state": "cancelled",
"changed": false
}
],
"failed": []
}
},
"assertions": [
"a task already in the target state is reported with changed false, not as a failure",
"an unknown id lands in failed[] with -32010 and never fails the whole call",
"event.task.state is emitted for every entry whose changed is true"
]
}
+77
View File
@@ -0,0 +1,77 @@
{
"name": "download.get \u2014 full detail with per-segment state",
"description": "Backs the progress dialog. This is the only place segment-level detail crosses the wire.",
"request": {
"jsonrpc": "2.0",
"id": 14,
"method": "download.get",
"params": {
"taskId": "$taskId"
}
},
"response": {
"jsonrpc": "2.0",
"id": 14,
"result": {
"summary": {
"taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"filename": "ubuntu-26.04-desktop-amd64.iso",
"saveDir": "/home/sami/Downloads/Programs",
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"effectiveUrl": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"sizeBytes": 6228541440,
"downloadedBytes": 2941190144,
"state": "downloading",
"speedBps": 29796556,
"etaSeconds": 110,
"resumable": true,
"segments": 8,
"categoryId": "programs",
"queueId": null,
"queuePosition": null,
"description": null,
"createdAt": "$isoDate",
"lastTryAt": "$isoDate",
"completedAt": null,
"error": null
},
"segmentDetail": [
{
"index": 0,
"startByte": 0,
"endByte": 778567679,
"downloadedBytes": 402653184,
"speedBps": 4089446,
"state": "receiving",
"httpStatus": 206
},
{
"index": 1,
"startByte": 778567680,
"endByte": 1557135359,
"downloadedBytes": 356515840,
"speedBps": 3565158,
"state": "receiving",
"httpStatus": 206
}
],
"headers": {
"User-Agent": "Velox/0.1",
"Referer": "https://releases.ubuntu.com/26.04/"
},
"referrer": "https://releases.ubuntu.com/26.04/",
"userAgent": "Velox/0.1",
"mime": "application/octet-stream",
"bufferBytes": 4194304,
"partPath": "/home/sami/Downloads/Programs/ubuntu-26.04-desktop-amd64.iso.veloxpart",
"checksum": null,
"checksumVerified": null,
"averageSpeedBps": 27262976,
"retryCount": 0
}
},
"assertions": [
"segment ranges are contiguous and cover exactly [0, sizeBytes)",
"the GUI draws one bar per entry and is never told what a segment steal is"
]
}
+81
View File
@@ -0,0 +1,81 @@
{
"name": "download.list \u2014 the main table, unfinished first page",
"description": "Filtering, sorting and paging all happen daemon-side; the GUI never materializes rows it will not draw.",
"request": {
"jsonrpc": "2.0",
"id": 13,
"method": "download.list",
"params": {
"filter": {
"states": [
"downloading",
"paused",
"queued"
]
},
"sort": {
"field": "createdAt",
"direction": "desc"
},
"offset": 0,
"limit": 50
}
},
"response": {
"jsonrpc": "2.0",
"id": 13,
"result": {
"total": 4,
"items": [
{
"taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"filename": "ubuntu-26.04-desktop-amd64.iso",
"saveDir": "/home/sami/Downloads/Programs",
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"effectiveUrl": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"sizeBytes": 6228541440,
"downloadedBytes": 2941190144,
"state": "downloading",
"speedBps": 29796556,
"etaSeconds": 110,
"resumable": true,
"segments": 8,
"categoryId": "programs",
"queueId": null,
"queuePosition": null,
"description": null,
"createdAt": "$isoDate",
"lastTryAt": "$isoDate",
"completedAt": null,
"error": null
},
{
"taskId": "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
"filename": "film.mkv",
"saveDir": "/home/sami/Downloads/Video",
"url": "https://example.org/film.mkv",
"effectiveUrl": "https://example.org/film.mkv",
"sizeBytes": 1503238553,
"downloadedBytes": 402653184,
"state": "paused",
"speedBps": 0,
"etaSeconds": null,
"resumable": true,
"segments": 4,
"categoryId": "video",
"queueId": null,
"queuePosition": null,
"description": null,
"createdAt": "$isoDate",
"lastTryAt": "$isoDate",
"completedAt": null,
"error": null
}
]
}
},
"assertions": [
"total counts every row matching the filter, ignoring offset and limit",
"the table is built from this once and maintained from events thereafter, never re-fetched per tick"
]
}
+39
View File
@@ -0,0 +1,39 @@
{
"name": "download.pause \u2014 applied to a two-task selection",
"description": "Progress is flushed to .veloxpart.meta, so a pause is indistinguishable from a crash as far as resume is concerned.",
"request": {
"jsonrpc": "2.0",
"id": 21,
"method": "download.pause",
"params": {
"taskIds": [
"$taskId",
"$taskId2"
]
}
},
"response": {
"jsonrpc": "2.0",
"id": 21,
"result": {
"updated": [
{
"taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"state": "paused",
"changed": true
},
{
"taskId": "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
"state": "paused",
"changed": false
}
],
"failed": []
}
},
"assertions": [
"a task already in the target state is reported with changed false, not as a failure",
"an unknown id lands in failed[] with -32010 and never fails the whole call",
"event.task.state is emitted for every entry whose changed is true"
]
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "download.probe \u2014 a resumable ISO on a well-behaved server",
"description": "Populates the File Info dialog. Establishes resumability from Accept-Ranges plus a validator.",
"request": {
"jsonrpc": "2.0",
"id": 10,
"method": "download.probe",
"params": {
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"headers": {
"User-Agent": "Velox/0.1"
}
}
},
"response": {
"jsonrpc": "2.0",
"id": 10,
"result": {
"filename": "ubuntu-26.04-desktop-amd64.iso",
"sizeBytes": 6228541440,
"mime": "application/octet-stream",
"resumable": true,
"effectiveUrl": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"suggestedCategoryId": "programs",
"suggestedSaveDir": "/home/sami/Downloads/Programs",
"etag": "\"5cf1a2b3-1730f4000\"",
"lastModified": "Thu, 23 Apr 2026 10:14:52 GMT",
"acceptRanges": true,
"redirectChain": [],
"requiresAuth": false
}
},
"assertions": [
"no task is created by a probe",
"resumable is true only when Accept-Ranges: bytes AND a validator are both present",
"the dialog opens before this returns; the RPC loop is never blocked on the network"
]
}
@@ -0,0 +1,31 @@
{
"name": "download.refreshUrl \u2014 a signed URL expired mid-download",
"description": "IDM's Refresh Download Address. Points the task at a fresh link and keeps every byte already on disk.",
"request": {
"jsonrpc": "2.0",
"id": 26,
"method": "download.refreshUrl",
"params": {
"taskId": "$taskId",
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso?token=eyJhbGciOiJIUzI1NiJ9.fresh",
"headers": {
"Referer": "https://releases.ubuntu.com/26.04/"
}
}
},
"response": {
"jsonrpc": "2.0",
"id": 26,
"result": {
"ok": true,
"resumable": true,
"contentChanged": false,
"sizeBytes": 6228541440,
"effectiveUrl": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso?token=eyJhbGciOiJIUzI1NiJ9.fresh"
}
},
"assertions": [
"size and validator are compared against what was recorded before resuming",
"contentChanged true must make the GUI ask before restarting; bytes are never discarded silently"
]
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "download.remove \u2014 drop two tasks and delete their bytes",
"description": "The only method that destroys user data, and the reason it is refused over the WebSocket transport.",
"request": {
"jsonrpc": "2.0",
"id": 24,
"method": "download.remove",
"params": {
"taskIds": [
"$taskId",
"$taskId2"
],
"deleteFile": true
}
},
"response": {
"jsonrpc": "2.0",
"id": 24,
"result": {
"removed": [
"3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f"
],
"failed": []
}
},
"assertions": [
"the .veloxpart and .veloxpart.meta pair is always removed",
"the finished file is removed only when deleteFile is true",
"this call is -32003 over the WebSocket transport"
]
}
+39
View File
@@ -0,0 +1,39 @@
{
"name": "download.resume \u2014 applied to a two-task selection",
"description": "Revalidated with If-Range against the stored validator before a single byte is appended.",
"request": {
"jsonrpc": "2.0",
"id": 22,
"method": "download.resume",
"params": {
"taskIds": [
"$taskId",
"$taskId2"
]
}
},
"response": {
"jsonrpc": "2.0",
"id": 22,
"result": {
"updated": [
{
"taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"state": "connecting",
"changed": true
},
{
"taskId": "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
"state": "connecting",
"changed": false
}
],
"failed": []
}
},
"assertions": [
"a task already in the target state is reported with changed false, not as a failure",
"an unknown id lands in failed[] with -32010 and never fails the whole call",
"event.task.state is emitted for every entry whose changed is true"
]
}
+39
View File
@@ -0,0 +1,39 @@
{
"name": "download.start \u2014 applied to a two-task selection",
"description": "A queued task jumps its queue; one already downloading is a no-op reported as changed false.",
"request": {
"jsonrpc": "2.0",
"id": 20,
"method": "download.start",
"params": {
"taskIds": [
"$taskId",
"$taskId2"
]
}
},
"response": {
"jsonrpc": "2.0",
"id": 20,
"result": {
"updated": [
{
"taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"state": "connecting",
"changed": true
},
{
"taskId": "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
"state": "connecting",
"changed": false
}
],
"failed": []
}
},
"assertions": [
"a task already in the target state is reported with changed false, not as a failure",
"an unknown id lands in failed[] with -32010 and never fails the whole call",
"event.task.state is emitted for every entry whose changed is true"
]
}
+48
View File
@@ -0,0 +1,48 @@
{
"name": "download.update \u2014 refile a task into another category",
"description": "Moving saveDir moves the file on disk in the same operation, which is what makes a drag onto the category tree one RPC.",
"request": {
"jsonrpc": "2.0",
"id": 25,
"method": "download.update",
"params": {
"taskId": "$taskId",
"patch": {
"categoryId": "video",
"saveDir": "/home/sami/Downloads/Video",
"description": "Ubuntu 26.04 desktop image"
}
}
},
"response": {
"jsonrpc": "2.0",
"id": 25,
"result": {
"taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"filename": "ubuntu-26.04-desktop-amd64.iso",
"saveDir": "/home/sami/Downloads/Video",
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"effectiveUrl": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"sizeBytes": 6228541440,
"downloadedBytes": 2941190144,
"state": "downloading",
"speedBps": 29796556,
"etaSeconds": 110,
"resumable": true,
"segments": 8,
"categoryId": "video",
"queueId": null,
"queuePosition": null,
"description": "Ubuntu 26.04 desktop image",
"createdAt": "$isoDate",
"lastTryAt": "$isoDate",
"completedAt": null,
"error": null
}
},
"assertions": [
"the file and its .veloxpart.meta move together, or neither moves",
"the new saveDir is canonicalized and checked against the allowed roots first",
"a running task is not re-segmented underneath the user"
]
}
@@ -0,0 +1,32 @@
{
"name": "capture.offer \u2014 a monitored type below the minimum size is declined",
"description": "Not an error: a normal 'no'. The daemon answers well inside the deadline and the extension lets Firefox handle it. reason is what the popup's diagnostics show.",
"request": {
"jsonrpc": "2.0",
"id": 111,
"method": "capture.offer",
"params": {
"url": "https://example.org/thumb.zip",
"method": "GET",
"tabUrl": "https://example.org/gallery",
"contentType": "application/zip",
"contentLength": 4096,
"filename": "thumb.zip",
"origin": "moz-extension://11111111-2222-3333-4444-555555555555"
}
},
"response": {
"jsonrpc": "2.0",
"id": 111,
"result": {
"action": "ignore",
"taskId": null,
"reason": "below_min_size"
}
},
"assertions": [
"action ignore means the extension returns {} and Firefox downloads normally",
"the answer still arrives within 750 ms",
"reason is set on every ignore so a puzzled user can find out why"
]
}
@@ -0,0 +1,29 @@
{
"name": "capture.offer \u2014 the daemon does not answer within 750 ms",
"description": "The most important test in this directory. The daemon is slow, wedged or gone; the extension must abandon the offer and let Firefox download the file normally. A download manager that eats downloads when its daemon is down is worse than no download manager, and this behaviour is non-negotiable.",
"kind": "timeout",
"deadlineMs": 750,
"request": {
"jsonrpc": "2.0",
"id": 110,
"method": "capture.offer",
"params": {
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"method": "GET",
"tabUrl": "https://releases.ubuntu.com/26.04/",
"contentType": "application/octet-stream",
"contentLength": 6228541440,
"origin": "moz-extension://11111111-2222-3333-4444-555555555555"
}
},
"response": null,
"assertions": [
"the extension gives up at 750 ms measured from send, not from connect",
"webRequest returns {} so Firefox downloads the file itself",
"no task is created, and the user sees the download in Firefox's own list",
"the extension does not retry: a retry would race the browser's own download",
"the same behaviour applies when the transport is disconnected entirely",
"reproduced against mockd with --slow 2000, and by killing the daemon outright"
],
"requires": "a daemon that is slow, wedged or absent"
}
@@ -0,0 +1,28 @@
{
"name": "download.add \u2014 not enough space to preallocate",
"description": "Preallocating at the final size means the failure happens now, on add, rather than at 97 % after an hour.",
"request": {
"jsonrpc": "2.0",
"id": 106,
"method": "download.add",
"params": {
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"saveDir": "/home/sami/Downloads/Programs",
"startMode": "now"
}
},
"response": {
"jsonrpc": "2.0",
"id": 106,
"error": {
"code": -32012,
"message": "not enough free space to preallocate 5.8 GB"
}
},
"assertions": [
"the check is against the actual filesystem holding saveDir, not the home directory",
"the partially created .veloxpart is removed before this error is returned",
"not replayable against a mock: exercised in tests/integration on a small tmpfs"
],
"requires": "a filesystem with no free space"
}
@@ -0,0 +1,29 @@
{
"name": "download.add \u2014 segments above the contract's maximum",
"description": "Params are validated against the schema before any handler runs, so a handler never sees an out-of-range value.",
"request": {
"jsonrpc": "2.0",
"id": 108,
"method": "download.add",
"params": {
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"segments": 64,
"startMode": "now"
}
},
"response": {
"jsonrpc": "2.0",
"id": 108,
"error": {
"code": -32602,
"message": "params/segments: value is above the maximum of 32",
"data": {
"path": "params/segments"
}
}
},
"assertions": [
"data.path is a JSON Pointer at the offending field",
"validation happens before the handler, on both transports"
]
}
@@ -0,0 +1,30 @@
{
"name": "download.add \u2014 a destination that escapes the allowed roots",
"description": "Paths are canonicalized before the check, so ../ traversal and symlinks cannot smuggle a write outside saveTo.allowedRoots.",
"request": {
"jsonrpc": "2.0",
"id": 105,
"method": "download.add",
"params": {
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"saveDir": "/home/sami/Downloads/../../etc",
"startMode": "now"
}
},
"response": {
"jsonrpc": "2.0",
"id": 105,
"error": {
"code": -32011,
"message": "destination is outside the allowed roots",
"data": {
"path": "/home/sami/Downloads/../../etc"
}
}
},
"assertions": [
"the path is canonicalized first: the check is on the resolved path, never the literal string",
"no file, no .veloxpart and no database row is created",
"a symlink whose target escapes the roots is refused the same way"
]
}
@@ -0,0 +1,27 @@
{
"name": "download.get \u2014 an unknown task id",
"description": "The ordinary stale-client case: the GUI asks about a row another client has since removed.",
"request": {
"jsonrpc": "2.0",
"id": 104,
"method": "download.get",
"params": {
"taskId": "00000000-0000-4000-8000-000000000000"
}
},
"response": {
"jsonrpc": "2.0",
"id": 104,
"error": {
"code": -32010,
"message": "no such task",
"data": {
"taskId": "00000000-0000-4000-8000-000000000000"
}
}
},
"assertions": [
"a bulk method reports this per id in failed[] instead of failing the whole call",
"the client's correct response is to drop the row, not to retry"
]
}
@@ -0,0 +1,30 @@
{
"name": "download.probe \u2014 the server answered 403",
"description": "data.httpStatus is what lets the GUI say 'the link has expired' instead of 'probe failed'.",
"request": {
"jsonrpc": "2.0",
"id": 107,
"method": "download.probe",
"params": {
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso?token=expired"
}
},
"response": {
"jsonrpc": "2.0",
"id": 107,
"error": {
"code": -32013,
"message": "probe failed: HTTP 403",
"data": {
"httpStatus": 403
}
}
},
"assertions": [
"data.httpStatus is present whenever there was an HTTP response at all",
"a DNS or connection failure returns -32013 with httpStatus null",
"no task is created by a failed probe",
"not replayable against a mock: exercised in tests/integration against tools/testserver"
],
"requires": "an origin server that answers 403"
}
@@ -0,0 +1,22 @@
{
"name": "an unknown method name",
"description": "Version skew and local port-scanning both look like this. Neither gets a useful reply.",
"request": {
"jsonrpc": "2.0",
"id": 109,
"method": "download.deleteEverything",
"params": {}
},
"response": {
"jsonrpc": "2.0",
"id": 109,
"error": {
"code": -32601,
"message": "no such method"
}
},
"assertions": [
"the reply does not enumerate valid methods",
"an unknown method never closes the connection: a newer client may simply be probing for a capability"
]
}
@@ -0,0 +1,29 @@
{
"name": "session.hello \u2014 an unpaired WebSocket client is refused",
"description": "The WS transport is reachable by any local process, so a token is mandatory there. The Unix socket needs none: SO_PEERCRED already proved same-UID.",
"transport": "ws",
"request": {
"jsonrpc": "2.0",
"id": 101,
"method": "session.hello",
"params": {
"clientType": "extension",
"clientName": "Velox for Firefox",
"protocolVersion": "1.0.0",
"token": "not-a-real-token"
}
},
"response": {
"jsonrpc": "2.0",
"id": 101,
"error": {
"code": -32002,
"message": "not paired: call session.pair first"
}
},
"assertions": [
"only session.pair is served on an unauthenticated WebSocket connection",
"the reply does not reveal whether the token was absent, malformed or merely wrong",
"this failure counts towards the pairing rate limit"
]
}
@@ -0,0 +1,33 @@
{
"name": "session.hello \u2014 a client built against protocol 2.x is refused",
"description": "Major mismatch fails loudly at connect rather than subtly at the tenth field. The GUI renders this as 'Velox needs updating'.",
"request": {
"jsonrpc": "2.0",
"id": 100,
"method": "session.hello",
"params": {
"clientType": "gui",
"clientName": "velox-gui 9.9.9",
"protocolVersion": "2.0.0"
}
},
"response": {
"jsonrpc": "2.0",
"id": 100,
"error": {
"code": -32001,
"message": "protocol major version mismatch: daemon speaks 1.x, client speaks 2.x",
"data": {
"expected": "1.0.0",
"actual": "2.0.0"
}
}
},
"assertions": [
"the connection is closed after this reply; no method is served on a mismatched major",
"a differing minor or patch is accepted, never refused",
"the message is safe to show a user verbatim",
"the version check is transport-independent; this is replayed on the Unix socket so it is not masked by -32002"
],
"transport": "uds"
}
@@ -0,0 +1,33 @@
{
"name": "session.pair \u2014 the sixth failed attempt in a minute is locked out",
"description": "Rate limiting is what stops another local process brute-forcing its way to a token.",
"transport": "ws",
"request": {
"jsonrpc": "2.0",
"id": 102,
"method": "session.pair",
"params": {
"clientName": "Velox for Firefox",
"extensionId": "11111111-2222-3333-4444-555555555555",
"code": "0000"
}
},
"response": {
"jsonrpc": "2.0",
"id": 102,
"error": {
"code": -32014,
"message": "too many pairing attempts; try again later",
"data": {
"retryAfterSec": 60
}
}
},
"assertions": [
"five failures per minute, then a 60 s lockout",
"the lockout is per-origin and survives a reconnect, or it is not a lockout",
"no user prompt is shown while locked out \u2014 the prompt itself is the thing being flooded",
"the runner does not brute-force a live daemon; mockd reproduces it under --lockout"
],
"requires": "six failed pairing attempts inside one minute"
}
@@ -0,0 +1,30 @@
{
"name": "settings.set \u2014 a privileged method called over the WebSocket transport",
"description": "The extension may request a download; it may not reconfigure the daemon. Letting it write saveTo.allowedRoots would defeat every path check in the project.",
"transport": "ws",
"request": {
"jsonrpc": "2.0",
"id": 103,
"method": "settings.set",
"params": {
"values": {
"saveTo.allowedRoots": [
"/"
]
}
}
},
"response": {
"jsonrpc": "2.0",
"id": 103,
"error": {
"code": -32003,
"message": "method is not permitted on this transport"
}
},
"assertions": [
"the check happens before params are even parsed",
"every method with x-privileged true behaves identically here",
"nothing is written and no event is emitted"
]
}
@@ -0,0 +1,18 @@
{
"name": "event.auth.required \u2014 a server wants Basic credentials",
"description": "The task waits in retry_wait until the client supplies them. Credentials go to the Secret Service, never back through this event and never into a log.",
"notification": {
"jsonrpc": "2.0",
"method": "event.auth.required",
"params": {
"taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"host": "files.example.org",
"realm": "Restricted",
"scheme": "basic"
}
},
"assertions": [
"no credential material appears in this payload",
"the task sits in retry_wait rather than failing outright"
]
}
@@ -0,0 +1,19 @@
{
"name": "event.grabber.progress \u2014 a crawl in flight",
"description": "done true means the file list in grabber.status is final.",
"notification": {
"jsonrpc": "2.0",
"method": "event.grabber.progress",
"params": {
"jobId": "grab-7f21",
"found": 3,
"crawled": 12,
"done": false,
"currentUrl": "https://releases.ubuntu.com/26.04/"
}
},
"assertions": [
"emitted at no more than 4 Hz",
"the wizard shows found and crawled separately: they diverge on a deep crawl"
]
}
@@ -0,0 +1,19 @@
{
"name": "event.notify \u2014 a download finished",
"description": "The client decides between a toast, a tray balloon and a sound; the daemon does not assume a GUI is running.",
"notification": {
"jsonrpc": "2.0",
"method": "event.notify",
"params": {
"level": "success",
"title": "Download complete",
"body": "ubuntu-26.04-desktop-amd64.iso (5.8 GB) finished in 3 m 28 s.",
"taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"sound": "complete"
}
},
"assertions": [
"the daemon never assumes a GUI is running to see this",
"sound names an event, not a file path \u2014 the client owns its sound set"
]
}
@@ -0,0 +1,18 @@
{
"name": "event.settings.changed \u2014 capture policy was edited",
"description": "Carries only key names. The extension watches for capture.* here and re-fetches capture.getRules so its mirror never lags the daemon.",
"notification": {
"jsonrpc": "2.0",
"method": "event.settings.changed",
"params": {
"keys": [
"capture.monitoredExtensions",
"capture.minSizeBytes"
]
}
},
"assertions": [
"a client re-reads only the keys it cares about",
"the extension treats any capture.* key as a signal to call capture.getRules"
]
}
@@ -0,0 +1,18 @@
{
"name": "event.speed.global \u2014 the status bar's 1 Hz tick",
"description": "Emitted even when nothing is active, so a client can tell 'idle' from 'disconnected'.",
"notification": {
"jsonrpc": "2.0",
"method": "event.speed.global",
"params": {
"downBps": 29796556,
"activeCount": 1,
"queuedCount": 2,
"limitBps": null
}
},
"assertions": [
"emitted at 1 Hz whether or not anything is downloading",
"limitBps null means the limiter is off"
]
}
@@ -0,0 +1,37 @@
{
"name": "event.task.added \u2014 a new row appears",
"description": "summary is always present so a client can insert the row without a follow-up download.get.",
"notification": {
"jsonrpc": "2.0",
"method": "event.task.added",
"params": {
"taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"summary": {
"taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"filename": "ubuntu-26.04-desktop-amd64.iso",
"saveDir": "/home/sami/Downloads/Programs",
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"effectiveUrl": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"sizeBytes": 6228541440,
"downloadedBytes": 0,
"state": "queued",
"speedBps": 0,
"etaSeconds": null,
"resumable": true,
"segments": 8,
"categoryId": "programs",
"queueId": null,
"queuePosition": null,
"description": null,
"createdAt": "$isoDate",
"lastTryAt": null,
"completedAt": null,
"error": null
}
}
},
"assertions": [
"every subscriber sees this, including the client that made the download.add call",
"the row can be drawn from this payload alone"
]
}
@@ -0,0 +1,43 @@
{
"name": "event.task.progress \u2014 one batched tick for two tasks",
"description": "A single array at no more than 4 Hz, never one notification per task. At twenty active downloads that is four messages a second instead of eighty.",
"notification": {
"jsonrpc": "2.0",
"method": "event.task.progress",
"params": {
"tasks": [
{
"taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"downloadedBytes": 2941190144,
"speedBps": 29796556,
"etaSeconds": 110,
"segments": [
{
"index": 0,
"downloadedBytes": 402653184,
"speedBps": 4089446
},
{
"index": 1,
"downloadedBytes": 356515840,
"speedBps": 3565158
}
]
},
{
"taskId": "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
"downloadedBytes": 402653184,
"speedBps": 0,
"etaSeconds": null,
"segments": []
}
],
"at": "$isoDate"
}
},
"assertions": [
"emitted at no more than 4 Hz regardless of how many tasks are active",
"clients apply a row patch; rebuilding the model on this event is a bug",
"a task with no segment detail still reports its byte counter"
]
}
@@ -0,0 +1,15 @@
{
"name": "event.task.removed \u2014 a row disappears",
"description": "There is nothing further to fetch: the client deletes the row.",
"notification": {
"jsonrpc": "2.0",
"method": "event.task.removed",
"params": {
"taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"deletedFile": true
}
},
"assertions": [
"deletedFile tells the GUI whether to offer an undo that is still meaningful"
]
}
@@ -0,0 +1,54 @@
{
"name": "event.task.state \u2014 a task fails on a dead link",
"description": "Carries the summary so the row repaints in full, and the error whenever the new state is failed or retry_wait.",
"notification": {
"jsonrpc": "2.0",
"method": "event.task.state",
"params": {
"taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"state": "failed",
"previousState": "downloading",
"summary": {
"taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"filename": "ubuntu-26.04-desktop-amd64.iso",
"saveDir": "/home/sami/Downloads/Programs",
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"effectiveUrl": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"sizeBytes": 6228541440,
"downloadedBytes": 0,
"state": "failed",
"speedBps": 0,
"etaSeconds": null,
"resumable": true,
"segments": 8,
"categoryId": "programs",
"queueId": null,
"queuePosition": null,
"description": null,
"createdAt": "$isoDate",
"lastTryAt": "$isoDate",
"completedAt": null,
"error": {
"code": -32013,
"message": "HTTP 404 on resume",
"httpStatus": 404,
"retryable": false,
"attempt": 3,
"nextRetryAt": null
}
},
"error": {
"code": -32013,
"message": "HTTP 404 on resume",
"httpStatus": 404,
"retryable": false,
"attempt": 3,
"nextRetryAt": null
}
}
},
"assertions": [
"error is present exactly when state is failed or retry_wait",
"retryable false means the scheduler will not pick this up again on its own"
]
}
+37
View File
@@ -0,0 +1,37 @@
{
"name": "grabber.harvest \u2014 download the two selected ISOs",
"description": "The only grabber call that creates tasks, and it names exactly what the user ticked.",
"request": {
"jsonrpc": "2.0",
"id": 72,
"method": "grabber.harvest",
"params": {
"jobId": "grab-7f21",
"select": [
"f1",
"f2"
],
"defaults": {
"url": "https://releases.ubuntu.com/26.04/",
"categoryId": "programs",
"startMode": "queue",
"queueId": "main"
}
}
},
"response": {
"jsonrpc": "2.0",
"id": 72,
"result": {
"taskIds": [
"$uuid",
"$uuid"
],
"failed": []
}
},
"assertions": [
"only the selected fileIds become tasks",
"a fileId that is not in the job is reported in failed[], never silently skipped"
]
}
+36
View File
@@ -0,0 +1,36 @@
{
"name": "grabber.start \u2014 crawl a release directory two levels deep",
"description": "Nothing is downloaded by this call. It walks pages and collects candidates for the wizard to show.",
"request": {
"jsonrpc": "2.0",
"id": 70,
"method": "grabber.start",
"params": {
"startUrl": "https://releases.ubuntu.com/26.04/",
"depth": 2,
"includePatterns": [
"*/26.04/*"
],
"excludePatterns": [
"*/torrent/*"
],
"fileTypes": [
"iso",
"zsync"
],
"sameHostOnly": true,
"maxFiles": 200
}
},
"response": {
"jsonrpc": "2.0",
"id": 70,
"result": {
"jobId": "grab-7f21"
}
},
"assertions": [
"a crawl never starts a download on its own",
"sameHostOnly true keeps the crawl off third-party hosts linked from the page"
]
}
+56
View File
@@ -0,0 +1,56 @@
{
"name": "grabber.status \u2014 crawl finished with three candidates",
"description": "Also delivered as event.grabber.progress; the poll exists so a reopened wizard can catch up.",
"request": {
"jsonrpc": "2.0",
"id": 71,
"method": "grabber.status",
"params": {
"jobId": "grab-7f21"
}
},
"response": {
"jsonrpc": "2.0",
"id": 71,
"result": {
"jobId": "grab-7f21",
"state": "done",
"crawled": 12,
"found": 3,
"files": [
{
"fileId": "f1",
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"filename": "ubuntu-26.04-desktop-amd64.iso",
"sizeBytes": 6228541440,
"contentType": "application/octet-stream",
"depth": 1,
"foundOn": "https://releases.ubuntu.com/26.04/"
},
{
"fileId": "f2",
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-live-server-amd64.iso",
"filename": "ubuntu-26.04-live-server-amd64.iso",
"sizeBytes": 2617245696,
"contentType": "application/octet-stream",
"depth": 1,
"foundOn": "https://releases.ubuntu.com/26.04/"
},
{
"fileId": "f3",
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso.zsync",
"filename": "ubuntu-26.04-desktop-amd64.iso.zsync",
"sizeBytes": 12189696,
"contentType": "application/octet-stream",
"depth": 1,
"foundOn": "https://releases.ubuntu.com/26.04/"
}
],
"error": null
}
},
"assertions": [
"state done means the file list is final",
"sizeBytes is null where the server refused a HEAD, and the wizard must cope with that"
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "limiter.get \u2014 the limiter is off",
"description": "globalBps still carries the last configured value so the GUI can restore it when the user re-enables the limit.",
"request": {
"jsonrpc": "2.0",
"id": 52,
"method": "limiter.get",
"params": {}
},
"response": {
"jsonrpc": "2.0",
"id": 52,
"result": {
"enabled": false,
"globalBps": 2097152,
"applyToRunning": false
}
},
"assertions": [
"enabled false means no throttling regardless of globalBps"
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "limiter.set \u2014 cap at 2 MiB/s and retune what is already running",
"description": "applyToRunning true is the Speed Limiter window's 'apply now' button.",
"request": {
"jsonrpc": "2.0",
"id": 53,
"method": "limiter.set",
"params": {
"enabled": true,
"globalBps": 2097152,
"applyToRunning": true
}
},
"response": {
"jsonrpc": "2.0",
"id": 53,
"result": {
"enabled": true,
"globalBps": 2097152,
"applyToRunning": true
}
},
"assertions": [
"the limit is global across every active task, not per task",
"with applyToRunning true, transfers already in flight are retuned rather than waiting for the next task"
]
}
+34
View File
@@ -0,0 +1,34 @@
{
"name": "media.addVariant \u2014 download 1080p with the English audio muxed in",
"description": "The result is an ordinary task that appears in the list like any other download.",
"request": {
"jsonrpc": "2.0",
"id": 62,
"method": "media.addVariant",
"params": {
"manifestUrl": "https://cdn.example.org/v/master.m3u8",
"variantId": "v-1080p",
"audioVariantId": "a-en",
"spec": {
"url": "https://cdn.example.org/v/master.m3u8",
"filename": "episode-42.mkv",
"categoryId": "video",
"startMode": "now"
}
}
},
"response": {
"jsonrpc": "2.0",
"id": 62,
"result": {
"taskId": "$uuid",
"state": "connecting",
"estimatedBytes": 1923000000
}
},
"assertions": [
"spec.url is ignored: the manifest and variant determine the source",
"a DRM-protected variant is refused with -32602 rather than started and failed later",
"segments are fetched in parallel and muxed with ffmpeg into one container"
]
}
@@ -0,0 +1,67 @@
{
"name": "media.listVariants \u2014 an HLS master playlist",
"description": "The daemon parses the manifest; the extension never does. Keeping that logic in one language is the whole point.",
"request": {
"jsonrpc": "2.0",
"id": 61,
"method": "media.listVariants",
"params": {
"manifestUrl": "https://cdn.example.org/v/master.m3u8",
"headers": {
"Referer": "https://example.org/watch/42"
}
}
},
"response": {
"jsonrpc": "2.0",
"id": 61,
"result": {
"variants": [
{
"variantId": "v-1080p",
"kind": "video",
"resolution": "1920x1080",
"bitrateBps": 5000000,
"codec": "avc1.640028",
"container": "ts",
"frameRate": 25.0,
"language": null,
"sizeEstimate": 1875000000,
"drm": false
},
{
"variantId": "v-720p",
"kind": "video",
"resolution": "1280x720",
"bitrateBps": 2800000,
"codec": "avc1.4d401f",
"container": "ts",
"frameRate": 25.0,
"language": null,
"sizeEstimate": 1050000000,
"drm": false
},
{
"variantId": "a-en",
"kind": "audio",
"resolution": null,
"bitrateBps": 128000,
"codec": "mp4a.40.2",
"container": "ts",
"frameRate": null,
"language": "en",
"sizeEstimate": 48000000,
"drm": false
}
],
"manifestType": "hls",
"durationSec": 3000.0,
"title": "Episode 42",
"drmProtected": false
}
},
"assertions": [
"sizeEstimate is bitrate x duration and must be labelled as approximate in the UI",
"a DRM-protected variant is reported with drm true and greyed out, never attempted"
]
}
+55
View File
@@ -0,0 +1,55 @@
{
"name": "queue.list \u2014 two queues, one running",
"description": "Not privileged: the extension's Add to Queue picker needs it.",
"request": {
"jsonrpc": "2.0",
"id": 33,
"method": "queue.list",
"params": {}
},
"response": {
"jsonrpc": "2.0",
"id": 33,
"result": {
"items": [
{
"queueId": "main",
"name": "Main Queue",
"state": "running",
"maxConcurrent": 3,
"taskIds": [
"3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f"
],
"schedule": null,
"onComplete": "nothing"
},
{
"queueId": "sync",
"name": "Sync Queue",
"state": "stopped",
"maxConcurrent": 1,
"taskIds": [],
"schedule": {
"enabled": true,
"mode": "periodic",
"startTime": "02:00",
"stopTime": "06:00",
"daysOfWeek": [
1,
2,
3,
4,
5
],
"onceDate": null
},
"onComplete": "nothing"
}
]
}
},
"assertions": [
"taskIds are in run order, not insertion order"
]
}
+37
View File
@@ -0,0 +1,37 @@
{
"name": "queue.reorder \u2014 move the second task to the front",
"description": "taskIds must be a permutation of current membership; anything else is -32602 so a stale drag cannot reshuffle the queue.",
"request": {
"jsonrpc": "2.0",
"id": 37,
"method": "queue.reorder",
"params": {
"queueId": "main",
"taskIds": [
"8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
"3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071"
]
}
},
"response": {
"jsonrpc": "2.0",
"id": 37,
"result": {
"queue": {
"queueId": "main",
"name": "Main Queue",
"state": "running",
"maxConcurrent": 3,
"taskIds": [
"8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
"3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071"
],
"schedule": null,
"onComplete": "nothing"
}
}
},
"assertions": [
"a taskIds list that is not an exact permutation is rejected whole, never applied partially"
]
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "queue.start \u2014 admit up to maxConcurrent tasks",
"description": "The scheduler keeps maxConcurrent running until the queue drains or is stopped.",
"request": {
"jsonrpc": "2.0",
"id": 35,
"method": "queue.start",
"params": {
"queueId": "main"
}
},
"response": {
"jsonrpc": "2.0",
"id": 35,
"result": {
"queue": {
"queueId": "main",
"name": "Main Queue",
"state": "running",
"maxConcurrent": 3,
"taskIds": [
"3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f"
],
"schedule": null,
"onComplete": "nothing"
},
"startedTaskIds": [
"3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f"
]
}
},
"assertions": [
"no more than maxConcurrent tasks from this queue are ever running at once",
"tasks are admitted in queue order, not by size or arrival"
]
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "queue.stop \u2014 stop the queue and pause what is running",
"description": "pauseRunning false would let in-flight tasks finish: the difference between stopping a queue and stopping everything.",
"request": {
"jsonrpc": "2.0",
"id": 36,
"method": "queue.stop",
"params": {
"queueId": "main",
"pauseRunning": true
}
},
"response": {
"jsonrpc": "2.0",
"id": 36,
"result": {
"queue": {
"queueId": "main",
"name": "Main Queue",
"state": "stopped",
"maxConcurrent": 3,
"taskIds": [
"3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f"
],
"schedule": null,
"onComplete": "nothing"
},
"pausedTaskIds": [
"3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f"
]
}
},
"assertions": [
"with pauseRunning false, running tasks finish and only admission stops"
]
}
+65
View File
@@ -0,0 +1,65 @@
{
"name": "queue.upsert \u2014 set a queue's concurrency and overnight window",
"description": "taskIds in the payload is ignored; membership moves through download.update and queue.reorder so two clients editing at once cannot drop a task.",
"request": {
"jsonrpc": "2.0",
"id": 34,
"method": "queue.upsert",
"params": {
"queue": {
"queueId": "sync",
"name": "Sync Queue",
"state": "stopped",
"maxConcurrent": 2,
"taskIds": [],
"schedule": {
"enabled": true,
"mode": "periodic",
"startTime": "02:00",
"stopTime": "06:00",
"daysOfWeek": [
1,
2,
3,
4,
5
],
"onceDate": null
},
"onComplete": "nothing"
}
}
},
"response": {
"jsonrpc": "2.0",
"id": 34,
"result": {
"queue": {
"queueId": "sync",
"name": "Sync Queue",
"state": "stopped",
"maxConcurrent": 2,
"taskIds": [],
"schedule": {
"enabled": true,
"mode": "periodic",
"startTime": "02:00",
"stopTime": "06:00",
"daysOfWeek": [
1,
2,
3,
4,
5
],
"onceDate": null
},
"onComplete": "nothing"
}
}
},
"assertions": [
"the taskIds sent by the client are ignored and the stored order is echoed back",
"schedule times are stored as local wall-clock and re-evaluated on a DST change"
]
}
+69
View File
@@ -0,0 +1,69 @@
{
"name": "rules.list \u2014 the routing table in priority order",
"description": "Privileged: this is the daemon's policy. The extension gets its narrowed view from capture.getRules.",
"request": {
"jsonrpc": "2.0",
"id": 38,
"method": "rules.list",
"params": {}
},
"response": {
"jsonrpc": "2.0",
"id": 38,
"result": {
"items": [
{
"ruleId": "iso-to-programs",
"name": "Disk images",
"enabled": true,
"priority": 10,
"match": {
"extensions": [
"iso",
"img"
],
"mimeTypes": null,
"hostPattern": null,
"urlPattern": null,
"minSizeBytes": null,
"maxSizeBytes": null
},
"action": {
"categoryId": "programs",
"saveDir": null,
"queueId": null,
"segments": 8,
"startMode": null,
"capture": null
}
},
{
"ruleId": "never-intranet",
"name": "Never capture the intranet",
"enabled": true,
"priority": 20,
"match": {
"extensions": null,
"mimeTypes": null,
"hostPattern": "*.corp.internal",
"urlPattern": null,
"minSizeBytes": null,
"maxSizeBytes": null
},
"action": {
"categoryId": null,
"saveDir": null,
"queueId": null,
"segments": null,
"startMode": null,
"capture": "ignore"
}
}
]
}
},
"assertions": [
"items are ordered by priority ascending; first match wins",
"no rule matching means the default category, never an error"
]
}
+97
View File
@@ -0,0 +1,97 @@
{
"name": "rules.upsert \u2014 add one rule and drop another atomically",
"description": "Applying adds and removes in one write means a reprioritisation never leaves the table half-valid.",
"request": {
"jsonrpc": "2.0",
"id": 39,
"method": "rules.upsert",
"params": {
"upsert": [
{
"ruleId": "big-to-queue",
"name": "Queue anything over 1 GiB",
"enabled": true,
"priority": 5,
"match": {
"extensions": null,
"mimeTypes": null,
"hostPattern": null,
"urlPattern": null,
"minSizeBytes": 1073741824,
"maxSizeBytes": null
},
"action": {
"categoryId": null,
"saveDir": null,
"queueId": "main",
"segments": null,
"startMode": "queue",
"capture": null
}
}
],
"remove": [
"never-intranet"
]
}
},
"response": {
"jsonrpc": "2.0",
"id": 39,
"result": {
"items": [
{
"ruleId": "big-to-queue",
"name": "Queue anything over 1 GiB",
"enabled": true,
"priority": 5,
"match": {
"extensions": null,
"mimeTypes": null,
"hostPattern": null,
"urlPattern": null,
"minSizeBytes": 1073741824,
"maxSizeBytes": null
},
"action": {
"categoryId": null,
"saveDir": null,
"queueId": "main",
"segments": null,
"startMode": "queue",
"capture": null
}
},
{
"ruleId": "iso-to-programs",
"name": "Disk images",
"enabled": true,
"priority": 10,
"match": {
"extensions": [
"iso",
"img"
],
"mimeTypes": null,
"hostPattern": null,
"urlPattern": null,
"minSizeBytes": null,
"maxSizeBytes": null
},
"action": {
"categoryId": "programs",
"saveDir": null,
"queueId": null,
"segments": 8,
"startMode": null,
"capture": null
}
}
]
}
},
"assertions": [
"the whole table comes back in priority order so the caller need not re-list",
"adds and removes commit together or not at all"
]
}
+44
View File
@@ -0,0 +1,44 @@
{
"name": "schedule.get \u2014 every queue's schedule",
"description": "queueId null asks for all of them. Backs the Scheduler window.",
"request": {
"jsonrpc": "2.0",
"id": 40,
"method": "schedule.get",
"params": {
"queueId": null
}
},
"response": {
"jsonrpc": "2.0",
"id": 40,
"result": {
"items": [
{
"queueId": "main",
"schedule": null
},
{
"queueId": "sync",
"schedule": {
"enabled": true,
"mode": "periodic",
"startTime": "02:00",
"stopTime": "06:00",
"daysOfWeek": [
1,
2,
3,
4,
5
],
"onceDate": null
}
}
]
}
},
"assertions": [
"a null schedule means the queue is under manual control"
]
}
+52
View File
@@ -0,0 +1,52 @@
{
"name": "schedule.set \u2014 run the sync queue on weekday nights",
"description": "Times are local wall-clock and are re-evaluated on a DST change rather than resolved to absolute instants now.",
"request": {
"jsonrpc": "2.0",
"id": 41,
"method": "schedule.set",
"params": {
"queueId": "sync",
"schedule": {
"enabled": true,
"mode": "periodic",
"startTime": "02:00",
"stopTime": "06:00",
"daysOfWeek": [
1,
2,
3,
4,
5
],
"onceDate": null
}
}
},
"response": {
"jsonrpc": "2.0",
"id": 41,
"result": {
"queueId": "sync",
"schedule": {
"enabled": true,
"mode": "periodic",
"startTime": "02:00",
"stopTime": "06:00",
"daysOfWeek": [
1,
2,
3,
4,
5
],
"onceDate": null
},
"nextRunAt": "$isoDate"
}
},
"assertions": [
"nextRunAt is computed in local time and skips days not in daysOfWeek",
"a null schedule clears it and leaves the queue manual"
]
}
+36
View File
@@ -0,0 +1,36 @@
{
"name": "session.hello \u2014 a GUI client connects over the Unix socket",
"description": "The handshake every client makes first. Matching majors, so the daemon answers with its capability list.",
"request": {
"jsonrpc": "2.0",
"id": 1,
"method": "session.hello",
"params": {
"clientType": "gui",
"clientName": "velox-gui 0.1.0",
"protocolVersion": "1.0.0"
}
},
"response": {
"jsonrpc": "2.0",
"id": 1,
"result": {
"daemonVersion": "0.1.0",
"protocolVersion": "1.0.0",
"capabilities": [
"media",
"grabber",
"secretservice"
],
"sessionId": "$uuid",
"transport": "uds"
}
},
"assertions": [
"the reply names a protocolVersion whose major matches the request's",
"sessionId is a fresh uuid per connection, not per client",
"no token is required on the Unix socket: SO_PEERCRED already proved same-UID",
"replayed on the Unix socket only: the same call without a token is -32002 over the WebSocket"
],
"transport": "uds"
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "session.pair \u2014 the extension pairs over the WebSocket transport",
"description": "First run only. The daemon prompts the user and returns a 256-bit token once they approve.",
"request": {
"jsonrpc": "2.0",
"id": 2,
"method": "session.pair",
"params": {
"clientName": "Velox for Firefox",
"extensionId": "11111111-2222-3333-4444-555555555555"
}
},
"response": {
"jsonrpc": "2.0",
"id": 2,
"result": {
"token": "$opaque",
"expiresAt": null
}
},
"assertions": [
"a user prompt is shown before any token is issued \u2014 never auto-approve",
"the token is at least 256 bits of entropy, base64url encoded",
"the daemon stores a hash of the token, never the token itself",
"this method is refused with -32003 on the Unix socket"
]
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "session.subscribe \u2014 the GUI asks for the full event set",
"description": "Nothing is delivered until this is called. Subscribing replaces any previous selection.",
"request": {
"jsonrpc": "2.0",
"id": 3,
"method": "session.subscribe",
"params": {
"events": [
"event.task.added",
"event.task.removed",
"event.task.state",
"event.task.progress",
"event.speed.global",
"event.notify"
]
}
},
"response": {
"jsonrpc": "2.0",
"id": 3,
"result": {
"ok": true,
"events": [
"event.task.added",
"event.task.removed",
"event.task.state",
"event.task.progress",
"event.speed.global",
"event.notify"
]
}
},
"assertions": [
"the echoed list lets a client spot an event it asked for that this daemon does not emit",
"no notification arrives on this connection before this call succeeds"
]
}
+34
View File
@@ -0,0 +1,34 @@
{
"name": "settings.get \u2014 the Connection tab's keys",
"description": "Privileged: the settings bag names local paths and the allowed write roots, which the extension has no business enumerating.",
"request": {
"jsonrpc": "2.0",
"id": 50,
"method": "settings.get",
"params": {
"keys": [
"connection.maxSegmentsPerDownload",
"connection.bufferBytes",
"connection.maxConcurrentDownloads",
"connection.timeoutSec"
]
}
},
"response": {
"jsonrpc": "2.0",
"id": 50,
"result": {
"values": {
"connection.maxSegmentsPerDownload": 8,
"connection.bufferBytes": 4194304,
"connection.maxConcurrentDownloads": 5,
"connection.timeoutSec": 30
}
}
},
"assertions": [
"only the requested keys come back",
"keys null returns everything",
"no password is ever present: credentials live in the Secret Service"
]
}
+34
View File
@@ -0,0 +1,34 @@
{
"name": "settings.set \u2014 raise the segment cap and turn on checksums",
"description": "Only the keys present change, and changed[] names exactly what took effect.",
"request": {
"jsonrpc": "2.0",
"id": 51,
"method": "settings.set",
"params": {
"values": {
"connection.maxSegmentsPerDownload": 16,
"downloads.verifyChecksums": true
}
}
},
"response": {
"jsonrpc": "2.0",
"id": 51,
"result": {
"values": {
"connection.maxSegmentsPerDownload": 16,
"downloads.verifyChecksums": true
},
"changed": [
"connection.maxSegmentsPerDownload",
"downloads.verifyChecksums"
]
}
},
"assertions": [
"event.settings.changed is emitted carrying exactly the keys in changed[]",
"an unknown key is -32602 and nothing at all is written",
"a directory key naming an unwritable path is -32011"
]
}
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/envelope.schema.json",
"title": "Envelope",
"description": "JSON-RPC 2.0 envelope as Velox uses it, plus the project's error codes. Every transport (UDS NDJSON, native-messaging length-prefix, loopback WebSocket) carries exactly these payloads; only the framing differs.",
"$defs": {
"Id": {
"title": "Id",
"description": "Request correlation id. Velox clients always send an integer; string ids are accepted for spec compliance. A notification has no id.",
"type": ["integer", "string"]
},
"Request": {
"title": "Request",
"type": "object",
"additionalProperties": false,
"required": ["jsonrpc", "method"],
"properties": {
"jsonrpc": { "const": "2.0" },
"id": { "$ref": "#/$defs/Id" },
"method": { "type": "string" },
"params": { "type": "object" }
}
},
"Response": {
"title": "Response",
"description": "Exactly one of result or error is present.",
"type": "object",
"additionalProperties": false,
"required": ["jsonrpc", "id"],
"properties": {
"jsonrpc": { "const": "2.0" },
"id": { "$ref": "#/$defs/Id" },
"result": {},
"error": { "$ref": "#/$defs/Error" }
}
},
"Notification": {
"title": "Notification",
"description": "Server to client. No id, never answered.",
"type": "object",
"additionalProperties": false,
"required": ["jsonrpc", "method", "params"],
"properties": {
"jsonrpc": { "const": "2.0" },
"method": { "type": "string" },
"params": { "type": "object" }
}
},
"Error": {
"title": "Error",
"type": "object",
"additionalProperties": false,
"required": ["code", "message"],
"properties": {
"code": { "$ref": "https://velox.dev/schema/types/ErrorCode.schema.json" },
"message": { "type": "string" },
"data": {
"type": ["object", "null"],
"description": "Code-specific detail. -32013 carries data.httpStatus; -32011 carries data.path; -32014 carries data.retryAfterSec.",
"properties": {
"httpStatus": { "type": ["integer", "null"] },
"path": { "type": ["string", "null"] },
"retryAfterSec": { "type": ["integer", "null"] },
"taskId": { "type": ["string", "null"] },
"expected": { "type": ["string", "null"] },
"actual": { "type": ["string", "null"] }
}
}
}
}
}
}
@@ -0,0 +1,44 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/events/event.auth.required.schema.json",
"title": "event.auth.required",
"description": "A server asked for credentials. The task sits in retry_wait until the client supplies them. Credentials travel to the Secret Service, never back through this event and never into a log.",
"x-direction": "server-to-client",
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": [
"taskId",
"host",
"scheme"
],
"properties": {
"taskId": {
"type": "string",
"format": "uuid"
},
"host": {
"type": "string"
},
"realm": {
"type": [
"string",
"null"
]
},
"scheme": {
"type": "string",
"enum": [
"basic",
"digest",
"ntlm",
"negotiate",
"proxy"
]
}
}
}
}
}
@@ -0,0 +1,44 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/events/event.grabber.progress.schema.json",
"title": "event.grabber.progress",
"description": "Crawl progress for the Site Grabber wizard. done true means the file list in grabber.status is final.",
"x-direction": "server-to-client",
"x-maxRateHz": 4,
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": [
"jobId",
"found",
"crawled",
"done"
],
"properties": {
"jobId": {
"type": "string"
},
"found": {
"type": "integer",
"minimum": 0
},
"crawled": {
"type": "integer",
"minimum": 0
},
"done": {
"type": "boolean"
},
"currentUrl": {
"type": [
"string",
"null"
],
"format": "uri"
}
}
}
}
}
@@ -0,0 +1,57 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/events/event.notify.schema.json",
"title": "event.notify",
"description": "Something the user should see: a completion, a failure, a queue finishing. The client decides between a toast, a tray balloon and a sound; the daemon does not assume a GUI is running.",
"x-direction": "server-to-client",
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": [
"level",
"title",
"body"
],
"properties": {
"level": {
"type": "string",
"enum": [
"info",
"success",
"warning",
"error"
]
},
"title": {
"type": "string",
"maxLength": 128
},
"body": {
"type": "string",
"maxLength": 1024
},
"taskId": {
"type": [
"string",
"null"
],
"format": "uuid"
},
"sound": {
"type": [
"string",
"null"
],
"enum": [
"complete",
"queueComplete",
"error",
null
]
}
}
}
}
}
@@ -0,0 +1,25 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/events/event.settings.changed.schema.json",
"title": "event.settings.changed",
"description": "Settings were written by some client. Carries only the key names; a client re-reads what it cares about. The extension watches for capture.* here and re-fetches capture.getRules so its rules never lag the daemon's.",
"x-direction": "server-to-client",
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": [
"keys"
],
"properties": {
"keys": {
"type": "array",
"items": {
"$ref": "https://velox.dev/schema/types/SettingKey.schema.json"
}
}
}
}
}
}
@@ -0,0 +1,41 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/events/event.speed.global.schema.json",
"title": "event.speed.global",
"description": "Aggregate throughput for the status bar, the tray tooltip and the extension popup. Emitted at 1 Hz even when nothing is active, so a client can tell 'idle' from 'disconnected'.",
"x-direction": "server-to-client",
"x-maxRateHz": 1,
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": [
"downBps",
"activeCount"
],
"properties": {
"downBps": {
"type": "integer",
"minimum": 0
},
"activeCount": {
"type": "integer",
"minimum": 0
},
"queuedCount": {
"type": "integer",
"minimum": 0
},
"limitBps": {
"type": [
"integer",
"null"
],
"minimum": 0,
"description": "null when the limiter is off."
}
}
}
}
}
@@ -0,0 +1,27 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/events/event.task.added.schema.json",
"title": "event.task.added",
"description": "A task entered the list. summary is always present so a client can insert the row without a follow-up download.get.",
"x-direction": "server-to-client",
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": [
"taskId",
"summary"
],
"properties": {
"taskId": {
"type": "string",
"format": "uuid"
},
"summary": {
"$ref": "https://velox.dev/schema/types/TaskSummary.schema.json"
}
}
}
}
}
@@ -0,0 +1,87 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/events/event.task.progress.schema.json",
"title": "event.task.progress",
"description": "Batched byte counters for every active task. Emitted at no more than 4 Hz as one array, never one notification per task: at twenty active downloads that is four messages a second instead of eighty. Clients apply a row patch and repaint the touched columns; rebuilding a model on this event is a bug.",
"x-direction": "server-to-client",
"x-maxRateHz": 4,
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": [
"tasks",
"at"
],
"properties": {
"tasks": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"taskId",
"downloadedBytes",
"speedBps"
],
"properties": {
"taskId": {
"type": "string",
"format": "uuid"
},
"downloadedBytes": {
"type": "integer",
"minimum": 0
},
"speedBps": {
"type": "integer",
"minimum": 0
},
"etaSeconds": {
"type": [
"integer",
"null"
],
"minimum": 0
},
"segments": {
"type": "array",
"maxItems": 32,
"items": {
"type": "object",
"additionalProperties": false,
"description": "Only what a segment bar needs. Full segment state comes from download.get.",
"required": [
"index",
"downloadedBytes",
"speedBps"
],
"properties": {
"index": {
"type": "integer",
"minimum": 0,
"maximum": 31
},
"downloadedBytes": {
"type": "integer",
"minimum": 0
},
"speedBps": {
"type": "integer",
"minimum": 0
}
}
}
}
}
}
},
"at": {
"type": "string",
"format": "date-time"
}
}
}
}
}
@@ -0,0 +1,27 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/events/event.task.removed.schema.json",
"title": "event.task.removed",
"description": "A task left the list. The client deletes the row; there is nothing further to fetch.",
"x-direction": "server-to-client",
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": [
"taskId",
"deletedFile"
],
"properties": {
"taskId": {
"type": "string",
"format": "uuid"
},
"deletedFile": {
"type": "boolean"
}
}
}
}
}
@@ -0,0 +1,57 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/events/event.task.state.schema.json",
"title": "event.task.state",
"description": "A task changed lifecycle state. Carries the summary so the row can be repainted in full without a round trip, and error whenever the new state is failed or retry_wait.",
"x-direction": "server-to-client",
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": [
"taskId",
"state"
],
"properties": {
"taskId": {
"type": "string",
"format": "uuid"
},
"state": {
"$ref": "https://velox.dev/schema/types/TaskState.schema.json"
},
"previousState": {
"oneOf": [
{
"$ref": "https://velox.dev/schema/types/TaskState.schema.json"
},
{
"type": "null"
}
]
},
"summary": {
"oneOf": [
{
"$ref": "https://velox.dev/schema/types/TaskSummary.schema.json"
},
{
"type": "null"
}
]
},
"error": {
"oneOf": [
{
"$ref": "https://velox.dev/schema/types/TaskError.schema.json"
},
{
"type": "null"
}
]
}
}
}
}
}
@@ -0,0 +1,23 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/capture.getRules.schema.json",
"title": "capture.getRules",
"description": "The daemon's capture policy, so the extension's shouldCapture decision cannot drift from the daemon's. Fetched on connect and whenever event.settings.changed names a capture.* key. If this call fails the extension keeps its last known rules and stays fail-open.",
"x-privileged": false,
"x-transports": [
"uds",
"ws"
],
"x-deadlineMs": 2000,
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"properties": {}
},
"result": {
"$ref": "https://velox.dev/schema/types/CaptureRules.schema.json"
}
}
}
@@ -2,10 +2,11 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/capture.offer.schema.json",
"title": "capture.offer",
"description": "Firefox offers an intercepted response to the daemon. The daemon MUST reply within 750 ms; the extension abandons the offer and lets Firefox download normally on timeout. TEMPLATE — lane PROTO owns the final shape.",
"description": "Firefox offers an intercepted response to the daemon. The daemon MUST reply within 750 ms; the extension abandons the offer and lets Firefox download normally on timeout. This deadline is the whole reason capture fails open, and it is conformance-tested: a daemon that is slow, down, or erroring must never cost the user a download.",
"x-privileged": false,
"x-transports": ["uds", "ws"],
"x-deadlineMs": 750,
"x-errors": [-32011],
"type": "object",
"properties": {
"params": {
@@ -16,32 +17,20 @@
"url": { "type": "string", "format": "uri" },
"method": { "type": "string", "enum": ["GET", "POST"] },
"tabUrl": { "type": "string", "format": "uri" },
"headers": {
"type": "object",
"description": "Request headers Firefox was about to send, verbatim. Needed for signed-URL and referrer-gated CDNs.",
"additionalProperties": { "type": "string" }
},
"headers": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/Headers.schema.json" }, { "type": "null" }] },
"cookies": {
"type": "array",
"type": ["array", "null"],
"description": "Cookies for the URL, so authenticated downloads work outside the browser.",
"items": {
"type": "object",
"required": ["name", "value"],
"properties": {
"name": { "type": "string" },
"value": { "type": "string" },
"domain": { "type": "string" },
"path": { "type": "string" }
}
}
"items": { "$ref": "https://velox.dev/schema/types/Cookie.schema.json" }
},
"contentType": { "type": ["string", "null"] },
"contentLength": { "type": ["integer", "null"], "minimum": 0 },
"contentDisposition": { "type": ["string", "null"] },
"filename": { "type": ["string", "null"], "description": "Extension's best guess; the daemon may override" },
"filename": { "type": ["string", "null"], "description": "The extension's best guess; the daemon may override it." },
"userAgent": { "type": ["string", "null"] },
"referrer": { "type": ["string", "null"] },
"origin": { "type": "string", "description": "moz-extension://… — the daemon verifies this on the WS transport" }
"origin": { "type": ["string", "null"], "description": "moz-extension://... The daemon verifies this on the WS transport and refuses anything else." },
"requestId": { "type": ["string", "null"], "description": "The extension's webRequest id, echoed in logs so a capture decision can be traced back to one browser request." }
}
},
"result": {
@@ -50,11 +39,12 @@
"required": ["action"],
"properties": {
"action": { "type": "string", "enum": ["take", "ignore"] },
"taskId": { "type": ["string", "null"], "format": "uuid" },
"taskId": { "type": ["string", "null"], "format": "uuid", "description": "Set when action is 'take'." },
"reason": {
"type": ["string", "null"],
"description": "Why the offer was declined. Set when action is 'ignore'; the extension logs it in the popup's diagnostics.",
"enum": ["excluded_host", "type_not_monitored", "below_min_size", "duplicate",
"capture_disabled", "user_declined", null]
"capture_disabled", "user_declined", "rule_ignore", null]
}
}
}
@@ -0,0 +1,35 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/category.list.schema.json",
"title": "category.list",
"description": "Every category with its folder and extension list. The extension calls this to populate its default-category picker, which is why it is not privileged; it is read-only and exposes only paths the user already configured.",
"x-privileged": false,
"x-transports": [
"uds",
"ws"
],
"x-deadlineMs": 2000,
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"properties": {}
},
"result": {
"type": "object",
"additionalProperties": false,
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "https://velox.dev/schema/types/Category.schema.json"
}
}
}
}
}
}
@@ -0,0 +1,56 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/category.remove.schema.json",
"title": "category.remove",
"description": "Delete a user-created category. Built-in categories are refused with -32602. Tasks filed under it are reassigned to reassignTo, or to the default category when that is null; no task is ever orphaned.",
"x-privileged": true,
"x-transports": [
"uds"
],
"x-deadlineMs": 5000,
"x-errors": [
-32003,
-32602
],
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": [
"categoryId"
],
"properties": {
"categoryId": {
"type": "string"
},
"reassignTo": {
"type": [
"string",
"null"
]
}
}
},
"result": {
"type": "object",
"additionalProperties": false,
"required": [
"removed",
"reassignedTaskIds"
],
"properties": {
"removed": {
"type": "boolean"
},
"reassignedTaskIds": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
}
}
}
}
}
@@ -0,0 +1,43 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/category.upsert.schema.json",
"title": "category.upsert",
"description": "Create or replace a category. Omit categoryId to create; supply it to replace. Changing saveDir does not move existing files \u2014 the GUI asks separately and issues download.update per task, so a re-point is never a surprise mass file move.",
"x-privileged": true,
"x-transports": [
"uds"
],
"x-deadlineMs": 5000,
"x-errors": [
-32003,
-32011
],
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": [
"category"
],
"properties": {
"category": {
"$ref": "https://velox.dev/schema/types/Category.schema.json"
}
}
},
"result": {
"type": "object",
"additionalProperties": false,
"description": "The stored category, with categoryId filled in on create.",
"required": [
"category"
],
"properties": {
"category": {
"$ref": "https://velox.dev/schema/types/Category.schema.json"
}
}
}
}
}
@@ -0,0 +1,25 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/download.add.schema.json",
"title": "download.add",
"description": "Create one task. saveDir is canonicalized and checked against saveTo.allowedRoots before anything is written; a path that escapes them is refused with -32011 and no file is created.",
"x-privileged": false,
"x-transports": ["uds", "ws"],
"x-deadlineMs": 5000,
"x-errors": [-32011, -32012, -32013],
"x-wsRestrictions": ["saveDir must be absent or resolve inside an existing category folder; anything else is -32011. The extension may request a download, it may not choose an arbitrary destination."],
"type": "object",
"properties": {
"params": { "$ref": "https://velox.dev/schema/types/DownloadSpec.schema.json" },
"result": {
"type": "object",
"additionalProperties": false,
"required": ["taskId", "state"],
"properties": {
"taskId": { "type": "string", "format": "uuid" },
"state": { "$ref": "https://velox.dev/schema/types/TaskState.schema.json" },
"duplicate": { "type": ["string", "null"], "format": "uuid", "description": "The existing task this URL matched, when downloads.duplicatePolicy resolved to 'skip'. taskId then names that existing task." }
}
}
}
}
@@ -0,0 +1,45 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/download.addBatch.schema.json",
"title": "download.addBatch",
"description": "Create many tasks in one call: the clipboard blob, the wildcard expander, and the extension's 'Download all links'. Partial success is normal and is reported per item rather than failing the whole batch.",
"x-privileged": false,
"x-transports": ["uds", "ws"],
"x-deadlineMs": 30000,
"x-errors": [-32011, -32012],
"x-wsRestrictions": ["Same saveDir restriction as download.add, applied to defaults and to every item."],
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": ["items"],
"properties": {
"items": { "type": "array", "minItems": 1, "maxItems": 5000, "items": { "$ref": "https://velox.dev/schema/types/DownloadSpec.schema.json" } },
"defaults": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/DownloadSpec.schema.json" }, { "type": "null" }], "description": "Applied to any field an item left unset. Its url is ignored." }
}
},
"result": {
"type": "object",
"additionalProperties": false,
"required": ["taskIds", "failed"],
"properties": {
"taskIds": { "type": "array", "items": { "type": "string", "format": "uuid" }, "description": "In the same order as the accepted items." },
"failed": {
"type": "array",
"description": "One entry per item that could not be added. index refers to params.items.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["index", "code", "message"],
"properties": {
"index": { "type": "integer", "minimum": 0 },
"code": { "$ref": "https://velox.dev/schema/types/ErrorCode.schema.json" },
"message": { "type": "string" }
}
}
}
}
}
}
}
@@ -0,0 +1,39 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/download.cancel.schema.json",
"title": "download.cancel",
"description": "Stop the given tasks and mark them cancelled. The .veloxpart file is kept so the user can still resume from the list; download.remove is what deletes bytes.",
"x-privileged": false,
"x-transports": [
"uds",
"ws"
],
"x-deadlineMs": 5000,
"x-errors": [
-32010
],
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": [
"taskIds"
],
"properties": {
"taskIds": {
"type": "array",
"minItems": 1,
"maxItems": 5000,
"items": {
"type": "string",
"format": "uuid"
}
}
}
},
"result": {
"$ref": "https://velox.dev/schema/types/BulkTaskResult.schema.json"
}
}
}
@@ -0,0 +1,20 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/download.get.schema.json",
"title": "download.get",
"description": "Full detail for one task, including per-segment state. Backs the progress dialog. Poll it no faster than the progress dialog repaints; the table must use events instead.",
"x-privileged": false,
"x-transports": ["uds", "ws"],
"x-deadlineMs": 5000,
"x-errors": [-32010],
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": ["taskId"],
"properties": { "taskId": { "type": "string", "format": "uuid" } }
},
"result": { "$ref": "https://velox.dev/schema/types/TaskDetail.schema.json" }
}
}
@@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/download.list.schema.json",
"title": "download.list",
"description": "The main table. Filtering, sorting and paging all happen in the daemon so the GUI never materializes 100k rows to show 40. Called once on connect; after that the table is maintained from events, never re-fetched on a progress tick.",
"x-privileged": false,
"x-transports": ["uds", "ws"],
"x-deadlineMs": 5000,
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"properties": {
"filter": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/TaskFilter.schema.json" }, { "type": "null" }] },
"sort": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/TaskSort.schema.json" }, { "type": "null" }] },
"offset": { "type": ["integer", "null"], "minimum": 0 },
"limit": { "type": ["integer", "null"], "minimum": 1, "maximum": 5000, "description": "Defaults to 500. The GUI pages; the extension popup asks for far fewer." }
}
},
"result": {
"type": "object",
"additionalProperties": false,
"required": ["total", "items"],
"properties": {
"total": { "type": "integer", "minimum": 0, "description": "Rows matching the filter, ignoring offset and limit." },
"items": { "type": "array", "items": { "$ref": "https://velox.dev/schema/types/TaskSummary.schema.json" } }
}
}
}
}
@@ -0,0 +1,39 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/download.pause.schema.json",
"title": "download.pause",
"description": "Suspend transfers and flush every segment's progress to the .veloxpart.meta file, so a pause is indistinguishable from a crash as far as resume is concerned. Never loses bytes already written.",
"x-privileged": false,
"x-transports": [
"uds",
"ws"
],
"x-deadlineMs": 5000,
"x-errors": [
-32010
],
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": [
"taskIds"
],
"properties": {
"taskIds": {
"type": "array",
"minItems": 1,
"maxItems": 5000,
"items": {
"type": "string",
"format": "uuid"
}
}
}
},
"result": {
"$ref": "https://velox.dev/schema/types/BulkTaskResult.schema.json"
}
}
}
@@ -0,0 +1,44 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/download.probe.schema.json",
"title": "download.probe",
"description": "Ask what is at a URL without creating a task. Populates the File Info dialog. Runs a HEAD, falling back to a ranged GET when HEAD is refused, which is also how resumability is established. Never blocks the RPC loop; the dialog opens immediately and fills in when this lands.",
"x-privileged": false,
"x-transports": ["uds", "ws"],
"x-deadlineMs": 30000,
"x-errors": [-32013],
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": ["url"],
"properties": {
"url": { "type": "string", "format": "uri" },
"headers": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/Headers.schema.json" }, { "type": "null" }] },
"cookies": { "type": ["array", "null"], "items": { "$ref": "https://velox.dev/schema/types/Cookie.schema.json" } },
"referrer": { "type": ["string", "null"] },
"userAgent": { "type": ["string", "null"] }
}
},
"result": {
"type": "object",
"additionalProperties": false,
"required": ["filename", "mime", "resumable", "effectiveUrl", "suggestedCategoryId"],
"properties": {
"filename": { "type": "string", "description": "From Content-Disposition when present, else the URL path, sanitized." },
"sizeBytes": { "type": ["integer", "null"], "minimum": 0 },
"mime": { "type": "string" },
"resumable": { "type": "boolean", "description": "Accept-Ranges: bytes and a validator (ETag or Last-Modified) are both present." },
"effectiveUrl": { "type": "string", "format": "uri" },
"suggestedCategoryId": { "type": "string", "description": "What the rules engine would pick. The dialog preselects it; the user may override." },
"suggestedSaveDir": { "type": ["string", "null"] },
"etag": { "type": ["string", "null"] },
"lastModified": { "type": ["string", "null"] },
"acceptRanges": { "type": "boolean" },
"redirectChain": { "type": "array", "items": { "type": "string", "format": "uri" }, "description": "Every hop, so the user can see where a shortener actually led." },
"requiresAuth": { "type": "boolean", "description": "The probe got a 401/407. The GUI should collect credentials before adding." }
}
}
}
}
@@ -0,0 +1,36 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/download.refreshUrl.schema.json",
"title": "download.refreshUrl",
"description": "IDM's 'Refresh Download Address'. Point an existing task at a freshly-issued URL when a signed link has expired, keeping every byte already on disk. The daemon re-probes and compares size and validator: if they still match, the transfer resumes from where it stopped; if they do not, it says so rather than silently restarting.",
"x-privileged": false,
"x-transports": ["uds", "ws"],
"x-deadlineMs": 30000,
"x-errors": [-32010, -32013],
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": ["taskId", "url"],
"properties": {
"taskId": { "type": "string", "format": "uuid" },
"url": { "type": "string", "format": "uri" },
"headers": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/Headers.schema.json" }, { "type": "null" }] },
"cookies": { "type": ["array", "null"], "items": { "$ref": "https://velox.dev/schema/types/Cookie.schema.json" } }
}
},
"result": {
"type": "object",
"additionalProperties": false,
"required": ["ok", "resumable", "contentChanged"],
"properties": {
"ok": { "type": "boolean" },
"resumable": { "type": "boolean" },
"contentChanged": { "type": "boolean", "description": "true when size or validator differ from what was recorded. The GUI must ask before restarting from zero — never discard bytes without consent." },
"sizeBytes": { "type": ["integer", "null"], "minimum": 0 },
"effectiveUrl": { "type": ["string", "null"], "format": "uri" }
}
}
}
}
@@ -0,0 +1,43 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/download.remove.schema.json",
"title": "download.remove",
"description": "Drop tasks from the list, optionally deleting the bytes on disk. Privileged: this is the only method that destroys user data, and the extension is never allowed to reach it. The daemon deletes the .veloxpart and .veloxpart.meta pair, and the finished file only when deleteFile is true.",
"x-privileged": true,
"x-transports": ["uds"],
"x-deadlineMs": 10000,
"x-errors": [-32003, -32010],
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": ["taskIds", "deleteFile"],
"properties": {
"taskIds": { "type": "array", "minItems": 1, "maxItems": 5000, "items": { "type": "string", "format": "uuid" } },
"deleteFile": { "type": "boolean", "description": "Explicit and required — there is no default for deleting a user's file." }
}
},
"result": {
"type": "object",
"additionalProperties": false,
"required": ["removed", "failed"],
"properties": {
"removed": { "type": "array", "items": { "type": "string", "format": "uuid" } },
"failed": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["taskId", "code", "message"],
"properties": {
"taskId": { "type": "string", "format": "uuid" },
"code": { "$ref": "https://velox.dev/schema/types/ErrorCode.schema.json" },
"message": { "type": "string" }
}
}
}
}
}
}
}
@@ -0,0 +1,39 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/download.resume.schema.json",
"title": "download.resume",
"description": "Continue paused tasks. Resumption is revalidated with If-Range against the stored ETag or Last-Modified; a 200 where 206 was expected means the file changed on the server, and the task moves to failed with a clear error rather than corrupting the part file.",
"x-privileged": false,
"x-transports": [
"uds",
"ws"
],
"x-deadlineMs": 5000,
"x-errors": [
-32010
],
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": [
"taskIds"
],
"properties": {
"taskIds": {
"type": "array",
"minItems": 1,
"maxItems": 5000,
"items": {
"type": "string",
"format": "uuid"
}
}
}
},
"result": {
"$ref": "https://velox.dev/schema/types/BulkTaskResult.schema.json"
}
}
}
@@ -0,0 +1,39 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/download.start.schema.json",
"title": "download.start",
"description": "Begin or restart the given tasks. A task in 'queued' jumps its queue; a task already downloading is a no-op reported as changed false.",
"x-privileged": false,
"x-transports": [
"uds",
"ws"
],
"x-deadlineMs": 5000,
"x-errors": [
-32010
],
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": [
"taskIds"
],
"properties": {
"taskIds": {
"type": "array",
"minItems": 1,
"maxItems": 5000,
"items": {
"type": "string",
"format": "uuid"
}
}
}
},
"result": {
"$ref": "https://velox.dev/schema/types/BulkTaskResult.schema.json"
}
}
}
@@ -0,0 +1,37 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/download.update.schema.json",
"title": "download.update",
"description": "Change a task's mutable fields. Moving saveDir or filename moves the file on disk in the same operation, which is what makes dragging a row onto a category work as one RPC. Privileged: it can name a destination path.",
"x-privileged": true,
"x-transports": ["uds"],
"x-deadlineMs": 30000,
"x-errors": [-32003, -32010, -32011],
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": ["taskId", "patch"],
"properties": {
"taskId": { "type": "string", "format": "uuid" },
"patch": {
"type": "object",
"additionalProperties": false,
"description": "Only the present fields change. An explicit null clears a nullable field.",
"properties": {
"filename": { "type": ["string", "null"], "maxLength": 255 },
"saveDir": { "type": ["string", "null"] },
"categoryId": { "type": ["string", "null"] },
"queueId": { "type": ["string", "null"] },
"description": { "type": ["string", "null"], "maxLength": 1024 },
"segments": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": "Takes effect on the next start; a running task is not re-segmented underneath the user." },
"bufferBytes": { "type": ["integer", "null"], "minimum": 4096, "maximum": 8388608 },
"checksum": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/Checksum.schema.json" }, { "type": "null" }] }
}
}
}
},
"result": { "$ref": "https://velox.dev/schema/types/TaskSummary.schema.json" }
}
}
@@ -0,0 +1,89 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/grabber.harvest.schema.json",
"title": "grabber.harvest",
"description": "Turn selected crawl results into tasks. This is the only grabber call that creates downloads, and it names exactly the files the user ticked \u2014 a crawl never starts a download on its own.",
"x-privileged": true,
"x-transports": [
"uds"
],
"x-deadlineMs": 30000,
"x-errors": [
-32003,
-32011
],
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": [
"jobId",
"select"
],
"properties": {
"jobId": {
"type": "string"
},
"select": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
},
"description": "fileIds from grabber.status."
},
"defaults": {
"oneOf": [
{
"$ref": "https://velox.dev/schema/types/DownloadSpec.schema.json"
},
{
"type": "null"
}
]
}
}
},
"result": {
"type": "object",
"additionalProperties": false,
"required": [
"taskIds",
"failed"
],
"properties": {
"taskIds": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
},
"failed": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"fileId",
"code",
"message"
],
"properties": {
"fileId": {
"type": "string"
},
"code": {
"$ref": "https://velox.dev/schema/types/ErrorCode.schema.json"
},
"message": {
"type": "string"
}
}
}
}
}
}
}
}
@@ -0,0 +1,106 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/grabber.start.schema.json",
"title": "grabber.start",
"description": "Start a depth-limited crawl. Nothing is downloaded by this call: it only walks pages and collects candidate links, which the wizard then shows for selection. Privileged because an unbounded crawl is a resource commitment the browser must not be able to make on the user's behalf.",
"x-privileged": true,
"x-transports": [
"uds"
],
"x-deadlineMs": 5000,
"x-errors": [
-32003
],
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": [
"startUrl",
"depth"
],
"properties": {
"startUrl": {
"type": "string",
"format": "uri"
},
"depth": {
"type": "integer",
"minimum": 0,
"maximum": 10
},
"includePatterns": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
}
},
"excludePatterns": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
}
},
"fileTypes": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
},
"description": "Extensions, without the dot. null means every type."
},
"sameHostOnly": {
"type": "boolean"
},
"maxFiles": {
"type": [
"integer",
"null"
],
"minimum": 1,
"maximum": 10000
},
"headers": {
"oneOf": [
{
"$ref": "https://velox.dev/schema/types/Headers.schema.json"
},
{
"type": "null"
}
]
},
"cookies": {
"type": [
"array",
"null"
],
"items": {
"$ref": "https://velox.dev/schema/types/Cookie.schema.json"
}
}
}
},
"result": {
"type": "object",
"additionalProperties": false,
"required": [
"jobId"
],
"properties": {
"jobId": {
"type": "string"
}
}
}
}
}
@@ -0,0 +1,75 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/grabber.status.schema.json",
"title": "grabber.status",
"description": "Poll one crawl. Also delivered as event.grabber.progress; the poll exists so the wizard can be reopened on a job it did not start and still catch up.",
"x-privileged": true,
"x-transports": [
"uds"
],
"x-deadlineMs": 5000,
"x-errors": [
-32003,
-32602
],
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": [
"jobId"
],
"properties": {
"jobId": {
"type": "string"
}
}
},
"result": {
"type": "object",
"additionalProperties": false,
"required": [
"jobId",
"state",
"crawled",
"found",
"files"
],
"properties": {
"jobId": {
"type": "string"
},
"state": {
"type": "string",
"enum": [
"crawling",
"done",
"failed",
"cancelled"
]
},
"crawled": {
"type": "integer",
"minimum": 0
},
"found": {
"type": "integer",
"minimum": 0
},
"files": {
"type": "array",
"items": {
"$ref": "https://velox.dev/schema/types/GrabberFile.schema.json"
}
},
"error": {
"type": [
"string",
"null"
]
}
}
}
}
}
@@ -0,0 +1,25 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/limiter.get.schema.json",
"title": "limiter.get",
"description": "Current global speed limit. Privileged: changing or reading the limiter belongs to the GUI and CLI; the extension shows throughput from event.speed.global instead.",
"x-privileged": true,
"x-transports": [
"uds"
],
"x-deadlineMs": 2000,
"x-errors": [
-32003
],
"type": "object",
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"properties": {}
},
"result": {
"$ref": "https://velox.dev/schema/types/Limiter.schema.json"
}
}
}
@@ -0,0 +1,24 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://velox.dev/schema/methods/limiter.set.schema.json",
"title": "limiter.set",
"description": "Set the global token-bucket limit. With applyToRunning true the change re-tunes transfers already in flight instead of taking effect only on the next task \u2014 the Speed Limiter window's 'apply now' button.",
"x-privileged": true,
"x-transports": [
"uds"
],
"x-deadlineMs": 5000,
"x-errors": [
-32003,
-32602
],
"type": "object",
"properties": {
"params": {
"$ref": "https://velox.dev/schema/types/Limiter.schema.json"
},
"result": {
"$ref": "https://velox.dev/schema/types/Limiter.schema.json"
}
}
}

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