2026-07-16 — v0.8.0: client/transport rework + handshake hardening, auth profiles, cert/MFA removal
The 0.7 and 0.8 work, read as one set of changes. 0.7.x was cut in a hurry to
unblock an external review and was never checked against the spec end to end —
treat those tags as pre-release. 0.8.0 is the first release to pass a full
conformance pass against spec/protocol.md; the hardening landed across several
independent review rounds (auth-surface design review, cross-language port audit,
two neutral security passes), every finding re-verified in shipped code and pinned
by a regression test.
Do not assume wire compatibility with pre-0.8.0 tags — only 0.8.0 is spec-conformant. Test suite grown to 298.
Client & transport semantics (0.7)
Behavioural rework of the client and channel layer — separate from the 0.8 security pass below.
- BREAKING — auto-retry removed. The client no longer silently re-sends a
call. A sent request that times out rejects with
RPCAbortedErrorand leaves the outcome UNKNOWN — the caller decides whether replaying is safe. A plain localRPCErrormeans the request provably never left. - BREAKING — deferred reset. The session resets only on a sent call that
gets
RPCAbortedError("TIMEOUT"); guardrail rejections (CHANNEL,ABORTED, queued-frame failures) no longer tear down and re-handshake the session. - Replay window. Server keeps a bounded seen-nonce set (configurable
replayWindow, default 4096) so a captured frame can’t be replayed within the window. abortPending. New client API to abort all in-flight calls at once (each rejects by its sent-status class), on top of per-callAbortSignal.- Make-before-break session continuity. A new handshake is negotiated as a candidate while the live session keeps serving; the old session is only replaced once the candidate is confirmed, so a re-handshake doesn’t drop in-flight work. Handshake states are resilient across transient channel gaps.
- Reconnecting channel adapters. WebSocket and socket adapters reconnect and the session survives transport loss — a call issued while the channel is down completes after recovery without a re-handshake.
- BREAKING — default
sendTimeout10s → 3s for faster failure detection of an unsent frame. - Port-complete spec + first KAT vectors.
spec/protocol.md,spec/security.mdassessment, and the initial known-answer test vectors landed here — the groundwork the 0.8.0 conformance pass later verified end to end.
Security
Handshake
- High — candidate phase now has its own absolute deadline. A candidate
session could be promoted after its confirmation window elapsed. Server stamps
candidateDeadlineon install and checks it beforepromoteCandidate(); companion fix drops a double-onErrorreport on one failed attempt (reportedflag). - Synchronous auth callbacks can no longer overrun
handshakeTimeout. Timer + boolean let a synchronousverify/secret/signwin the race (post-awaitcontinuation is a microtask, runs before the timer). Now an absoluteDate.now()deadline is checked after every auth callback and before candidate install, session publish, and reply send — both peers. - Candidate promotion split from payload decoding. An AEAD-authenticated frame
with malformed inner msgpack used to fail the whole decrypt — candidate not
promoted, timer not cleared, nonce not recorded — stalling a valid peer to
timeout. AEAD opening (
createAeadOpener) is now separate fromdecodePlaintext; candidate is promoted and nonce recorded the moment Poly1305 verifies, regardless of inner payload shape. maxPendingHandshakescaps concurrent in-flight handshakes so a peer with never-settling auth callbacks can’t accumulate unbounded pending work.- Failed handshake-reply send drops the candidate immediately (when
candidateEpochstill matches) instead of lingering to its timer; exactly one error reported. - Low — handshake epoch exhaustion is a terminal client error. No ceiling
guard on the client epoch counter; past
0xffffffffevery hello would be silently dropped server-side asInvalid epoch, surfacing only as opaque timeouts.startHandshakenow rejects withRPCError("CLIENT", "Handshake epoch exhausted; destroy and recreate client"). Unreachable in practice (~4.3 × 10⁹ handshakes); no wire/reuse impact.
Crypto / secrets
- Medium-High — all-zero secret rejected at any length. Guard only covered 32
bytes.
isEmptySecretnow rejects any-length all-zero buffers;deriveSessionSecretthrowsTypeError("secret must not be all-zero").
Replay / framing
- Reflected
t: 2frames no longer consume replay slots. A direction guard runs before the nonce is recorded, closing a replay-window-narrowing reflection. - Response-framing size limits tightened in the same pass.
Input validation
- Bigints beyond 64 bits rejected (
INVALID_DATA) instead of round-tripping a corrupted value;BIGINT_MIN/BIGINT_MAXexported. - Cyclic outbound graphs rejected (
WeakSetdetection) instead of recursing to a stack overflow; non-cyclic repeated references still rebuilt independently. NaN/Infinityno longer disable limits.maxPending,maxMessageBytes, JWTmaxAgevalidated as finite integers at construction.maxMessageBytes: NaNused to makelen > maxalways false — trivially reached viaNumber(process.env.X).
Middleware
- Middleware returning without calling
next()fails closed (RPCError ("MIDDLEWARE", ...)) — previously skipped the handler but returned success. Completion guarded on synchronous throw and bare return. - Fire-and-forget
next()is no longer a supported pattern — a middleware that completes while itsnext()promise is still pending fails closed withRPCError("MIDDLEWARE", ...)instead of replying with the middleware’s own value while the handler outcome is silently dropped (inspired by tRPC’s return-next guard — “did you forget toreturn next()?” — which uses a runtime envelope/marker rather than settlement tracking). A synchronous downstream throw is normalized into a rejectednext()promise so a catch-fallback middleware is not misclassified. The detached downstream promise is still observed internally, so its rejection can never surface as anunhandledRejectionand terminate the process.
Auth payloads
- Auth payloads pass the full
sanitize()gate and are version-checked; malformed or unversioned payloads rejected asUNAUTHORIZEDbefore reaching appverify.
Added
- Normative auth profiles: JWT, Ed25519, ECDSA (P-256). Each has a versioned
wire schema (
vfield, unknown version rejected server-side) and published KAT vectors inspec/protocol.md/test/unit/vectors.test.ts— cross-language ports interoperate byte-for-byte. maxPendingHandshakesserver option.BIGINT_MIN/BIGINT_MAXexports.- Port-complete spec + KAT vectors — 44-item implementation checklist and full vectors (keys, proof, both transcripts, three auth-profile payloads) cross-checked spec ↔ tests ↔ implementation.
Changed
- BREAKING — Node floor
>=20.19.0.package.jsondeclared a range the build and@noble/hashes2.x never supported; declaration now matches reality. Node < 20.19.0 unsupported, not CI-tested. - Spec/docs realigned with code: JWT credential visibility in the opening frame
called out, asymmetric examples authenticate both directions,
protocol.mdcontext-error contract matchesserver.ts, malformed-envelope / stale-and-unknown-response-id handling pinned, sent-boundary for async adapters clarified.
Removed
- BREAKING — certificate + multifactor auth helpers deleted
(
createCertificateServerAuth,createMultifactorServerAuth) with tests. The certificate helper was ECDSA verification under a name promising chain/expiry/revocation it never did (all in the app callback). The multifactor helper couldn’t enforce same-principal binding — default{ ...primary, ...secondary }merge passed two different principals as MFA, and spread order could let an issuer-signed JWT claim overwrite a cryptographically verified field. Use JWT/Ed25519/ECDSA profiles or a customverify;spec/security.mddocuments certificate and two-factor-with-binding recipes.
Fixed
- Nested
undefinedin outbound values no longer diverges from the declared type contract. - Reserved route/middleware names can no longer be registered.
- Malformed RPC responses (bad
ok, absent/garbagec/m) dropped or coerced strictly per spec.
Dependencies
puppeteermoved todevDependencies— was inoptionalDependencies(installs by default for consumers), draggingws(high/moderate) andjs-yaml(moderate) into productionnpm audit. Production audit now clean.wsbumped to^8.21.0.
Verification
- Full gate green: 39 files, 298 tests; lint, typecheck, ESM+CJS build clean (Node 22.22.1).
- Every finding re-verified in shipped code (not on commit-message trust) and mapped to a regression test; KAT vectors byte-identical across spec/tests/impl.
- Full audit trail:
review/0.8/review-release-verdict-0.8.0.md.