Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/PROJECT-MEMORY.md
Original file line number Diff line number Diff line change
@@ -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/<n>` named `feat(relay): <summary> (#<n>)`.
- `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`.
20 changes: 20 additions & 0 deletions docs/knowledge/INDEX.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
71 changes: 71 additions & 0 deletions docs/knowledge/features/routing-envelope.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions docs/lessons.md
Original file line number Diff line number Diff line change
@@ -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).
Loading