Files
samiandClaude Opus 5 53421d6cb8 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
2026-09-09 19:55:54 +04:00

620 lines
24 KiB
Python

#!/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())