feat(proto): publish nuncio.v1 contract + stability test + docs
Emit a FileDescriptorSet golden (proto/nuncio/v1/descriptor.bin) at build time and assert the freshly-compiled descriptor matches it byte-for-byte, so any breaking change to the nuncio.v1 wire contract fails the build until the golden is deliberately regenerated. Source info is stripped so pure doc-comment edits do not perturb the golden. Add proto/README.md documenting Swift/C#/TypeScript codegen from the proto, the loopback bearer-token auth model, the System.Subscribe push stream, and the v1 versioning policy (additive-only within v1; breaking changes go to nuncio.v2). Refs #174
This commit is contained in:
4 files changed
+216
-1
No files matched your search
@@ -10,6 +10,13 @@
|
||||
//! `cmake` to build one from source (unlike source-vendoring alternatives
|
||||
//! such as `protobuf-src`). This is required for reproducible builds in CI
|
||||
//! across all three platforms.
|
||||
//!
|
||||
//! Alongside code generation, this also emits a compiled `FileDescriptorSet`
|
||||
//! for the `nuncio.v1` package to `$OUT_DIR`. The crate's own tests compare
|
||||
//! that freshly generated descriptor against the one committed at
|
||||
//! `proto/nuncio/v1/descriptor.bin` -- the published wire contract -- so any
|
||||
//! accidental breaking change to the `.proto` sources fails the test suite
|
||||
//! instead of silently shipping.
|
||||
|
||||
fn main() {
|
||||
let protoc_path = match protoc_bin_vendored::protoc_bin_path() {
|
||||
@@ -28,10 +35,28 @@ fn main() {
|
||||
|
||||
println!("cargo:rerun-if-changed={proto_file}");
|
||||
|
||||
let out_dir = match std::env::var("OUT_DIR") {
|
||||
Ok(dir) => dir,
|
||||
Err(err) => {
|
||||
eprintln!("OUT_DIR was not set by cargo: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let descriptor_path = std::path::Path::new(&out_dir).join("nuncio_v1_descriptor.bin");
|
||||
|
||||
let mut config = tonic_prost_build::Config::new();
|
||||
// `SourceCodeInfo` records comment text and source byte offsets, which
|
||||
// are irrelevant to the wire contract. Excluding it keeps the emitted
|
||||
// descriptor -- and the golden file it is checked against -- focused
|
||||
// purely on the structural contract (fields, numbers, types, RPCs), so
|
||||
// editing a doc comment alone never perturbs the committed golden.
|
||||
config.skip_source_info();
|
||||
config.file_descriptor_set_path(&descriptor_path);
|
||||
|
||||
if let Err(err) = tonic_prost_build::configure()
|
||||
.build_client(true)
|
||||
.build_server(true)
|
||||
.compile_protos(&[proto_file], &["proto"])
|
||||
.compile_with_config(config, &[proto_file], &["proto"])
|
||||
{
|
||||
eprintln!("failed to compile nuncio.proto: {err}");
|
||||
std::process::exit(1);
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# `nuncio.v1` protobuf contract
|
||||
|
||||
This directory holds the versioned gRPC/protobuf contract for the `nunciod`
|
||||
daemon: `nuncio/v1/nuncio.proto`, defining the `System`, `Accounts`, `Mail`,
|
||||
`Filters`, `Export`, and `Audit` services.
|
||||
|
||||
The Rust crate `nuncio-proto` (one level up) compiles this `.proto` into the
|
||||
in-repo Rust client/server stubs used by `nunciod`, `nuncio-cli`, and the
|
||||
other in-repo presentation shells. This README is for anyone generating a
|
||||
client in a **different** language against the same contract -- e.g. a
|
||||
native Swift/macOS app, a C#/WinUI app, or a TypeScript/Node MCP bridge.
|
||||
|
||||
`descriptor.bin` alongside the `.proto` file is a compiled
|
||||
`FileDescriptorSet` for the whole `nuncio.v1` package, committed as the
|
||||
published, canonical snapshot of the wire contract. `nuncio-proto`'s own test
|
||||
suite fails if a `.proto` edit changes what that descriptor compiles to
|
||||
without the committed copy being deliberately updated to match -- see
|
||||
`descriptor_matches_committed_golden` in `nuncio-proto/src/lib.rs`. Anyone
|
||||
generating a client against this contract can diff their own compiled
|
||||
descriptor against `descriptor.bin` to check they are codegen-ing against
|
||||
the exact same wire shape this repo tested against.
|
||||
|
||||
## What you need before generating anything
|
||||
|
||||
- The daemon binds to a **loopback-only** address, `127.0.0.1:9420` by
|
||||
default (overridable via the `NUNCIO_GRPC_ADDR` environment variable, as
|
||||
`host:port`). It is plain HTTP/2 (`h2c`), never TLS-wrapped -- loopback
|
||||
traffic doesn't leave the host, so authentication is via bearer token
|
||||
instead of transport encryption.
|
||||
- Every RPC on every service requires an `authorization: Bearer <token>`
|
||||
gRPC metadata header. The token is 32 random bytes, hex-encoded, minted
|
||||
once and persisted in the OS-native credential vault (Keychain / Credential
|
||||
Manager / Secret Service) under the daemon's `grpc-bearer-token` vault
|
||||
entry -- it is never logged and never sent in cleartext anywhere but this
|
||||
header. A client generated from this contract in any language still needs
|
||||
a way to read that same vault entry (or otherwise obtain the token from a
|
||||
process that can) before it can call any RPC; there is no unauthenticated
|
||||
RPC anywhere in this contract, including `GetStatus`.
|
||||
- `System.Subscribe` is the one server-streaming RPC in the contract: it
|
||||
opens a long-lived stream of `Event` messages (sync progress, filter
|
||||
matches, database recovery, update availability, shutdown, etc.) that the
|
||||
daemon pushes for the lifetime of the call. Generated clients should treat
|
||||
it as a live push feed, not a request/response call -- keep reading from
|
||||
the stream until the daemon closes it or the connection drops, rather than
|
||||
expecting a single reply.
|
||||
|
||||
## Generating a client
|
||||
|
||||
All three toolchains below take the same two inputs: `nuncio.proto` in this
|
||||
directory, and a `protoc` binary (or an equivalent pure-language compiler).
|
||||
There are no `.proto` imports outside this file to worry about.
|
||||
|
||||
### Swift (grpc-swift)
|
||||
|
||||
```bash
|
||||
# Run from this directory (crates/nuncio-proto/proto). protoc-gen-grpc-swift
|
||||
# and protoc-gen-swift come from grpc-swift/grpc-swift-protobuf.
|
||||
protoc \
|
||||
--swift_out=./Generated \
|
||||
--grpc-swift_out=./Generated \
|
||||
--proto_path=. \
|
||||
nuncio/v1/nuncio.proto
|
||||
```
|
||||
|
||||
See the [grpc-swift](https://github.com/grpc/grpc-swift) plugin
|
||||
documentation for the exact flags matching the grpc-swift version and Swift
|
||||
Package Manager setup in use; the `Client` variant it emits pairs with
|
||||
`GRPCChannelPool`/`ClientTransport` for connecting to `127.0.0.1:9420` and
|
||||
attaching the bearer token as a call header.
|
||||
|
||||
### C# (Grpc.Tools)
|
||||
|
||||
Reference the `Grpc.Tools` and `Grpc.Net.Client` NuGet packages, then add
|
||||
this to the consuming `.csproj` (paths relative to wherever `nuncio.proto`
|
||||
was copied into the C# project, or referenced directly from this repo via a
|
||||
relative `Link`):
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<Protobuf Include="nuncio/v1/nuncio.proto" GrpcServices="Client" ProtoRoot="nuncio/v1" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
`dotnet build` then generates the client stubs automatically via the MSBuild
|
||||
integration `Grpc.Tools` provides; no manual `protoc` invocation is needed.
|
||||
Attach the bearer token per call via `Metadata` on the `CallOptions`, or via
|
||||
a `CallCredentials`/`CallInvoker` wrapper if every call should carry it
|
||||
uniformly.
|
||||
|
||||
### TypeScript (ts-proto or connect-es)
|
||||
|
||||
Either generator works from the same `.proto`; pick based on whether a
|
||||
`grpc-js`-style client (`ts-proto`) or a Connect-protocol client
|
||||
(`connect-es`, which also speaks plain gRPC) fits the consuming project
|
||||
better.
|
||||
|
||||
```bash
|
||||
# Run from this directory (crates/nuncio-proto/proto).
|
||||
|
||||
# ts-proto (emits classic grpc-js-style client + message types)
|
||||
protoc \
|
||||
--plugin=protoc-gen-ts_proto=./node_modules/.bin/protoc-gen-ts_proto \
|
||||
--ts_proto_out=./generated \
|
||||
--ts_proto_opt=outputServices=grpc-js \
|
||||
--proto_path=. \
|
||||
nuncio/v1/nuncio.proto
|
||||
|
||||
# connect-es (via buf generate; requires a buf.gen.yaml pinning
|
||||
# @bufbuild/protoc-gen-es and @connectrpc/protoc-gen-connect-es, with
|
||||
# this directory as buf's input root)
|
||||
buf generate --path nuncio/v1/nuncio.proto
|
||||
```
|
||||
|
||||
This is the natural fit for an `nuncio-mcp` TypeScript bridge: generate the
|
||||
client once, dial `127.0.0.1:9420`, attach the bearer token as an
|
||||
`authorization` header on the underlying transport, and subscribe to
|
||||
`System.Subscribe` for push events instead of polling.
|
||||
|
||||
## Versioning policy
|
||||
|
||||
The `nuncio.v1` package is stable. Concretely:
|
||||
|
||||
- **Allowed within `nuncio.v1`** (no version bump): adding a new RPC to an
|
||||
existing service; adding a new service; adding a new message; adding a new
|
||||
*optional* field to an existing message using a previously-unused field
|
||||
number; adding a new `enum` value; adding a new `oneof` case to `Event`.
|
||||
These are all wire-compatible -- old clients silently ignore fields/cases
|
||||
they don't recognize, and the daemon never *requires* a new field to
|
||||
service an old request.
|
||||
- **Never done within `nuncio.v1`**: removing or renaming a field; changing
|
||||
a field's number; changing a field's type; removing or renaming an RPC or
|
||||
service; changing an RPC's request/response message; repurposing a
|
||||
`oneof` field number for a different case. Any of these breaks old
|
||||
generated clients talking to a new daemon (or vice versa).
|
||||
- **If a breaking change is genuinely required**, it goes in a new
|
||||
`nuncio.v2` package (a new `.proto` file, new generated module, served
|
||||
alongside -- not instead of -- `nuncio.v1` for as long as both are
|
||||
supported), never by mutating `nuncio.v1` in place.
|
||||
- The committed `descriptor.bin` is the enforcement mechanism: any edit to
|
||||
`nuncio.proto` that changes the compiled wire shape fails
|
||||
`nuncio-proto`'s test suite until `descriptor.bin` is deliberately
|
||||
regenerated and committed alongside the `.proto` change, which is exactly
|
||||
the point where "is this actually additive?" gets asked and answered.
|
||||
Binary file not shown.
@@ -68,3 +68,50 @@ mod tests {
|
||||
let _request = GetStatusRequest {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Contract-stability check for the published `nuncio.v1` wire format.
|
||||
///
|
||||
/// `build.rs` emits a `FileDescriptorSet` for `proto/nuncio/v1/nuncio.proto`
|
||||
/// to `$OUT_DIR` on every build (with `SourceCodeInfo` stripped, so pure doc-
|
||||
/// comment edits don't perturb it). This module compares that freshly
|
||||
/// compiled descriptor, byte for byte, against the one committed at
|
||||
/// `proto/nuncio/v1/descriptor.bin` -- the crate's published contract, which
|
||||
/// future native client repos (Swift, C#, TypeScript) codegen from.
|
||||
///
|
||||
/// A mismatch means the `.proto` sources changed the wire contract: an added,
|
||||
/// removed, or renumbered field; a changed field type; a changed RPC
|
||||
/// signature; etc. See the assertion message below for how to intentionally
|
||||
/// update the golden once such a change is reviewed and accepted.
|
||||
#[cfg(test)]
|
||||
mod contract_stability {
|
||||
/// The published, committed `nuncio.v1` `FileDescriptorSet`.
|
||||
const GOLDEN_DESCRIPTOR: &[u8] = include_bytes!("../proto/nuncio/v1/descriptor.bin");
|
||||
|
||||
/// The `FileDescriptorSet` compiled from the current `.proto` sources by
|
||||
/// this very build, written by `build.rs` via
|
||||
/// `file_descriptor_set_path`.
|
||||
const FRESH_DESCRIPTOR: &[u8] =
|
||||
include_bytes!(concat!(env!("OUT_DIR"), "/nuncio_v1_descriptor.bin"));
|
||||
|
||||
#[test]
|
||||
fn descriptor_matches_committed_golden() {
|
||||
assert_eq!(
|
||||
FRESH_DESCRIPTOR,
|
||||
GOLDEN_DESCRIPTOR,
|
||||
"the `nuncio.v1` FileDescriptorSet compiled from \
|
||||
`proto/nuncio/v1/nuncio.proto` no longer matches the committed \
|
||||
golden at `crates/nuncio-proto/proto/nuncio/v1/descriptor.bin`. \
|
||||
This means the wire contract changed (an added/removed/\
|
||||
renumbered field, a changed field type, or a changed RPC \
|
||||
signature). If this change is intentional -- either an \
|
||||
additive, backward-compatible change within `nuncio.v1`, or a \
|
||||
deliberate breaking change that has ALSO been moved to a new \
|
||||
`nuncio.v2` package -- update the golden: rebuild with \
|
||||
`cargo build -p nuncio-proto`, then overwrite the committed \
|
||||
`descriptor.bin` with the freshly generated descriptor at:\n \
|
||||
{}\n\nThen re-run `cargo test -p nuncio-proto` and commit the \
|
||||
updated `descriptor.bin` alongside the `.proto` change.",
|
||||
concat!(env!("OUT_DIR"), "/nuncio_v1_descriptor.bin")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user