From 569b49fd5a5d58b3c4a1c5c905e064ff00ec4ff5 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 29 Sep 2026 09:19:29 -0600 Subject: [PATCH 1/7] docs(http): document request.authorized and request.peerCertificate Both properties existed on the Request class (Node/Bun/uWS) but only `authorized` was mentioned, and only in prose. Add both to the Properties table with per-runtime behavior, and cross-link them from the "Trust and mTLS" paragraph, which already explained authorized but not by name in a linkable spot. Verified against harper's server/serverHelpers/Request.ts and proxyProtocol.ts: both getters are populated not just on a directly- terminated TLS connection but also on Harper Fabric's proxy-protocol UDS mirror once it decodes a verified client cert. peerCertificate returns an empty object (not null) when the connection is TLS-capable but no client cert was presented -- Node's own getPeerCertificate() semantics. Dispatch-Task: documentation-request-authorized-peercert Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HbtUN4YmHf5NBj25wZZnsV --- reference/http/api.md | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/reference/http/api.md b/reference/http/api.md index 4d064621..8d5133ff 100644 --- a/reference/http/api.md +++ b/reference/http/api.md @@ -78,18 +78,20 @@ A `Request` object is passed to HTTP middleware handlers and direct static REST ### Properties -| Property | Type | Description | -| ---------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `url` | string | The request target (path + query string), e.g. `/path?query=string` | -| `method` | string | HTTP method: `GET`, `POST`, `PUT`, `DELETE`, etc. | -| `headers` | [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) | Request headers | -| `pathname` | string | Path portion of the URL, without query string | -| `protocol` | string | `http` or `https`. On the Node runtime, behind a TLS-terminating proxy that forwards the PROXY v2 SSL TLV, this reflects the client's original scheme even though the proxy-to-Harper hop is plaintext; a proxy forwarding only address info (no SSL TLV) still reads as `http`. Bun and uWebSockets derive this from `X-Forwarded-Proto`/the listener's secure flag instead, independent of the SSL TLV. | -| `data` | any | Deserialized body, based on `Content-Type` header | -| `ip` | string | Remote IP address of the client (or last proxy) | -| `host` | string | Host from the request headers | -| `session` | object | Current cookie-based session (a `Table` record instance). Update with `request.session.update({ key: value })`. A cookie is set automatically the first time a session is updated or a login occurs. | -| `connectionInfo` | object \| undefined | TLS facts a fronting proxy forwarded over PROXY protocol v2 — negotiated ALPN, SNI, TLS version/cipher, the client's JA3/JA4 fingerprint, and the client's mTLS certificate chain (presented, not necessarily verified — see below). `undefined` for a direct connection or one without a v2 header. See [Connection info](#connection-info). | +| Property | Type | Description | +| ----------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `url` | string | The request target (path + query string), e.g. `/path?query=string` | +| `method` | string | HTTP method: `GET`, `POST`, `PUT`, `DELETE`, etc. | +| `headers` | [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) | Request headers | +| `pathname` | string | Path portion of the URL, without query string | +| `protocol` | string | `http` or `https`. On the Node runtime, behind a TLS-terminating proxy that forwards the PROXY v2 SSL TLV, this reflects the client's original scheme even though the proxy-to-Harper hop is plaintext; a proxy forwarding only address info (no SSL TLV) still reads as `http`. Bun and uWebSockets derive this from `X-Forwarded-Proto`/the listener's secure flag instead, independent of the SSL TLV. | +| `data` | any | Deserialized body, based on `Content-Type` header | +| `ip` | string | Remote IP address of the client (or last proxy) | +| `host` | string | Host from the request headers | +| `session` | object | Current cookie-based session (a `Table` record instance). Update with `request.session.update({ key: value })`. A cookie is set automatically the first time a session is updated or a login occurs. | +| `connectionInfo` | object \| undefined | TLS facts a fronting proxy forwarded over PROXY protocol v2 — negotiated ALPN, SNI, TLS version/cipher, the client's JA3/JA4 fingerprint, and the client's mTLS certificate chain (presented, not necessarily verified — see below). `undefined` for a direct connection or one without a v2 header. See [Connection info](#connection-info). | +| `authorized` | boolean \| undefined | Whether the client's certificate passed verification against the server's configured CA(s) — Node's `socket.authorized`, true for a directly-terminated TLS connection and for Harper Fabric's proxy-protocol UDS mirror once it decodes a verified client cert from the forwarded PROXY v2 header (see [Trust and mTLS](#connection-info)). `undefined` on a plain (non-TLS, non-mirrored) connection, and always `undefined` on Bun and uWebSockets. | +| `peerCertificate` | object \| null | The client's TLS peer certificate, including the `issuerCertificate` chain — Node's `socket.getPeerCertificate(true)`, cached after the first read. Populated the same way as `authorized` (directly, or via the Fabric UDS mirror). An empty object (`{}`) if no client certificate was presented; `null` on a connection with no TLS/mTLS support at this layer, and always `null` on Bun and uWebSockets. | ### Connection info @@ -124,7 +126,7 @@ class Origin { } ``` -**Trust and mTLS.** `connectionInfo` is populated only from the trusted proxy-protocol channel (Harper Fabric's per-worker Unix domain socket, writable only by the local proxy) — never from a request header, so the channel itself cannot be forged by a client (its _contents_ are client-controlled, per above). `clientCertChain` and `tls.verified` are independent signals, not one another's proof: a chain is attached whenever the client presented a certificate, whether or not the proxy verified it, and `tls.verified` can be `true` while `clientCertChain` is absent (a proxy may verify a certificate but omit an oversized chain). Treating chain presence alone as proof of a verified client accepts unverified certificates. For gating on a verified mTLS client, use `request.authorized` — a chain-gated boolean the proxy layer populates with the same semantics as a directly-terminated TLS connection — rather than `connectionInfo.tls.verified` or `clientCertChain` presence alone. Like `connectionInfo`, this is Node-runtime only: `request.authorized` is always `undefined` on Bun and uWebSockets. +**Trust and mTLS.** `connectionInfo` is populated only from the trusted proxy-protocol channel (Harper Fabric's per-worker Unix domain socket, writable only by the local proxy) — never from a request header, so the channel itself cannot be forged by a client (its _contents_ are client-controlled, per above). `clientCertChain` and `tls.verified` are independent signals, not one another's proof: a chain is attached whenever the client presented a certificate, whether or not the proxy verified it, and `tls.verified` can be `true` while `clientCertChain` is absent (a proxy may verify a certificate but omit an oversized chain). Treating chain presence alone as proof of a verified client accepts unverified certificates. For gating on a verified mTLS client, use [`request.authorized`](#properties) — a chain-gated boolean the proxy layer populates with the same semantics as a directly-terminated TLS connection — rather than `connectionInfo.tls.verified` or `clientCertChain` presence alone. To read the certificate itself rather than just its verification status, [`request.peerCertificate`](#properties) gives the same parsed object (with `issuerCertificate` chain) whether the connection is directly terminated or mirrored through the proxy. Like `connectionInfo`, both are Node-runtime only: `request.authorized` and `request.peerCertificate` are always `undefined`/`null` on Bun and uWebSockets. ### Methods From 68d8d2b3e38ca85fffd0334e2cf4a796bc4324a5 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 29 Sep 2026 09:36:18 -0600 Subject: [PATCH 2/7] docs(http): distinguish direct vs. Fabric-mirror CA and cert shape Pre-push review (codex + Harper domain) flagged that the initial wording implied a single "server's configured CA(s)" and an identical parsed certificate object across both paths. In fact the Fabric UDS mirror verifies against Symphony's own client_ca_pem (symphony src/tls.rs), not Harper's http.mtls config, and its synthesized peerCertificate (proxyProtocol.ts synthesizePeerCertificate) carries a reduced field set and returns {} in more cases than "no cert presented" (proxy-verified but oversized chain omitted, or an unparseable leaf). Also drop the "added in v5.2.0" implication -- both getters predate v5.2.0 (present in the local v5.0.0 core tag); what's new in v5.2.0 is the Fabric mirror populating them, so the badges are now type="changed". Dispatch-Task: documentation-request-authorized-peercert Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HbtUN4YmHf5NBj25wZZnsV --- reference/http/api.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/reference/http/api.md b/reference/http/api.md index 8d5133ff..b8bb4e45 100644 --- a/reference/http/api.md +++ b/reference/http/api.md @@ -78,20 +78,20 @@ A `Request` object is passed to HTTP middleware handlers and direct static REST ### Properties -| Property | Type | Description | -| ----------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `url` | string | The request target (path + query string), e.g. `/path?query=string` | -| `method` | string | HTTP method: `GET`, `POST`, `PUT`, `DELETE`, etc. | -| `headers` | [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) | Request headers | -| `pathname` | string | Path portion of the URL, without query string | -| `protocol` | string | `http` or `https`. On the Node runtime, behind a TLS-terminating proxy that forwards the PROXY v2 SSL TLV, this reflects the client's original scheme even though the proxy-to-Harper hop is plaintext; a proxy forwarding only address info (no SSL TLV) still reads as `http`. Bun and uWebSockets derive this from `X-Forwarded-Proto`/the listener's secure flag instead, independent of the SSL TLV. | -| `data` | any | Deserialized body, based on `Content-Type` header | -| `ip` | string | Remote IP address of the client (or last proxy) | -| `host` | string | Host from the request headers | -| `session` | object | Current cookie-based session (a `Table` record instance). Update with `request.session.update({ key: value })`. A cookie is set automatically the first time a session is updated or a login occurs. | -| `connectionInfo` | object \| undefined | TLS facts a fronting proxy forwarded over PROXY protocol v2 — negotiated ALPN, SNI, TLS version/cipher, the client's JA3/JA4 fingerprint, and the client's mTLS certificate chain (presented, not necessarily verified — see below). `undefined` for a direct connection or one without a v2 header. See [Connection info](#connection-info). | -| `authorized` | boolean \| undefined | Whether the client's certificate passed verification against the server's configured CA(s) — Node's `socket.authorized`, true for a directly-terminated TLS connection and for Harper Fabric's proxy-protocol UDS mirror once it decodes a verified client cert from the forwarded PROXY v2 header (see [Trust and mTLS](#connection-info)). `undefined` on a plain (non-TLS, non-mirrored) connection, and always `undefined` on Bun and uWebSockets. | -| `peerCertificate` | object \| null | The client's TLS peer certificate, including the `issuerCertificate` chain — Node's `socket.getPeerCertificate(true)`, cached after the first read. Populated the same way as `authorized` (directly, or via the Fabric UDS mirror). An empty object (`{}`) if no client certificate was presented; `null` on a connection with no TLS/mTLS support at this layer, and always `null` on Bun and uWebSockets. | +| Property | Type | Description | +| ----------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `url` | string | The request target (path + query string), e.g. `/path?query=string` | +| `method` | string | HTTP method: `GET`, `POST`, `PUT`, `DELETE`, etc. | +| `headers` | [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) | Request headers | +| `pathname` | string | Path portion of the URL, without query string | +| `protocol` | string | `http` or `https`. On the Node runtime, behind a TLS-terminating proxy that forwards the PROXY v2 SSL TLV, this reflects the client's original scheme even though the proxy-to-Harper hop is plaintext; a proxy forwarding only address info (no SSL TLV) still reads as `http`. Bun and uWebSockets derive this from `X-Forwarded-Proto`/the listener's secure flag instead, independent of the SSL TLV. | +| `data` | any | Deserialized body, based on `Content-Type` header | +| `ip` | string | Remote IP address of the client (or last proxy) | +| `host` | string | Host from the request headers | +| `session` | object | Current cookie-based session (a `Table` record instance). Update with `request.session.update({ key: value })`. A cookie is set automatically the first time a session is updated or a login occurs. | +| `connectionInfo` | object \| undefined | TLS facts a fronting proxy forwarded over PROXY protocol v2 — negotiated ALPN, SNI, TLS version/cipher, the client's JA3/JA4 fingerprint, and the client's mTLS certificate chain (presented, not necessarily verified — see below). `undefined` for a direct connection or one without a v2 header. See [Connection info](#connection-info). | +| `authorized` | boolean \| undefined | Whether the client's certificate passed TLS verification — Node's `socket.authorized`. On a directly-terminated connection this is verification against Harper's own configured mTLS CA(s); behind Harper Fabric's proxy-protocol UDS mirror it instead reflects Symphony's own client-certificate verification, forwarded as a single trust bit — a different CA than Harper's `http.mtls` config (see [Trust and mTLS](#connection-info)). `false` for an unverified or absent client certificate on either path, including a mirrored connection where the proxy verified the client but omitted its oversized chain. `undefined` on a plain (non-TLS, non-mirrored) connection, and always `undefined` on Bun and uWebSockets. | +| `peerCertificate` | object \| null | The client's TLS peer certificate — Node's `socket.getPeerCertificate(true)`, cached after the first read. On a direct connection this is Node's full certificate object, including `issuerCertificate`; behind the Fabric UDS mirror it's a smaller object parsed from the forwarded chain (subject, issuer, subjectaltname, validity, serial number, fingerprints, `issuerCertificate` — no `pubkey`, `bits`, `modulus`, `ca`, or `ext_key_usage`). An empty object (`{}`) if no certificate was presented, if a verified chain was forwarded but omitted, or if the forwarded leaf fails to parse; `null` on a connection with no TLS/mTLS support at this layer, and always `null` on Bun and uWebSockets. | ### Connection info @@ -126,7 +126,7 @@ class Origin { } ``` -**Trust and mTLS.** `connectionInfo` is populated only from the trusted proxy-protocol channel (Harper Fabric's per-worker Unix domain socket, writable only by the local proxy) — never from a request header, so the channel itself cannot be forged by a client (its _contents_ are client-controlled, per above). `clientCertChain` and `tls.verified` are independent signals, not one another's proof: a chain is attached whenever the client presented a certificate, whether or not the proxy verified it, and `tls.verified` can be `true` while `clientCertChain` is absent (a proxy may verify a certificate but omit an oversized chain). Treating chain presence alone as proof of a verified client accepts unverified certificates. For gating on a verified mTLS client, use [`request.authorized`](#properties) — a chain-gated boolean the proxy layer populates with the same semantics as a directly-terminated TLS connection — rather than `connectionInfo.tls.verified` or `clientCertChain` presence alone. To read the certificate itself rather than just its verification status, [`request.peerCertificate`](#properties) gives the same parsed object (with `issuerCertificate` chain) whether the connection is directly terminated or mirrored through the proxy. Like `connectionInfo`, both are Node-runtime only: `request.authorized` and `request.peerCertificate` are always `undefined`/`null` on Bun and uWebSockets. +**Trust and mTLS.** `connectionInfo` is populated only from the trusted proxy-protocol channel (Harper Fabric's per-worker Unix domain socket, writable only by the local proxy) — never from a request header, so the channel itself cannot be forged by a client (its _contents_ are client-controlled, per above). `clientCertChain` and `tls.verified` are independent signals, not one another's proof: a chain is attached whenever the client presented a certificate, whether or not the proxy verified it, and `tls.verified` can be `true` while `clientCertChain` is absent (a proxy may verify a certificate but omit an oversized chain). Treating chain presence alone as proof of a verified client accepts unverified certificates. For gating on a verified mTLS client, use [`request.authorized`](#properties) rather than `connectionInfo.tls.verified` or `clientCertChain` presence alone — it's `false` (not just absent) for an unverified or chain-omitted mTLS attempt, on either path. Note that the proxy path verifies against Symphony's own client CA, not Harper's `http.mtls` config, so a directly-terminated `authorized: true` and a mirrored `authorized: true` aren't backed by the same trust root even though both gate the same way. To read the certificate itself, [`request.peerCertificate`](#properties) is populated the same way, but the mirror's version carries fewer fields than a direct connection's (see the table). Like `connectionInfo`, both are Node-runtime only: `request.authorized` and `request.peerCertificate` are always `undefined`/`null` on Bun and uWebSockets. ### Methods From 05f609e95e9ef347e1e071987fb61cc322370132 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 29 Sep 2026 09:41:46 -0600 Subject: [PATCH 3/7] docs(http): name the exact Harper CA setting, not http.mtls Round-2 review (codex + gemini + domain) caught that http.mtls has no CA field -- Harper's client CA is tls.certificateAuthority (reference/http/configuration.md:200). Also softened "different CA" to "not guaranteed to share a trust root": Symphony's CA and Harper's are independently configured, not proven to always differ. Fixed a self-contradicting "forwarded but omitted" phrase, added the omitted `raw` field to the mirror's certificate shape (it's the escape hatch for pubkey/bits/etc via re-parsing), scoped chain-omission language to the mirror path only, and noted the rare authorized:true + peerCertificate:{} combination when the forwarded leaf fails to parse. Dispatch-Task: documentation-request-authorized-peercert Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HbtUN4YmHf5NBj25wZZnsV --- reference/http/api.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/reference/http/api.md b/reference/http/api.md index b8bb4e45..c20f0a4c 100644 --- a/reference/http/api.md +++ b/reference/http/api.md @@ -78,20 +78,20 @@ A `Request` object is passed to HTTP middleware handlers and direct static REST ### Properties -| Property | Type | Description | -| ----------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `url` | string | The request target (path + query string), e.g. `/path?query=string` | -| `method` | string | HTTP method: `GET`, `POST`, `PUT`, `DELETE`, etc. | -| `headers` | [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) | Request headers | -| `pathname` | string | Path portion of the URL, without query string | -| `protocol` | string | `http` or `https`. On the Node runtime, behind a TLS-terminating proxy that forwards the PROXY v2 SSL TLV, this reflects the client's original scheme even though the proxy-to-Harper hop is plaintext; a proxy forwarding only address info (no SSL TLV) still reads as `http`. Bun and uWebSockets derive this from `X-Forwarded-Proto`/the listener's secure flag instead, independent of the SSL TLV. | -| `data` | any | Deserialized body, based on `Content-Type` header | -| `ip` | string | Remote IP address of the client (or last proxy) | -| `host` | string | Host from the request headers | -| `session` | object | Current cookie-based session (a `Table` record instance). Update with `request.session.update({ key: value })`. A cookie is set automatically the first time a session is updated or a login occurs. | -| `connectionInfo` | object \| undefined | TLS facts a fronting proxy forwarded over PROXY protocol v2 — negotiated ALPN, SNI, TLS version/cipher, the client's JA3/JA4 fingerprint, and the client's mTLS certificate chain (presented, not necessarily verified — see below). `undefined` for a direct connection or one without a v2 header. See [Connection info](#connection-info). | -| `authorized` | boolean \| undefined | Whether the client's certificate passed TLS verification — Node's `socket.authorized`. On a directly-terminated connection this is verification against Harper's own configured mTLS CA(s); behind Harper Fabric's proxy-protocol UDS mirror it instead reflects Symphony's own client-certificate verification, forwarded as a single trust bit — a different CA than Harper's `http.mtls` config (see [Trust and mTLS](#connection-info)). `false` for an unverified or absent client certificate on either path, including a mirrored connection where the proxy verified the client but omitted its oversized chain. `undefined` on a plain (non-TLS, non-mirrored) connection, and always `undefined` on Bun and uWebSockets. | -| `peerCertificate` | object \| null | The client's TLS peer certificate — Node's `socket.getPeerCertificate(true)`, cached after the first read. On a direct connection this is Node's full certificate object, including `issuerCertificate`; behind the Fabric UDS mirror it's a smaller object parsed from the forwarded chain (subject, issuer, subjectaltname, validity, serial number, fingerprints, `issuerCertificate` — no `pubkey`, `bits`, `modulus`, `ca`, or `ext_key_usage`). An empty object (`{}`) if no certificate was presented, if a verified chain was forwarded but omitted, or if the forwarded leaf fails to parse; `null` on a connection with no TLS/mTLS support at this layer, and always `null` on Bun and uWebSockets. | +| Property | Type | Description | +| ----------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `url` | string | The request target (path + query string), e.g. `/path?query=string` | +| `method` | string | HTTP method: `GET`, `POST`, `PUT`, `DELETE`, etc. | +| `headers` | [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) | Request headers | +| `pathname` | string | Path portion of the URL, without query string | +| `protocol` | string | `http` or `https`. On the Node runtime, behind a TLS-terminating proxy that forwards the PROXY v2 SSL TLV, this reflects the client's original scheme even though the proxy-to-Harper hop is plaintext; a proxy forwarding only address info (no SSL TLV) still reads as `http`. Bun and uWebSockets derive this from `X-Forwarded-Proto`/the listener's secure flag instead, independent of the SSL TLV. | +| `data` | any | Deserialized body, based on `Content-Type` header | +| `ip` | string | Remote IP address of the client (or last proxy) | +| `host` | string | Host from the request headers | +| `session` | object | Current cookie-based session (a `Table` record instance). Update with `request.session.update({ key: value })`. A cookie is set automatically the first time a session is updated or a login occurs. | +| `connectionInfo` | object \| undefined | TLS facts a fronting proxy forwarded over PROXY protocol v2 — negotiated ALPN, SNI, TLS version/cipher, the client's JA3/JA4 fingerprint, and the client's mTLS certificate chain (presented, not necessarily verified — see below). `undefined` for a direct connection or one without a v2 header. See [Connection info](#connection-info). | +| `authorized` | boolean \| undefined | Whether the client's certificate passed TLS verification — Node's `socket.authorized`. On a directly-terminated connection this is verification against Harper's own `tls.certificateAuthority`; behind Harper Fabric's proxy-protocol UDS mirror it instead reflects Symphony's own, separately configured client CA, forwarded as a single trust bit (see [Trust and mTLS](#connection-info)). `false` for an unverified or absent client certificate on either path, including a mirrored connection where the proxy verified the client but omitted its oversized chain. `undefined` on a plain (non-TLS, non-mirrored) connection, and always `undefined` on Bun and uWebSockets. | +| `peerCertificate` | object \| null | The client's TLS peer certificate — Node's `socket.getPeerCertificate(true)`, cached after the first read. On a direct connection this is Node's full certificate object, including `issuerCertificate`; behind the Fabric UDS mirror it's a smaller object parsed from the forwarded chain (subject, issuer, subjectaltname, validity, serial number, fingerprints, `raw`, `issuerCertificate` — no `pubkey`, `bits`, `modulus`, `ca`, or `ext_key_usage`, though `raw` can be re-parsed for those). An empty object (`{}`) if no certificate was presented, if the proxy verified the certificate but omitted the chain, or if the forwarded leaf fails to parse (rare, but possible even when `authorized` is `true`); `null` on a connection with no TLS/mTLS support at this layer, and always `null` on Bun and uWebSockets. | ### Connection info @@ -126,7 +126,7 @@ class Origin { } ``` -**Trust and mTLS.** `connectionInfo` is populated only from the trusted proxy-protocol channel (Harper Fabric's per-worker Unix domain socket, writable only by the local proxy) — never from a request header, so the channel itself cannot be forged by a client (its _contents_ are client-controlled, per above). `clientCertChain` and `tls.verified` are independent signals, not one another's proof: a chain is attached whenever the client presented a certificate, whether or not the proxy verified it, and `tls.verified` can be `true` while `clientCertChain` is absent (a proxy may verify a certificate but omit an oversized chain). Treating chain presence alone as proof of a verified client accepts unverified certificates. For gating on a verified mTLS client, use [`request.authorized`](#properties) rather than `connectionInfo.tls.verified` or `clientCertChain` presence alone — it's `false` (not just absent) for an unverified or chain-omitted mTLS attempt, on either path. Note that the proxy path verifies against Symphony's own client CA, not Harper's `http.mtls` config, so a directly-terminated `authorized: true` and a mirrored `authorized: true` aren't backed by the same trust root even though both gate the same way. To read the certificate itself, [`request.peerCertificate`](#properties) is populated the same way, but the mirror's version carries fewer fields than a direct connection's (see the table). Like `connectionInfo`, both are Node-runtime only: `request.authorized` and `request.peerCertificate` are always `undefined`/`null` on Bun and uWebSockets. +**Trust and mTLS.** `connectionInfo` is populated only from the trusted proxy-protocol channel (Harper Fabric's per-worker Unix domain socket, writable only by the local proxy) — never from a request header, so the channel itself cannot be forged by a client (its _contents_ are client-controlled, per above). `clientCertChain` and `tls.verified` are independent signals, not one another's proof: a chain is attached whenever the client presented a certificate, whether or not the proxy verified it, and `tls.verified` can be `true` while `clientCertChain` is absent (a proxy may verify a certificate but omit an oversized chain). Treating chain presence alone as proof of a verified client accepts unverified certificates. For gating on a verified mTLS client, use [`request.authorized`](#properties) rather than `connectionInfo.tls.verified` or `clientCertChain` presence alone — it's `false` (not just absent) for an unverified or absent client certificate on either path, including a mirrored chain the proxy verified but omitted. Note that the proxy path verifies against Symphony's own, separately configured client CA, not Harper's `tls.certificateAuthority`, so a directly-terminated `authorized: true` and a mirrored `authorized: true` aren't guaranteed to share a trust root even though both gate the same way. To read the certificate itself, [`request.peerCertificate`](#properties) is populated the same way, but the mirror's version carries fewer fields than a direct connection's (see the table). Like `connectionInfo`, both are Node-runtime only: `request.authorized` and `request.peerCertificate` are always `undefined`/`null` on Bun and uWebSockets. ### Methods From b6d424c4e83e71c70f35354cba2bb2008edaae5e Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 29 Sep 2026 09:49:25 -0600 Subject: [PATCH 4/7] docs(http): name Harper's full CA trust set and the no-issuerCertificate cases Round-3 review (codex + gemini + domain) caught two more accuracy gaps, both already documented elsewhere in this repo: - Direct-TLS `authorized`/`peerCertificate` verification isn't limited to `tls.certificateAuthority` -- Harper also trusts any certificate record marked as an authority, including its own auto-generated install-time CA (harper core security/keys.ts). Reused this repo's existing wording from reference/security/certificate-verification.md:229 and linked it. - `peerCertificate.issuerCertificate` isn't always present on a direct connection either: Harper omits the chain on every resumed TLS session and on Node.js 26.8.0/26.8.1 (documented at reference/security/certificate-verification.md:227). Dispatch-Task: documentation-request-authorized-peercert Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HbtUN4YmHf5NBj25wZZnsV --- reference/http/api.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/reference/http/api.md b/reference/http/api.md index c20f0a4c..c4bf3baa 100644 --- a/reference/http/api.md +++ b/reference/http/api.md @@ -78,20 +78,20 @@ A `Request` object is passed to HTTP middleware handlers and direct static REST ### Properties -| Property | Type | Description | -| ----------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `url` | string | The request target (path + query string), e.g. `/path?query=string` | -| `method` | string | HTTP method: `GET`, `POST`, `PUT`, `DELETE`, etc. | -| `headers` | [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) | Request headers | -| `pathname` | string | Path portion of the URL, without query string | -| `protocol` | string | `http` or `https`. On the Node runtime, behind a TLS-terminating proxy that forwards the PROXY v2 SSL TLV, this reflects the client's original scheme even though the proxy-to-Harper hop is plaintext; a proxy forwarding only address info (no SSL TLV) still reads as `http`. Bun and uWebSockets derive this from `X-Forwarded-Proto`/the listener's secure flag instead, independent of the SSL TLV. | -| `data` | any | Deserialized body, based on `Content-Type` header | -| `ip` | string | Remote IP address of the client (or last proxy) | -| `host` | string | Host from the request headers | -| `session` | object | Current cookie-based session (a `Table` record instance). Update with `request.session.update({ key: value })`. A cookie is set automatically the first time a session is updated or a login occurs. | -| `connectionInfo` | object \| undefined | TLS facts a fronting proxy forwarded over PROXY protocol v2 — negotiated ALPN, SNI, TLS version/cipher, the client's JA3/JA4 fingerprint, and the client's mTLS certificate chain (presented, not necessarily verified — see below). `undefined` for a direct connection or one without a v2 header. See [Connection info](#connection-info). | -| `authorized` | boolean \| undefined | Whether the client's certificate passed TLS verification — Node's `socket.authorized`. On a directly-terminated connection this is verification against Harper's own `tls.certificateAuthority`; behind Harper Fabric's proxy-protocol UDS mirror it instead reflects Symphony's own, separately configured client CA, forwarded as a single trust bit (see [Trust and mTLS](#connection-info)). `false` for an unverified or absent client certificate on either path, including a mirrored connection where the proxy verified the client but omitted its oversized chain. `undefined` on a plain (non-TLS, non-mirrored) connection, and always `undefined` on Bun and uWebSockets. | -| `peerCertificate` | object \| null | The client's TLS peer certificate — Node's `socket.getPeerCertificate(true)`, cached after the first read. On a direct connection this is Node's full certificate object, including `issuerCertificate`; behind the Fabric UDS mirror it's a smaller object parsed from the forwarded chain (subject, issuer, subjectaltname, validity, serial number, fingerprints, `raw`, `issuerCertificate` — no `pubkey`, `bits`, `modulus`, `ca`, or `ext_key_usage`, though `raw` can be re-parsed for those). An empty object (`{}`) if no certificate was presented, if the proxy verified the certificate but omitted the chain, or if the forwarded leaf fails to parse (rare, but possible even when `authorized` is `true`); `null` on a connection with no TLS/mTLS support at this layer, and always `null` on Bun and uWebSockets. | +| Property | Type | Description | +| ----------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `url` | string | The request target (path + query string), e.g. `/path?query=string` | +| `method` | string | HTTP method: `GET`, `POST`, `PUT`, `DELETE`, etc. | +| `headers` | [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) | Request headers | +| `pathname` | string | Path portion of the URL, without query string | +| `protocol` | string | `http` or `https`. On the Node runtime, behind a TLS-terminating proxy that forwards the PROXY v2 SSL TLV, this reflects the client's original scheme even though the proxy-to-Harper hop is plaintext; a proxy forwarding only address info (no SSL TLV) still reads as `http`. Bun and uWebSockets derive this from `X-Forwarded-Proto`/the listener's secure flag instead, independent of the SSL TLV. | +| `data` | any | Deserialized body, based on `Content-Type` header | +| `ip` | string | Remote IP address of the client (or last proxy) | +| `host` | string | Host from the request headers | +| `session` | object | Current cookie-based session (a `Table` record instance). Update with `request.session.update({ key: value })`. A cookie is set automatically the first time a session is updated or a login occurs. | +| `connectionInfo` | object \| undefined | TLS facts a fronting proxy forwarded over PROXY protocol v2 — negotiated ALPN, SNI, TLS version/cipher, the client's JA3/JA4 fingerprint, and the client's mTLS certificate chain (presented, not necessarily verified — see below). `undefined` for a direct connection or one without a v2 header. See [Connection info](#connection-info). | +| `authorized` | boolean \| undefined | Whether the client's certificate passed TLS verification — Node's `socket.authorized`. On a directly-terminated connection this is verification against Harper's configured certificate authorities (`tls.certificateAuthority`, or a certificate record marked as an authority — see [Certificate Verification](../security/certificate-verification.md)); behind Harper Fabric's proxy-protocol UDS mirror it instead reflects Symphony's own, separately configured client CA, forwarded as a single trust bit (see [Trust and mTLS](#connection-info)). `false` for an unverified or absent client certificate on either path, including a mirrored connection where the proxy verified the client but omitted its oversized chain. `undefined` on a plain (non-TLS, non-mirrored) connection, and always `undefined` on Bun and uWebSockets. | +| `peerCertificate` | object \| null | The client's TLS peer certificate — Node's `socket.getPeerCertificate(true)`, cached after the first read. On a direct connection this is Node's full certificate object, including `issuerCertificate` — except on a resumed TLS session or Node.js 26.8.0/26.8.1, where the chain (and so `issuerCertificate`) isn't available; behind the Fabric UDS mirror it's a smaller object parsed from the forwarded chain (subject, issuer, subjectaltname, validity, serial number, fingerprints, `raw`, `issuerCertificate` — no `pubkey`, `bits`, `modulus`, `ca`, or `ext_key_usage`, though `raw` can be re-parsed for those). An empty object (`{}`) if no certificate was presented, if the proxy verified the certificate but omitted the chain, or if the forwarded leaf fails to parse (rare, but possible even when `authorized` is `true`); `null` on a connection with no TLS/mTLS support at this layer, and always `null` on Bun and uWebSockets. | ### Connection info @@ -126,7 +126,7 @@ class Origin { } ``` -**Trust and mTLS.** `connectionInfo` is populated only from the trusted proxy-protocol channel (Harper Fabric's per-worker Unix domain socket, writable only by the local proxy) — never from a request header, so the channel itself cannot be forged by a client (its _contents_ are client-controlled, per above). `clientCertChain` and `tls.verified` are independent signals, not one another's proof: a chain is attached whenever the client presented a certificate, whether or not the proxy verified it, and `tls.verified` can be `true` while `clientCertChain` is absent (a proxy may verify a certificate but omit an oversized chain). Treating chain presence alone as proof of a verified client accepts unverified certificates. For gating on a verified mTLS client, use [`request.authorized`](#properties) rather than `connectionInfo.tls.verified` or `clientCertChain` presence alone — it's `false` (not just absent) for an unverified or absent client certificate on either path, including a mirrored chain the proxy verified but omitted. Note that the proxy path verifies against Symphony's own, separately configured client CA, not Harper's `tls.certificateAuthority`, so a directly-terminated `authorized: true` and a mirrored `authorized: true` aren't guaranteed to share a trust root even though both gate the same way. To read the certificate itself, [`request.peerCertificate`](#properties) is populated the same way, but the mirror's version carries fewer fields than a direct connection's (see the table). Like `connectionInfo`, both are Node-runtime only: `request.authorized` and `request.peerCertificate` are always `undefined`/`null` on Bun and uWebSockets. +**Trust and mTLS.** `connectionInfo` is populated only from the trusted proxy-protocol channel (Harper Fabric's per-worker Unix domain socket, writable only by the local proxy) — never from a request header, so the channel itself cannot be forged by a client (its _contents_ are client-controlled, per above). `clientCertChain` and `tls.verified` are independent signals, not one another's proof: a chain is attached whenever the client presented a certificate, whether or not the proxy verified it, and `tls.verified` can be `true` while `clientCertChain` is absent (a proxy may verify a certificate but omit an oversized chain). Treating chain presence alone as proof of a verified client accepts unverified certificates. For gating on a verified mTLS client, use [`request.authorized`](#properties) rather than `connectionInfo.tls.verified` or `clientCertChain` presence alone — it's `false` (not just absent) for an unverified or absent client certificate on either path, including a mirrored chain the proxy verified but omitted. Note that the proxy path verifies against Symphony's own, separately configured client CA, not Harper's own configured certificate authorities, so a directly-terminated `authorized: true` and a mirrored `authorized: true` aren't guaranteed to share a trust root even though both gate the same way. To read the certificate itself, [`request.peerCertificate`](#properties) is populated the same way, but the mirror's version carries fewer fields than a direct connection's (see the table). Like `connectionInfo`, both are Node-runtime only: `request.authorized` and `request.peerCertificate` are always `undefined`/`null` on Bun and uWebSockets. ### Methods From 3bbb93d567a5d991a0ccfb18971713fd6714c10c Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sun, 18 Oct 2026 10:19:11 -0600 Subject: [PATCH 5/7] docs(http): caveat issuerCertificate and revocation, name authority records on the mTLS page - request.peerCertificate: issuerCertificate is also absent when the issuer is in neither the client's chain nor Harper's trusted CAs (e.g. an unverified client when mTLS is optional), not only on resumed sessions and Node.js 26.8.0/26.8.1. - request.authorized reflects chain trust only; revocation is checked separately by Harper's mTLS authentication when http.mtls is configured. - mtls-authentication.md named only tls.certificateAuthority as a CA source; certificate records marked as an authority count too. Dispatch-Task: documentation-request-authorized-peercert Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UrvaPnSVqfCTL7KPatikPN --- reference/http/api.md | 30 +++++++++++------------ reference/security/mtls-authentication.md | 4 +-- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/reference/http/api.md b/reference/http/api.md index c4bf3baa..98ccc598 100644 --- a/reference/http/api.md +++ b/reference/http/api.md @@ -78,20 +78,20 @@ A `Request` object is passed to HTTP middleware handlers and direct static REST ### Properties -| Property | Type | Description | -| ----------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `url` | string | The request target (path + query string), e.g. `/path?query=string` | -| `method` | string | HTTP method: `GET`, `POST`, `PUT`, `DELETE`, etc. | -| `headers` | [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) | Request headers | -| `pathname` | string | Path portion of the URL, without query string | -| `protocol` | string | `http` or `https`. On the Node runtime, behind a TLS-terminating proxy that forwards the PROXY v2 SSL TLV, this reflects the client's original scheme even though the proxy-to-Harper hop is plaintext; a proxy forwarding only address info (no SSL TLV) still reads as `http`. Bun and uWebSockets derive this from `X-Forwarded-Proto`/the listener's secure flag instead, independent of the SSL TLV. | -| `data` | any | Deserialized body, based on `Content-Type` header | -| `ip` | string | Remote IP address of the client (or last proxy) | -| `host` | string | Host from the request headers | -| `session` | object | Current cookie-based session (a `Table` record instance). Update with `request.session.update({ key: value })`. A cookie is set automatically the first time a session is updated or a login occurs. | -| `connectionInfo` | object \| undefined | TLS facts a fronting proxy forwarded over PROXY protocol v2 — negotiated ALPN, SNI, TLS version/cipher, the client's JA3/JA4 fingerprint, and the client's mTLS certificate chain (presented, not necessarily verified — see below). `undefined` for a direct connection or one without a v2 header. See [Connection info](#connection-info). | -| `authorized` | boolean \| undefined | Whether the client's certificate passed TLS verification — Node's `socket.authorized`. On a directly-terminated connection this is verification against Harper's configured certificate authorities (`tls.certificateAuthority`, or a certificate record marked as an authority — see [Certificate Verification](../security/certificate-verification.md)); behind Harper Fabric's proxy-protocol UDS mirror it instead reflects Symphony's own, separately configured client CA, forwarded as a single trust bit (see [Trust and mTLS](#connection-info)). `false` for an unverified or absent client certificate on either path, including a mirrored connection where the proxy verified the client but omitted its oversized chain. `undefined` on a plain (non-TLS, non-mirrored) connection, and always `undefined` on Bun and uWebSockets. | -| `peerCertificate` | object \| null | The client's TLS peer certificate — Node's `socket.getPeerCertificate(true)`, cached after the first read. On a direct connection this is Node's full certificate object, including `issuerCertificate` — except on a resumed TLS session or Node.js 26.8.0/26.8.1, where the chain (and so `issuerCertificate`) isn't available; behind the Fabric UDS mirror it's a smaller object parsed from the forwarded chain (subject, issuer, subjectaltname, validity, serial number, fingerprints, `raw`, `issuerCertificate` — no `pubkey`, `bits`, `modulus`, `ca`, or `ext_key_usage`, though `raw` can be re-parsed for those). An empty object (`{}`) if no certificate was presented, if the proxy verified the certificate but omitted the chain, or if the forwarded leaf fails to parse (rare, but possible even when `authorized` is `true`); `null` on a connection with no TLS/mTLS support at this layer, and always `null` on Bun and uWebSockets. | +| Property | Type | Description | +| ----------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `url` | string | The request target (path + query string), e.g. `/path?query=string` | +| `method` | string | HTTP method: `GET`, `POST`, `PUT`, `DELETE`, etc. | +| `headers` | [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) | Request headers | +| `pathname` | string | Path portion of the URL, without query string | +| `protocol` | string | `http` or `https`. On the Node runtime, behind a TLS-terminating proxy that forwards the PROXY v2 SSL TLV, this reflects the client's original scheme even though the proxy-to-Harper hop is plaintext; a proxy forwarding only address info (no SSL TLV) still reads as `http`. Bun and uWebSockets derive this from `X-Forwarded-Proto`/the listener's secure flag instead, independent of the SSL TLV. | +| `data` | any | Deserialized body, based on `Content-Type` header | +| `ip` | string | Remote IP address of the client (or last proxy) | +| `host` | string | Host from the request headers | +| `session` | object | Current cookie-based session (a `Table` record instance). Update with `request.session.update({ key: value })`. A cookie is set automatically the first time a session is updated or a login occurs. | +| `connectionInfo` | object \| undefined | TLS facts a fronting proxy forwarded over PROXY protocol v2 — negotiated ALPN, SNI, TLS version/cipher, the client's JA3/JA4 fingerprint, and the client's mTLS certificate chain (presented, not necessarily verified — see below). `undefined` for a direct connection or one without a v2 header. See [Connection info](#connection-info). | +| `authorized` | boolean \| undefined | Whether the client's certificate passed TLS verification — Node's `socket.authorized`. On a directly-terminated connection this is verification against Harper's configured certificate authorities (`tls.certificateAuthority`, or a certificate record marked as an authority — see [Certificate Verification](../security/certificate-verification.md)); behind Harper Fabric's proxy-protocol UDS mirror it instead reflects Symphony's own, separately configured client CA, forwarded as a single trust bit (see [Trust and mTLS](#connection-info)). `false` for an unverified or absent client certificate on either path, including a mirrored connection where the proxy verified the client but omitted its oversized chain. `undefined` on a plain (non-TLS, non-mirrored) connection, and always `undefined` on Bun and uWebSockets. | +| `peerCertificate` | object \| null | The client's TLS peer certificate — Node's `socket.getPeerCertificate(true)`, cached after the first read. On a direct connection this is Node's full certificate object. `issuerCertificate` is present only when Node can resolve the issuer, so it's absent on a resumed TLS session, on Node.js 26.8.0/26.8.1, and when the issuer is in neither the client's sent chain nor Harper's trusted CAs (for example, an unverified client when mTLS is optional) — check it before reading its fields; behind the Fabric UDS mirror it's a smaller object parsed from the forwarded chain (subject, issuer, subjectaltname, validity, serial number, fingerprints, `raw`, `issuerCertificate` — no `pubkey`, `bits`, `modulus`, `ca`, or `ext_key_usage`, though `raw` can be re-parsed for those). An empty object (`{}`) if no certificate was presented, if the proxy verified the certificate but omitted the chain, or if the forwarded leaf fails to parse (rare, but possible even when `authorized` is `true`); `null` on a connection with no TLS/mTLS support at this layer, and always `null` on Bun and uWebSockets. | ### Connection info @@ -126,7 +126,7 @@ class Origin { } ``` -**Trust and mTLS.** `connectionInfo` is populated only from the trusted proxy-protocol channel (Harper Fabric's per-worker Unix domain socket, writable only by the local proxy) — never from a request header, so the channel itself cannot be forged by a client (its _contents_ are client-controlled, per above). `clientCertChain` and `tls.verified` are independent signals, not one another's proof: a chain is attached whenever the client presented a certificate, whether or not the proxy verified it, and `tls.verified` can be `true` while `clientCertChain` is absent (a proxy may verify a certificate but omit an oversized chain). Treating chain presence alone as proof of a verified client accepts unverified certificates. For gating on a verified mTLS client, use [`request.authorized`](#properties) rather than `connectionInfo.tls.verified` or `clientCertChain` presence alone — it's `false` (not just absent) for an unverified or absent client certificate on either path, including a mirrored chain the proxy verified but omitted. Note that the proxy path verifies against Symphony's own, separately configured client CA, not Harper's own configured certificate authorities, so a directly-terminated `authorized: true` and a mirrored `authorized: true` aren't guaranteed to share a trust root even though both gate the same way. To read the certificate itself, [`request.peerCertificate`](#properties) is populated the same way, but the mirror's version carries fewer fields than a direct connection's (see the table). Like `connectionInfo`, both are Node-runtime only: `request.authorized` and `request.peerCertificate` are always `undefined`/`null` on Bun and uWebSockets. +**Trust and mTLS.** `connectionInfo` is populated only from the trusted proxy-protocol channel (Harper Fabric's per-worker Unix domain socket, writable only by the local proxy) — never from a request header, so the channel itself cannot be forged by a client (its _contents_ are client-controlled, per above). `clientCertChain` and `tls.verified` are independent signals, not one another's proof: a chain is attached whenever the client presented a certificate, whether or not the proxy verified it, and `tls.verified` can be `true` while `clientCertChain` is absent (a proxy may verify a certificate but omit an oversized chain). Treating chain presence alone as proof of a verified client accepts unverified certificates. For gating on a verified mTLS client, use [`request.authorized`](#properties) rather than `connectionInfo.tls.verified` or `clientCertChain` presence alone — it's `false` (not just absent) for an unverified or absent client certificate on either path, including a mirrored chain the proxy verified but omitted. `authorized` reflects chain trust only: revocation is checked separately, by Harper's mTLS authentication and only when `http.mtls` is configured (see [Certificate Verification](../security/certificate-verification.md)), so in your own middleware a revoked certificate can still read `authorized: true`. Note that the proxy path verifies against Symphony's own, separately configured client CA, not Harper's own configured certificate authorities, so a directly-terminated `authorized: true` and a mirrored `authorized: true` aren't guaranteed to share a trust root even though both gate the same way. To read the certificate itself, [`request.peerCertificate`](#properties) is populated the same way, but the mirror's version carries fewer fields than a direct connection's (see the table). Like `connectionInfo`, both are Node-runtime only: `request.authorized` and `request.peerCertificate` are always `undefined`/`null` on Bun and uWebSockets. ### Methods diff --git a/reference/security/mtls-authentication.md b/reference/security/mtls-authentication.md index 12cd8457..78d70711 100644 --- a/reference/security/mtls-authentication.md +++ b/reference/security/mtls-authentication.md @@ -13,7 +13,7 @@ Harper supports Mutual TLS (mTLS) authentication for incoming HTTP connections. ## How It Works 1. The client presents a TLS certificate during the handshake. -2. Harper validates the certificate against the configured CA (`tls.certificateAuthority`). +2. Harper validates the certificate against its configured CAs (`tls.certificateAuthority`, or a certificate record marked as an authority). 3. If valid, Harper extracts the `CN` from the certificate subject and uses it as the username for the request. 1. Or it is configurable via the `http.mtls.user` option in the relevant configuration object. 4. Optionally, Harper checks whether the certificate has been revoked (see [Certificate Verification](./certificate-verification.md)). @@ -69,7 +69,7 @@ The username for the mTLS-authenticated request is derived from the `CN` field o To use mTLS you need: -1. A Certificate Authority (CA) certificate configured in `tls.certificateAuthority`. +1. A Certificate Authority (CA) certificate configured in `tls.certificateAuthority`, or stored as a certificate record marked as an authority. 2. Client certificates signed by that CA, with a `CN` matching a Harper username. 3. The `http.mtls` configuration enabled. From c499222e8c63d879691bd7d9bd706bc746c75868 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sun, 18 Oct 2026 10:23:48 -0600 Subject: [PATCH 6/7] docs(http): name authority records as an http.mtls CA source The http.mtls option said client certificates verify only against tls.certificateAuthority; certificate records marked as an authority count too, matching the mTLS authentication and request.authorized docs. Dispatch-Task: documentation-request-authorized-peercert Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UrvaPnSVqfCTL7KPatikPN --- reference/http/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/http/configuration.md b/reference/http/configuration.md index 1f8cab5b..1119ba6e 100644 --- a/reference/http/configuration.md +++ b/reference/http/configuration.md @@ -197,7 +197,7 @@ Type: `boolean | object` Default: `false` -Enables mutual TLS (mTLS) authentication for HTTP connections. When set to `true`, client certificates are verified against the CA specified in `tls.certificateAuthority`. Authenticated connections use the `CN` (common name) from the certificate subject as the Harper username. +Enables mutual TLS (mTLS) authentication for HTTP connections. When set to `true`, client certificates are verified against Harper's configured CAs (`tls.certificateAuthority`, or a certificate record marked as an authority). Authenticated connections use the `CN` (common name) from the certificate subject as the Harper username. ```yaml http: From d8b1e492bedecd833624b51b84abe115a358a255 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sun, 18 Oct 2026 10:33:07 -0600 Subject: [PATCH 7/7] docs(http): split the revocation and trust-root caveats into their own sentences Drops "Note that" and gives each caveat in the Trust and mTLS paragraph its own sentence so it can be found when scanning. Dispatch-Task: documentation-request-authorized-peercert Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UrvaPnSVqfCTL7KPatikPN --- reference/http/api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/http/api.md b/reference/http/api.md index 98ccc598..ec2a7c41 100644 --- a/reference/http/api.md +++ b/reference/http/api.md @@ -126,7 +126,7 @@ class Origin { } ``` -**Trust and mTLS.** `connectionInfo` is populated only from the trusted proxy-protocol channel (Harper Fabric's per-worker Unix domain socket, writable only by the local proxy) — never from a request header, so the channel itself cannot be forged by a client (its _contents_ are client-controlled, per above). `clientCertChain` and `tls.verified` are independent signals, not one another's proof: a chain is attached whenever the client presented a certificate, whether or not the proxy verified it, and `tls.verified` can be `true` while `clientCertChain` is absent (a proxy may verify a certificate but omit an oversized chain). Treating chain presence alone as proof of a verified client accepts unverified certificates. For gating on a verified mTLS client, use [`request.authorized`](#properties) rather than `connectionInfo.tls.verified` or `clientCertChain` presence alone — it's `false` (not just absent) for an unverified or absent client certificate on either path, including a mirrored chain the proxy verified but omitted. `authorized` reflects chain trust only: revocation is checked separately, by Harper's mTLS authentication and only when `http.mtls` is configured (see [Certificate Verification](../security/certificate-verification.md)), so in your own middleware a revoked certificate can still read `authorized: true`. Note that the proxy path verifies against Symphony's own, separately configured client CA, not Harper's own configured certificate authorities, so a directly-terminated `authorized: true` and a mirrored `authorized: true` aren't guaranteed to share a trust root even though both gate the same way. To read the certificate itself, [`request.peerCertificate`](#properties) is populated the same way, but the mirror's version carries fewer fields than a direct connection's (see the table). Like `connectionInfo`, both are Node-runtime only: `request.authorized` and `request.peerCertificate` are always `undefined`/`null` on Bun and uWebSockets. +**Trust and mTLS.** `connectionInfo` is populated only from the trusted proxy-protocol channel (Harper Fabric's per-worker Unix domain socket, writable only by the local proxy) — never from a request header, so the channel itself cannot be forged by a client (its _contents_ are client-controlled, per above). `clientCertChain` and `tls.verified` are independent signals, not one another's proof: a chain is attached whenever the client presented a certificate, whether or not the proxy verified it, and `tls.verified` can be `true` while `clientCertChain` is absent (a proxy may verify a certificate but omit an oversized chain). Treating chain presence alone as proof of a verified client accepts unverified certificates. For gating on a verified mTLS client, use [`request.authorized`](#properties) rather than `connectionInfo.tls.verified` or `clientCertChain` presence alone — it's `false` (not just absent) for an unverified or absent client certificate on either path, including a mirrored chain the proxy verified but omitted. `authorized` reflects chain trust only. Revocation is checked separately, by Harper's mTLS authentication and only when `http.mtls` is configured (see [Certificate Verification](../security/certificate-verification.md)), so in your own middleware a revoked certificate can still read `authorized: true`. The proxy path verifies against Symphony's own, separately configured client CA, not Harper's own configured certificate authorities. A directly-terminated `authorized: true` and a mirrored `authorized: true` therefore aren't guaranteed to share a trust root, even though both gate the same way. To read the certificate itself, [`request.peerCertificate`](#properties) is populated the same way, but the mirror's version carries fewer fields than a direct connection's (see the table). Like `connectionInfo`, both are Node-runtime only: `request.authorized` and `request.peerCertificate` are always `undefined`/`null` on Bun and uWebSockets. ### Methods