diff --git a/docs/PROJECT-MEMORY.md b/docs/PROJECT-MEMORY.md new file mode 100644 index 0000000..ab30b89 --- /dev/null +++ b/docs/PROJECT-MEMORY.md @@ -0,0 +1,32 @@ +# Project memory — pyrycode-relay + +Stateless WebSocket router between mobile clients and pyry binaries. Internet-exposed; adversarial input is the default assumption. Authoritative wire spec lives in `pyrycode/pyrycode/docs/protocol-mobile.md`. + +## What's built + +| Area | Status | Where | +|---|---|---| +| Go skeleton | Done | `cmd/pyrycode-relay/main.go`, `internal/relay/doc.go` | +| `http.Server` with explicit timeouts (gosec G114) | Done | `cmd/pyrycode-relay/main.go` | +| Routing envelope wrapper type (`Envelope`, `Marshal`, `Unmarshal`, sentinel errors) | Done (#1) | `internal/relay/envelope.go` | +| WS upgrade on `/v1/server` and `/v1/client` | Not started | — | +| Header validation (`x-pyrycode-server`, `x-pyrycode-token`) | Not started | — | +| Connection registry (server-id ↔ binary, server-id ↔ phones) | Not started | — | +| Frame forwarding using the routing envelope | Not started | — | +| `conn_id` generation scheme | Not started | — | +| TLS / autocert | Not started | — | +| Threat model doc | Not started | `docs/threat-model.md` planned | + +## Patterns established + +- **Sentinel errors, branched via `errors.Is`** for validation failures at protocol boundaries. New routing-layer code follows the `Err...` naming and wraps with `fmt.Errorf("…: %w", err, sentinel)` when adding context. +- **Opacity by type.** Inner-frame payloads are carried as `json.RawMessage`. The relay never deserialises payloads; the type makes that hard to violate accidentally. +- **Validate at the envelope boundary, not deeper.** Structural checks (presence, non-empty, JSON well-formedness) belong here; semantic checks (token validity, message kind) belong to the binary. +- **Stdlib only.** No external Go dependencies so far. `encoding/json` is sufficient for routing. +- **Tests live in the same package** (`package relay`, not `relay_test`) so they can `errors.Is` against unexported sentinels if needed and reach private helpers without re-exporting. + +## Conventions + +- Single ticket = single commit on `feature/` named `feat(relay): (#)`. +- `make vet`, `make test` (`-race`), and `make build` must all be clean before merge. +- Knowledge base lives under `docs/knowledge/` (features, decisions). One-line index in `docs/knowledge/INDEX.md`. diff --git a/docs/knowledge/INDEX.md b/docs/knowledge/INDEX.md new file mode 100644 index 0000000..75f4098 --- /dev/null +++ b/docs/knowledge/INDEX.md @@ -0,0 +1,20 @@ +# Knowledge Index + +One-line pointers into the evergreen knowledge base. Newest entries at the top of each section. + +## Features + +- [Routing envelope](features/routing-envelope.md) — typed Go wrapper (`Envelope`, `Marshal`, `Unmarshal`) for the `{conn_id, frame}` wire shape exchanged between relay and binary. + +## Decisions + +- [ADR-0001: Routing envelope shape and opacity](decisions/0001-routing-envelope-shape-and-opacity.md) — `json.RawMessage` for the inner frame; sentinel errors; validate at boundary, never parse payloads. + +## Architecture + +- [System overview](../architecture.md) — top-level: stateless WS router between phones and pyry binaries. (Lives at `docs/architecture.md`; not yet split into `architecture/`.) + +## Cross-cutting + +- [Project memory](../PROJECT-MEMORY.md) — what's built, patterns established, current state. +- [Lessons](../lessons.md) — gotchas worth carrying forward. diff --git a/docs/knowledge/decisions/0001-routing-envelope-shape-and-opacity.md b/docs/knowledge/decisions/0001-routing-envelope-shape-and-opacity.md new file mode 100644 index 0000000..866329d --- /dev/null +++ b/docs/knowledge/decisions/0001-routing-envelope-shape-and-opacity.md @@ -0,0 +1,52 @@ +# ADR-0001: Routing envelope shape and opacity + +**Status:** Accepted (#1) +**Date:** 2026-05-08 + +## Context + +The relay forwards WS frames between phones and a pyry binary, wrapping them in a `{conn_id, frame}` JSON envelope on the binary-side connection. Every future routing-layer ticket (header validation, registry, frame forwarding) needs a single Go vocabulary for that envelope. Two non-negotiable constraints from `docs/architecture.md`: + +1. The relay must never deserialise inner-frame payloads — frames are opaque bytes. +2. Validation happens at the envelope boundary; inner-frame semantics belong to the binary. + +## Decision + +A new package-internal type `relay.Envelope` with `Marshal`/`Unmarshal` helpers in `internal/relay/envelope.go`. The inner frame field is typed `json.RawMessage`. Errors are exported sentinel values (`ErrEmptyConnID`, `ErrInvalidFrameJSON`, `ErrMalformedEnvelope`, `ErrMissingConnID`, `ErrMissingFrame`); callers branch with `errors.Is`. + +`Marshal` validates non-empty `connID` and `json.Valid(frame)`. `Unmarshal` validates that the outer payload is a JSON object with non-empty `conn_id` and a `frame` that is neither absent nor JSON null. + +Stdlib only — no external dependency. + +## Rationale + +### `json.RawMessage` for `Frame` + +Alternatives considered: + +- `[]byte` — `encoding/json` would base64-encode it on Marshal; wrong wire shape. +- `interface{}` / `map[string]interface{}` — forces the relay to deserialise the inner frame on every Unmarshal, violating the opacity invariant and burning CPU re-marshalling on forward. +- `string` — readable but still pays a copy and hides that the bytes are JSON. + +`json.RawMessage` is purpose-built: it preserves the underlying bytes verbatim through both directions and is the natural way to express "valid JSON, but I don't care what's inside" in stdlib. + +### Sentinel errors over ad-hoc strings + +Tests assert with `errors.Is` rather than string-matching, and downstream code can distinguish "client sent bad envelope" (close with a protocol error) from "relay bug" (alert) without parsing strings. Wrapping with `fmt.Errorf("…: %w", …, sentinel)` retains the underlying decoder detail when useful. + +### Validate inner-frame syntax only on Marshal + +`json.Marshal` of an `Envelope` containing garbage `RawMessage` bytes succeeds and silently emits invalid JSON inside valid JSON, corrupting the wire. `json.Valid` is cheap and the only place we touch frame contents. On Unmarshal, the decoder has already syntax-checked the whole payload, so re-validating the inner frame would be redundant. + +### Reject empty / null `conn_id` and `frame` uniformly + +Three cases for `conn_id` (absent, empty string, null) are equivalent garbage at this layer; collapsing them into a single `ErrMissingConnID` keeps the API small. `frame` needs a special-case check for the 4-byte string `"null"` because `json.RawMessage` of an explicit JSON null doesn't appear absent by length alone. + +## Consequences + +- All future routing-layer code uses `relay.Envelope` and the helpers; inline JSON for routing is a code-review smell. +- The opacity invariant is type-enforced: a contributor who wants to read a phone's payload can't, without changing this type and the architecture doc together. +- Sentinel errors are a stable contract — adding new ones is fine, removing or repurposing one is a breaking change for downstream packages. +- Round-trip is byte-stable modulo whitespace; tests assert via `json.Compact` rather than raw `bytes.Equal`. If a future change ever needs strict byte-for-byte preservation of inner-frame whitespace, that requires moving away from `json.RawMessage` re-encoding (e.g. hand-assembling the outer JSON). +- No `conn_id` format validation here. The conn-id generation ticket owns that. +- No size limits here. The WS read loop will impose them. diff --git a/docs/knowledge/features/routing-envelope.md b/docs/knowledge/features/routing-envelope.md new file mode 100644 index 0000000..86bf5fa --- /dev/null +++ b/docs/knowledge/features/routing-envelope.md @@ -0,0 +1,71 @@ +# Routing envelope + +The relay forwards WebSocket frames between phones and a pyrycode binary. On the binary-side connection it wraps each frame in a small JSON envelope so the binary knows which phone connection a frame belongs to: + +```json +{ "conn_id": "c-7f3a...", "frame": { /* inner envelope, opaque to relay */ } } +``` + +This package provides the canonical Go vocabulary for that wrapper. All future routing-layer code (header validation, registry, frame forwarding) uses these helpers — no inline JSON. + +Authoritative wire spec: [`pyrycode/pyrycode/docs/protocol-mobile.md` § Routing envelope](https://github.com/pyrycode/pyrycode/blob/main/docs/protocol-mobile.md#routing-envelope). + +## API + +Package `internal/relay` (`envelope.go`): + +```go +type Envelope struct { + ConnID string `json:"conn_id"` + Frame json.RawMessage `json:"frame"` +} + +func Marshal(connID string, frame []byte) ([]byte, error) +func Unmarshal(data []byte) (Envelope, error) +``` + +Sentinel errors (branch with `errors.Is`): + +| Error | Returned by | When | +|---|---|---| +| `ErrEmptyConnID` | `Marshal` | `connID == ""` | +| `ErrInvalidFrameJSON` | `Marshal` | `frame` is nil/empty/not valid JSON | +| `ErrMalformedEnvelope` | `Unmarshal` | outer JSON parse fails or payload isn't a JSON object (wraps the decoder error) | +| `ErrMissingConnID` | `Unmarshal` | `conn_id` absent, empty string, or null | +| `ErrMissingFrame` | `Unmarshal` | `frame` absent or JSON null | + +## Opacity invariant + +`Frame` is the only field the relay ever forwards from the wire. The relay never deserialises it. The type enforces this via `json.RawMessage`, which preserves the underlying bytes verbatim through marshal/unmarshal (modulo insignificant whitespace canonicalised by `encoding/json`). + +Round-trip guarantee: `Marshal(connID, frame)` → `Unmarshal(...)` → `got.Frame` is byte-equal to `frame` after `json.Compact` on both sides. + +## Validation rules + +`Marshal` validates *envelope structure* and *inner-frame syntax*: + +- non-empty `connID` +- `json.Valid(frame)` — also rejects nil/empty since `json.Valid(nil)` and `json.Valid([]byte{})` are both false +- inner-frame *semantics* are not inspected + +`Unmarshal` validates *envelope structure* only: + +- outer payload parses as a JSON object +- `conn_id` present and non-empty (absent / `""` / `null` collapse to `ErrMissingConnID`) +- `frame` present and not null (`len(env.Frame) == 0` covers absent; explicit `bytes.Equal(env.Frame, []byte("null"))` covers `"frame": null`, which would otherwise pass length validation as the 4-byte string `"null"`) +- inner-frame syntax is not re-validated; `json.RawMessage` defers parsing forever + +What is deliberately *not* validated: + +- `conn_id` format (length, character set, prefix) — owned by the conn-id generation ticket; this layer accepts any non-empty string. +- inner-frame content — violating the opacity invariant. +- envelope or frame size — the WS read loop will enforce read limits in a later ticket. + +## Concurrency + +Both functions are pure and stateless. No goroutines, no shared state, no I/O. Safe to call concurrently. + +## Related + +- [ADR-0001: Routing envelope shape and opacity](../decisions/0001-routing-envelope-shape-and-opacity.md) — why `json.RawMessage` and sentinel errors. +- [Architecture overview](../../architecture.md) — where this fits in the relay's data flow. diff --git a/docs/lessons.md b/docs/lessons.md new file mode 100644 index 0000000..dd1ed09 --- /dev/null +++ b/docs/lessons.md @@ -0,0 +1,15 @@ +# Lessons + +Gotchas worth carrying forward. Each entry: what bit us (or nearly did), and what we do about it. + +## `json.Valid` rejects nil and empty bytes + +`encoding/json.Valid(nil)` and `json.Valid([]byte{})` both return `false`. Useful: a single `if !json.Valid(frame)` check covers nil, empty, and malformed bytes — no need for separate length guards. Source: routing-envelope `Marshal` (#1). + +## `json.RawMessage` of a JSON `null` is the 4-byte string `"null"` + +When unmarshalling `{"frame": null}` into a struct with `Frame json.RawMessage`, the resulting `Frame` is `[]byte("null")` — length 4, *not* zero — so a length-only check treats it as present. If absence-vs-null matters semantically, also compare against `[]byte("null")` explicitly. Source: routing-envelope `Unmarshal` (#1). + +## `json.RawMessage` round-trips are byte-stable *modulo whitespace* + +`encoding/json` re-encodes `RawMessage` through the outer marshaller and may strip insignificant whitespace. Tests asserting opacity should canonicalise both sides with `json.Compact` before `bytes.Equal`, otherwise they flake on formatting. Source: routing-envelope tests (#1). diff --git a/docs/specs/architecture/1-routing-envelope.md b/docs/specs/architecture/1-routing-envelope.md new file mode 100644 index 0000000..70f8869 --- /dev/null +++ b/docs/specs/architecture/1-routing-envelope.md @@ -0,0 +1,191 @@ +# Spec — Routing-envelope wrapper type (#1) + +## Files to read first + +- `internal/relay/doc.go` — current package doc; the new file lives next to this and stays consistent with the package's "routing core" framing. +- `cmd/pyrycode-relay/main.go:1-7` — links the protocol spec; the envelope's wire shape is authoritative there. +- `docs/architecture.md:11-12,21,27` — reaffirms two non-negotiables: (a) relay prepends/strips the routing envelope; (b) frames are opaque bytes — the relay never reads payloads. +- `Makefile` — `test` runs `go test -race ./...`; `vet` runs `go vet ./...`. The new code must be clean under both. +- `go.mod` — module path `github.com/pyrycode/pyrycode-relay`, Go 1.26.2; stdlib only. + +Wire shape (per ticket body, mirroring `pyrycode/pyrycode/docs/protocol-mobile.md § Routing envelope`): + +```json +{ "conn_id": "c-7f3a...", "frame": { /* inner envelope, opaque to relay */ } } +``` + +## Context + +The relay forwards WS frames between phones and a binary, wrapping them in a small `{conn_id, frame}` envelope so the binary knows which phone connection a frame belongs to. Today there is no Go vocabulary for this envelope; future routing-layer tickets (header validation, registry, frame forwarding) all need it. This ticket establishes the canonical type plus marshal/unmarshal helpers, with no networking and no consumers yet. + +The two invariants that drive the design: + +1. **Opacity** — the relay must never deserialise the inner frame. `frame` is bytes in, bytes out, byte-for-byte (modulo whitespace from `encoding/json`'s canonicalisation, which is acceptable per the ticket's "modulo whitespace" wording). +2. **Validation at the boundary** — empty/missing `conn_id` and missing `frame` are rejected. Inner-frame *syntax* is checked (must be valid JSON) but inner-frame *semantics* are not. + +## Design + +### Package & files + +- New file: `internal/relay/envelope.go` +- New test file: `internal/relay/envelope_test.go` +- Both `package relay` (test file uses the same package, not `relay_test`, so it can reference unexported sentinel errors directly via `errors.Is`). + +### Type + +```go +// Envelope is the routing wrapper exchanged between the relay and a pyrycode +// binary on the /v1/server connection. It carries an opaque inner frame +// addressed by the relay-assigned phone connection id. +// +// The relay treats Frame as opaque bytes and MUST NOT deserialise them. +// See pyrycode/pyrycode/docs/protocol-mobile.md § Routing envelope. +type Envelope struct { + ConnID string `json:"conn_id"` + Frame json.RawMessage `json:"frame"` +} +``` + +`json.RawMessage` is the right choice: its `MarshalJSON`/`UnmarshalJSON` preserve the underlying bytes verbatim, which is what the opacity invariant requires. It's also stdlib — no extra dependency. + +### Helpers + +```go +// Marshal builds the JSON-encoded routing envelope for an inner frame +// addressed to the given phone connection. +// +// connID must be non-empty. frame must be syntactically valid JSON; its +// contents are otherwise opaque to the relay. Returns the JSON-encoded +// envelope, or one of ErrEmptyConnID / ErrInvalidFrameJSON wrapped with +// context. +func Marshal(connID string, frame []byte) ([]byte, error) + +// Unmarshal parses a JSON-encoded routing envelope. It returns +// ErrMalformedEnvelope for syntactically invalid JSON, ErrMissingConnID +// when conn_id is absent or empty, and ErrMissingFrame when frame is +// absent or JSON null. +// +// The Frame field of the returned Envelope is the verbatim bytes of the +// inner frame (possibly with insignificant whitespace normalised by the +// JSON decoder). The relay MUST NOT inspect them. +func Unmarshal(data []byte) (Envelope, error) +``` + +### Sentinel errors + +Exported in the same file: + +```go +var ( + ErrEmptyConnID = errors.New("relay: empty conn_id") + ErrInvalidFrameJSON = errors.New("relay: frame is not valid JSON") + ErrMalformedEnvelope = errors.New("relay: malformed routing envelope") + ErrMissingConnID = errors.New("relay: routing envelope missing conn_id") + ErrMissingFrame = errors.New("relay: routing envelope missing frame") +) +``` + +Sentinel errors over ad-hoc `fmt.Errorf` strings: tests can assert with `errors.Is`, and downstream routing-layer tickets get a stable contract for distinguishing "client sent garbage" from "relay bug" without string matching. Wrap with `fmt.Errorf("...: %w", err, ErrXxx)` when the helper wants to add context (e.g. underlying `json.Unmarshal` error). + +### Marshal — algorithm + +1. If `connID == ""` → return `nil, ErrEmptyConnID`. +2. If `!json.Valid(frame)` → return `nil, ErrInvalidFrameJSON`. + - Note: `json.Valid(nil)` and `json.Valid([]byte{})` are both `false`, so this check also catches empty/missing inner frames at marshal time. Good — no extra branch needed. +3. Construct `Envelope{ConnID: connID, Frame: json.RawMessage(frame)}` and `return json.Marshal(env)`. The `RawMessage` marshaller emits the bytes verbatim (modulo whitespace canonicalisation by the outer encoder). + +### Unmarshal — algorithm + +1. `var env Envelope; err := json.Unmarshal(data, &env)`. +2. If `err != nil` → return `Envelope{}, fmt.Errorf("%w: %v", ErrMalformedEnvelope, err)`. +3. If `env.ConnID == ""` → return `Envelope{}, ErrMissingConnID`. + - This collapses three cases into one: field absent, field present as `""`, field present as `null` (`json.RawMessage` of a string field treats `null` as zero value). All three are equivalent garbage at this layer; no need to distinguish. +4. If `len(env.Frame) == 0 || bytes.Equal(env.Frame, []byte("null"))` → return `Envelope{}, ErrMissingFrame`. + - `len == 0` covers the absent case (`json.RawMessage` left as nil). + - The explicit `null`-literal check covers `"frame": null`. Without it, the `RawMessage` would be the 4-byte string `"null"` and pass length validation while semantically representing absence. Treat both as missing. +5. Return `env, nil`. + +### Why these checks and not more + +The ticket is explicit: validate envelope-level structure, treat the inner frame as opaque. So: + +- **Don't** validate `conn_id` format (length, character set, prefix). The relay assigns it; format is decided in a later ticket (out-of-scope per ticket body). +- **Don't** parse the inner frame on Unmarshal. `json.RawMessage` deliberately defers parsing; that defers it forever, which is the point. +- **Do** validate the inner frame on Marshal. We're emitting the envelope; if the caller hands us garbage bytes, `json.Marshal` would still succeed (it'd just emit invalid JSON inside valid JSON, which silently corrupts the wire). `json.Valid` is cheap and the only place we touch frame contents. + +### Concurrency model + +None. Both functions are pure and stateless: no goroutines, no shared mutable state, no I/O. Safe to call from any goroutine concurrently. + +### Error handling + +All error returns are typed sentinels (possibly wrapped). Callers use `errors.Is` to branch on `ErrMissingConnID` etc. when they need to distinguish "client sent bad envelope" (close with a protocol error) from "relay bug" (panic or alert). No panics; no `os.Exit`; no logging — this is library code. + +## Testing strategy + +Single test file, `internal/relay/envelope_test.go`, `package relay`. Use the standard `testing` package; no external libs. Subtest names map 1:1 to the AC bullets so the trace is obvious. + +### Round-trip with bytewise opacity + +```go +inner := []byte(`{"type":"hello","nested":{"a":[1,2,3],"b":null,"c":"xé"}}`) +out, err := relay.Marshal("c-abc123", inner) +// require: err == nil +got, err := relay.Unmarshal(out) +// require: err == nil +// require: got.ConnID == "c-abc123" +// require: bytes.Equal(canonicaliseJSON(got.Frame), canonicaliseJSON(inner)) +``` + +The "modulo whitespace" caveat from the AC is real: the outer `json.Marshal` re-encodes the `RawMessage` and may strip insignificant whitespace. To assert opacity robustly, canonicalise both sides with `json.Compact` before comparing — that proves the *semantic* bytes are identical without flaking on formatting. Include the unicode escape (`é`) and a `null` value to demonstrate the inner frame survives unchanged. + +### Marshal rejections + +Table-driven, asserting `errors.Is(err, ErrXxx)`: + +| Case | connID | frame | Expected error | +|---|---|---|---| +| empty conn id | `""` | `{"x":1}` | `ErrEmptyConnID` | +| nil frame | `"c-1"` | `nil` | `ErrInvalidFrameJSON` | +| empty frame | `"c-1"` | `[]byte("")` | `ErrInvalidFrameJSON` | +| garbage frame | `"c-1"` | `[]byte("not json")` | `ErrInvalidFrameJSON` | +| truncated frame | `"c-1"` | `[]byte("{\"a\":")` | `ErrInvalidFrameJSON` | + +### Unmarshal rejections + +| Case | input | Expected error | +|---|---|---| +| malformed JSON | `{"conn_id":"c-1"` (truncated) | `ErrMalformedEnvelope` | +| not an object | `[]` | `ErrMalformedEnvelope` (json/Unmarshal returns a type error) | +| missing conn_id | `{"frame":{}}` | `ErrMissingConnID` | +| empty conn_id | `{"conn_id":"","frame":{}}` | `ErrMissingConnID` | +| null conn_id | `{"conn_id":null,"frame":{}}` | `ErrMissingConnID` | +| missing frame | `{"conn_id":"c-1"}` | `ErrMissingFrame` | +| null frame | `{"conn_id":"c-1","frame":null}` | `ErrMissingFrame` | + +All rejection assertions go through `errors.Is`, not string compare. + +### What we deliberately do not test + +- Inner-frame *content* — the whole point is we never look. A test that introspects `Frame` past `bytes.Equal` would violate the invariant the type is enforcing. +- `conn_id` format / character set — out of scope; later ticket. +- Goroutine safety — pure functions; not interesting to exercise. + +## Open questions + +1. **Outer JSON whitespace.** `json.Marshal` produces compact output, so the round-trip's "modulo whitespace" caveat shouldn't bite in practice. Specified anyway because tests assert via `json.Compact` to be robust if Go's encoder ever changes. +2. **`conn_id` character validation.** Deferred to the connection-id generation ticket. This file's contract: any non-empty string is acceptable. +3. **Max envelope / frame size.** No size limit imposed here. The networking layer (later ticket) will enforce a read limit on the WS read loop; envelope-level helpers are size-agnostic. + +## Out of scope (re-stated, for the developer) + +- No edits to `cmd/pyrycode-relay/main.go`. `make build` must keep producing a working binary, but only because nothing changes in `main`. +- No new package; this lives in `internal/relay` next to `doc.go`. +- No connection registry, no WS upgrade, no header validation, no `conn_id` generation. + +## Done means + +- `internal/relay/envelope.go` exists with `Envelope`, `Marshal`, `Unmarshal`, and the five sentinel errors, each exported symbol carrying a doc comment that names the opacity invariant. +- `internal/relay/envelope_test.go` covers every row of both rejection tables plus the opacity round-trip. +- `make vet`, `make test`, `make build` all clean from the repo root. +- One commit on `feature/1`: `feat(relay): routing-envelope wrapper type (#1)`. diff --git a/internal/relay/envelope.go b/internal/relay/envelope.go new file mode 100644 index 0000000..ca4fc7d --- /dev/null +++ b/internal/relay/envelope.go @@ -0,0 +1,79 @@ +package relay + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" +) + +// Sentinel errors returned by Marshal and Unmarshal. Callers branch on these +// with errors.Is to distinguish "client sent garbage" from "relay bug" without +// matching error strings. +var ( + ErrEmptyConnID = errors.New("relay: empty conn_id") + ErrInvalidFrameJSON = errors.New("relay: frame is not valid JSON") + ErrMalformedEnvelope = errors.New("relay: malformed routing envelope") + ErrMissingConnID = errors.New("relay: routing envelope missing conn_id") + ErrMissingFrame = errors.New("relay: routing envelope missing frame") +) + +// Envelope is the routing wrapper exchanged between the relay and a pyrycode +// binary on the /v1/server connection. It carries an opaque inner frame +// addressed by the relay-assigned phone connection id. +// +// The relay treats Frame as opaque bytes and MUST NOT deserialise them. +// See pyrycode/pyrycode/docs/protocol-mobile.md § Routing envelope. +type Envelope struct { + ConnID string `json:"conn_id"` + Frame json.RawMessage `json:"frame"` +} + +// Marshal builds the JSON-encoded routing envelope for an inner frame +// addressed to the given phone connection. +// +// connID must be non-empty. frame must be syntactically valid JSON; its +// contents are otherwise opaque to the relay and are emitted byte-for-byte +// (modulo whitespace canonicalisation by encoding/json). Returns +// ErrEmptyConnID for an empty connection id and ErrInvalidFrameJSON for +// frame bytes that are not valid JSON. +func Marshal(connID string, frame []byte) ([]byte, error) { + if connID == "" { + return nil, ErrEmptyConnID + } + if !json.Valid(frame) { + return nil, ErrInvalidFrameJSON + } + env := Envelope{ConnID: connID, Frame: json.RawMessage(frame)} + out, err := json.Marshal(env) + if err != nil { + // Unreachable in practice: the only non-static field is a + // json.RawMessage we have already validated. Wrap defensively. + return nil, fmt.Errorf("relay: marshalling envelope: %w", err) + } + return out, nil +} + +// Unmarshal parses a JSON-encoded routing envelope. +// +// It returns ErrMalformedEnvelope (wrapping the underlying decoder error) +// for syntactically invalid JSON or a non-object payload, ErrMissingConnID +// when conn_id is absent, empty, or null, and ErrMissingFrame when frame +// is absent or null. +// +// The Frame field of the returned Envelope is the verbatim bytes of the +// inner frame (modulo insignificant whitespace normalised by the JSON +// decoder). The relay MUST NOT inspect them. +func Unmarshal(data []byte) (Envelope, error) { + var env Envelope + if err := json.Unmarshal(data, &env); err != nil { + return Envelope{}, fmt.Errorf("%w: %v", ErrMalformedEnvelope, err) + } + if env.ConnID == "" { + return Envelope{}, ErrMissingConnID + } + if len(env.Frame) == 0 || bytes.Equal(env.Frame, []byte("null")) { + return Envelope{}, ErrMissingFrame + } + return env, nil +} diff --git a/internal/relay/envelope_test.go b/internal/relay/envelope_test.go new file mode 100644 index 0000000..34f9d5f --- /dev/null +++ b/internal/relay/envelope_test.go @@ -0,0 +1,99 @@ +package relay + +import ( + "bytes" + "encoding/json" + "errors" + "testing" +) + +func TestMarshalUnmarshal_RoundTripBytewiseOpacity(t *testing.T) { + t.Parallel() + + inner := []byte(`{"type":"hello","nested":{"a":[1,2,3],"b":null,"c":"xé"}}`) + + out, err := Marshal("c-abc123", inner) + if err != nil { + t.Fatalf("Marshal: unexpected error: %v", err) + } + + got, err := Unmarshal(out) + if err != nil { + t.Fatalf("Unmarshal: unexpected error: %v", err) + } + if got.ConnID != "c-abc123" { + t.Errorf("ConnID: got %q, want %q", got.ConnID, "c-abc123") + } + + var wantBuf, gotBuf bytes.Buffer + if err := json.Compact(&wantBuf, inner); err != nil { + t.Fatalf("compact want: %v", err) + } + if err := json.Compact(&gotBuf, got.Frame); err != nil { + t.Fatalf("compact got: %v", err) + } + if !bytes.Equal(wantBuf.Bytes(), gotBuf.Bytes()) { + t.Errorf("frame bytes diverged after round-trip\nwant: %s\n got: %s", wantBuf.Bytes(), gotBuf.Bytes()) + } +} + +func TestMarshal_Rejections(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + connID string + frame []byte + want error + }{ + {"empty conn id", "", []byte(`{"x":1}`), ErrEmptyConnID}, + {"nil frame", "c-1", nil, ErrInvalidFrameJSON}, + {"empty frame", "c-1", []byte(""), ErrInvalidFrameJSON}, + {"garbage frame", "c-1", []byte("not json"), ErrInvalidFrameJSON}, + {"truncated frame", "c-1", []byte(`{"a":`), ErrInvalidFrameJSON}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + out, err := Marshal(tc.connID, tc.frame) + if !errors.Is(err, tc.want) { + t.Fatalf("Marshal: got err=%v, want errors.Is(_, %v)", err, tc.want) + } + if out != nil { + t.Errorf("Marshal: expected nil output on error, got %s", out) + } + }) + } +} + +func TestUnmarshal_Rejections(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in string + want error + }{ + {"malformed JSON", `{"conn_id":"c-1"`, ErrMalformedEnvelope}, + {"not an object", `[]`, ErrMalformedEnvelope}, + {"missing conn_id", `{"frame":{}}`, ErrMissingConnID}, + {"empty conn_id", `{"conn_id":"","frame":{}}`, ErrMissingConnID}, + {"null conn_id", `{"conn_id":null,"frame":{}}`, ErrMissingConnID}, + {"missing frame", `{"conn_id":"c-1"}`, ErrMissingFrame}, + {"null frame", `{"conn_id":"c-1","frame":null}`, ErrMissingFrame}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := Unmarshal([]byte(tc.in)) + if !errors.Is(err, tc.want) { + t.Fatalf("Unmarshal: got err=%v, want errors.Is(_, %v)", err, tc.want) + } + if got.ConnID != "" || got.Frame != nil { + t.Errorf("Unmarshal: expected zero Envelope on error, got %+v", got) + } + }) + } +}