Conversation
#5) Implements the phone-side handshake from protocol-mobile.md § Phone → relay → binary. Validates x-pyrycode-server, x-pyrycode-token, user-agent before upgrade; on success registers the phone in the registry under the requested server-id. Closes 4404 if no binary holds the slot. Token is read for presence only — never parsed, compared, or logged. Mirrors the structural shape of /v1/server (#4): validate-pre-upgrade, defer-after-successful-claim, application close codes emitted on the underlying *websocket.Conn for the stillborn-WSConn 4404 path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Code Review: #5Decision: PASS Findings
SummarySpec-faithful implementation. The handler is a structural twin of Verified against the architect's
Tests cover all six AC bullets 1:1 (valid upgrade + ConnID prefix/8-hex suffix, header gate table over the three required headers × {missing, empty}, 4404 with literal reason, peer close → unregister + binary untouched, three-phone independent lifecycle, optional device-name accepted with and without). Test harness mirrors Verification:
The grace-window interaction with #20 (registry's Connection caps and per-header byte caps are documented residual DoS risks in |
Add evergreen feature doc for /v1/client. Update INDEX, PROJECT-MEMORY's "What's built" row, and sharpen patterns where #5 makes them concrete: validate-pre-upgrade, application-close-codes-on-underlying-conn, and the defer-after-successful-claim ordering. Add a new pattern for "courier credentials" — relay-handled secrets it does not validate, presence-checked then discarded, never logged or echoed (the X-Pyrycode-Token discipline). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
What
Implements
/v1/client, the phone-side WebSocket upgrade endpoint. ValidatesX-Pyrycode-Server,X-Pyrycode-Token, andUser-Agentheaders pre-upgrade; on success registers the phone in the registry under the requested server-id and holds the connection open until the peer (or the registry, on binary-grace expiry) closes it. Closes WS code4404("no server with that id") when no binary holds the slot.The relay treats
x-pyrycode-tokenas opaque — read once for the presence check, then out of scope. Never parsed, compared, or logged. Token verification is the binary's job.Issue
Closes #5.
Files
internal/relay/client_endpoint.go— exportsClientHandler(reg, logger). ReusesrandHex8andremoteHostfromserver_endpoint.goverbatim.internal/relay/client_endpoint_test.go— six tests, mirrorsserver_endpoint_test.goharness (startClient,dialWithClient,validClientHeaders,seedBinary).cmd/pyrycode-relay/main.go— one-line mux registration next to/v1/server.Testing
Six new tests covering each AC bullet:
TestClientEndpoint_ValidUpgrade_RegistersPhone— phone registers under the binary's server-id;ConnID()isclient-<id>-<8 hex chars>.TestClientEndpoint_HeaderGate_400— table-driven over the three required headers (missing or empty); each row asserts 400 + zero registry mutations.TestClientEndpoint_NoBinary_4404— no binary seeded → close 4404 with the literal reason"no server with that id".TestClientEndpoint_PeerClose_UnregistersPhone— peer close removes the phone from the registry; binary entry untouched.TestClientEndpoint_MultiplePhones_IndependentLifecycle— three phones register; closing the middle leaves the other two; closing in any order works.TestClientEndpoint_DeviceNameOptional_HandlerAccepts— handler accepts requests with and withoutX-Pyrycode-Device-Name.Verification:
go vet ./...— clean.go test -race ./...— all packages pass.go build ./cmd/pyrycode-relay— builds.gosec ./...— 0 issues.govulncheck ./...— same set of pre-existing stdlib/golang.org/x/netfindings asfeature/5HEAD before this PR (verified by stashing the change). Unrelated to this ticket; an env/runtime-upgrade concern surfaced bymake lint.Architecture compliance
ClientHandler(*Registry, *slog.Logger) http.Handler— matches the spec exactly. No new types, no new sentinel errors.websocket.Accept; missing/empty required header → empty-body 400; registry untouched.c.Close(websocket.StatusCode(4404), "no server with that id")directly on the underlying*websocket.Conn(stillborn WSConn pattern, same as/v1/server's 4409).WSConn.Closealways emitsStatusNormalClosure, so the application code path bypasses the wrapper.defer { UnregisterPhone; wsconn.Close; log }is registered AFTERRegisterPhonereturns nil — the "defer-after-successful-claim" rule from PROJECT-MEMORY.connID := "client-" + serverID + "-" + randHex8()— opaque routing key;crypto/randfor the suffix; never relied on for security.phone_registered→server_id, conn_id, device_name, remote;phone_register_no_server→server_id, remote;phone_unregistered→server_id, conn_id. Token, user-agent, and full-header dumps are never logged.<-readCtx.Done()placeholder viac.CloseRead(r.Context())— to be replaced by the frame loop in relay: frame forwarding loop — wrap/unwrap routing envelope, route by server-id and conn_id #6. Drains control frames so the conn observes peer-close.Security review compliance
The architect's
## Security reviewPASS verdict named two posture-level reminders, both honoured by this diff:grep -n token internal/relay/client_endpoint.go— the only reference is in the pre-upgrade gate.)RegisterPhonesucceeds; cleanup never schedules against a slot the handler does not own.Connection caps and per-header byte caps are documented residual DoS risks in
docs/threat-model.md§ "DoS resistance" — out of scope per the spec.