Repository navigation
web: relay token minting endpoint (POST /api/relay/token) - #7879
Conversation
POST /api/relay/token: a signed-in cmux endpoint gets a short-TTL (5 min) EdDSA JWT (iss=cmux, aud=cmux-relay, sub=<user>, optional endpoint_id binding) plus the fleet RelayMap in one round-trip. The web API is the token issuer holding the Ed25519 PRIVATE key (CMUX_RELAY_JWT_PRIVATE_KEY_PEM); each relay holds only the public key and verifies offline. Returns 503 if the key is not provisioned, so it is safe to deploy before the secret is set. Signed with node:crypto (no new dependency). Backend-only; no iOS/macOS app changes. Test proves the minted token verifies against the matching public key (i.e. the relay would accept it) and covers 401/400/503/endpoint_id/RelayMap-override.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds an authenticated Next.js route that mints short-lived Ed25519 relay JWTs, with endpoint validation, rate limiting, signing-key configuration, relay URL resolution, and comprehensive route and token tests. ChangesRelay token minting
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant POST as POST route
participant Auth as verifyRequest
participant Limit as checkRateLimit
participant Key as relaySigningKey
participant JWT as mintRelayToken
participant Relay as relayUrls
Client->>POST: Send token request
POST->>Auth: Verify native client request
Auth-->>POST: Return authenticated user
POST->>Limit: Check per-user rate limit
POST->>Key: Load signing key
Key-->>POST: Return Ed25519 key
POST->>JWT: Mint token with user ID and endpoint ID
JWT-->>POST: Return token and expiration
POST->>Relay: Resolve relay URLs
POST-->>Client: Return token metadata and relay URLs
🚥 Pre-merge checks | ✅ 25✅ Passed checks (25 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryThis PR adds an authenticated API endpoint for issuing short-lived relay access tokens. The main changes are:
Confidence Score: 5/5The updated code looks safe to merge.
Important Files Changed
Reviews (8): Last reviewed commit: "web: canonical base32 endpoint-id + matc..." | Re-trigger Greptile |
| return key; | ||
| } catch { | ||
| return null; | ||
| } |
There was a problem hiding this comment.
PEM escaped newlines not normalized
Medium Severity
CMUX_RELAY_JWT_PRIVATE_KEY_PEM is passed straight to createPrivateKey without rewriting literal \n sequences. Other private keys loaded from env in this repo (ASC_PRIVATE_KEY, APNs .p8) normalize that form because it is common in Vercel/env stores. A key that still contains BEGIN but uses escaped newlines fails parse and surfaces as relay_token_not_configured 503.
Reviewed by Cursor Bugbot for commit c8fb208. Configure here.
| // Optional: bind the token to a specific iroh endpoint id (anti-replay). | ||
| let endpointId: string | undefined; | ||
| const raw = await request.text(); | ||
| if (raw.length > MAX_BODY_BYTES) { |
There was a problem hiding this comment.
| cached = { pem, key }; | ||
| return key; |
There was a problem hiding this comment.
Wrong Key Type Reaches Signing
createPrivateKey accepts valid private keys that are not Ed25519. If the secret contains another key type, this branch caches it and edSign(null, ...) can throw or create a signature inconsistent with the JWT's EdDSA header, turning a configuration error into a 500 or an unusable token.
| // iroh EndpointId is an Ed25519 public key rendered as z-base-32 (~52 chars) or | ||
| // hex (64 chars). Validate permissively on charset/length; the relay is | ||
| // authoritative on parsing and on matching the handshake key. | ||
| const ENDPOINT_ID_RE = /^[0-9a-z]{40,120}$/i; |
There was a problem hiding this comment.
Endpoint Pattern Accepts Invalid Keys
This pattern accepts values that are neither a 52-character iroh z-base-32 key nor a 64-character hexadecimal key, such as 40 arbitrary alphanumeric characters. The route returns 200 with that value in endpoint_id, but the relay cannot parse or match it, so the client receives a token that fails only when connecting.
| const urls = raw | ||
| .split(",") | ||
| .map((s) => s.trim()) | ||
| .filter(Boolean); | ||
| if (urls.length > 0) return urls; |
There was a problem hiding this comment.
Invalid Relay Overrides Return 200
The override accepts every nonempty string without parsing or enforcing HTTPS. A value such as usc1.relay.cmux.dev or an accidental http:// URL is returned in a successful response, leaving clients with an unusable or insecure RelayMap instead of exposing the configuration error.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c8fb208a22
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
|
|
||
| export async function POST(request: Request): Promise<Response> { | ||
| const user = await verifyRequest(request); |
There was a problem hiding this comment.
Reject cookie auth for relay token minting
Here verifyRequest is called with its default options, which also allow the Stack cookie token store; the comparable native-only /api/devices POST opts out with allowCookie: false. With the signing key configured, a signed-in cmux browser session can therefore POST to this route and read a relay JWT without the Authorization/X-Stack-Refresh-Token pair that this endpoint documents as its trust boundary, expanding relay credential minting to web-cookie contexts. Pass allowCookie: false here, or add the same browser mutation/origin protection if cookie auth is intended.
Useful? React with 👍 / 👎.
| const key = createPrivateKey(pem); | ||
| cached = { pem, key }; |
There was a problem hiding this comment.
Validate the relay signing key type
If CMUX_RELAY_JWT_PRIVATE_KEY_PEM is set to a valid non-Ed25519 private PEM, createPrivateKey succeeds and this caches it, while mintRelayToken still advertises alg: "EdDSA"; for example an RSA key will produce a 200 response with a signature that every Ed25519 relay verifier rejects. Treat parsed keys whose asymmetricKeyType is not ed25519 as not configured before caching/signing so deployment mistakes fail as the documented 503 instead of issuing unusable tokens.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@web/app/api/relay/token/route.ts`:
- Around line 107-159: Add abuse protection at the start of POST, after
verifyRequest confirms the user, by applying the project’s existing per-user or
per-IP rate-limiting mechanism before signingKey, request parsing, and
mintRelayToken. Return the established rate-limit response when the limit is
exceeded, while preserving normal token issuance for allowed requests; use
existing limiter/coalescing symbols rather than introducing an unrelated
implementation.
- Around line 107-109: Update the verifyRequest call in POST to pass the option
that disables cookie authentication, using allowCookie: false. Preserve the
existing unauthorized response and ensure relay tokens are minted only through
Stack Bearer and refresh-token authentication.
In `@web/tests/relay-token-route.test.ts`:
- Around line 113-136: Add tests alongside the existing relay token route tests
for both validation branches: send malformed JSON through POST and assert status
400, then send an oversized endpointId payload and assert status 413. Reuse the
existing currentUser setup and request helpers where appropriate, targeting the
POST handler.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 7a2a5096-b403-4b10-bcfd-20aaf6aebab6
📒 Files selected for processing (2)
web/app/api/relay/token/route.tsweb/tests/relay-token-route.test.ts
| test("400 on malformed endpoint_id", async () => { | ||
| currentUser = { id: "user_123" }; | ||
| const res = await POST(postReq({ endpointId: "not a valid id!!" })); | ||
| expect(res.status).toBe(400); | ||
| }); | ||
|
|
||
| test("honors CMUX_RELAY_URLS override", async () => { | ||
| currentUser = { id: "user_123" }; | ||
| process.env.CMUX_RELAY_URLS = | ||
| "https://a.example.com, https://b.example.com"; | ||
| const res = await POST(postReq()); | ||
| const json = (await res.json()) as { relays: string[] }; | ||
| expect(json.relays).toEqual([ | ||
| "https://a.example.com", | ||
| "https://b.example.com", | ||
| ]); | ||
| }); | ||
|
|
||
| test("503 when the signing key is not configured", async () => { | ||
| currentUser = { id: "user_123" }; | ||
| delete process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM; | ||
| const res = await POST(postReq()); | ||
| expect(res.status).toBe(503); | ||
| }); |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Add coverage for the size-limit and malformed-JSON branches.
The route has explicit payload_too_large (413) and invalid_json (400) paths that no test exercises. For a signing endpoint these validation branches are worth pinning so regressions in the body-size/parse guards surface in CI.
💚 Suggested additional tests
test("400 on malformed JSON body", async () => {
currentUser = { id: "user_123" };
const res = await POST(
new Request("https://cmux.dev/api/relay/token", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{not json",
}),
);
expect(res.status).toBe(400);
});
test("413 when body exceeds the size limit", async () => {
currentUser = { id: "user_123" };
const res = await POST(postReq({ endpointId: "y".repeat(5000) }));
expect(res.status).toBe(413);
});🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/tests/relay-token-route.test.ts` around lines 113 - 136, Add tests
alongside the existing relay token route tests for both validation branches:
send malformed JSON through POST and assert status 400, then send an oversized
endpointId payload and assert status 413. Reuse the existing currentUser setup
and request helpers where appropriate, targeting the POST handler.
The route-level test globally mock.module'd auth+routeHelpers, which leaked into the shared bun-test process and broke 44 unrelated route tests. Move the pure minting/validation logic into services/relay/token.ts and unit-test that (no mocking, no leak): mint-verifies-against-pubkey, wrong-key-rejects, endpoint_id binding, key/URL config. Route is now thin glue over the service. Full web suite: 586 pass / 0 fail; typecheck clean.
| return key; | ||
| } catch { | ||
| return null; | ||
| } |
There was a problem hiding this comment.
PEM newline escapes ignored
Medium Severity
relaySigningKey passes CMUX_RELAY_JWT_PRIVATE_KEY_PEM straight to createPrivateKey without normalizing literal \n escapes. Elsewhere (ASC_PRIVATE_KEY, APNs .p8) the codebase rewrites those escapes before parsing. A normally provisioned single-line PEM then fails parse, returns null, and the route keeps answering 503 relay_token_not_configured.
Reviewed by Cursor Bugbot for commit 5b95538. Configure here.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5b95538927
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } catch { | ||
| return jsonResponse({ error: "invalid_json" }, 400); | ||
| } | ||
| if (body.endpointId !== undefined && body.endpointId !== null) { |
There was a problem hiding this comment.
Guard parsed JSON before reading endpointId
When an authenticated caller sends a valid JSON null body, JSON.parse assigns body = null and this dereference throws before the route can return a controlled 400/empty-body response, so /api/relay/token returns a 500 for malformed input. Validate that the parsed value is a non-null object before reading endpointId (or treat null as an empty body).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@web/services/relay/token.ts`:
- Around line 53-64: Update the catch block in relaySigningKey to log the
createPrivateKey parse error before returning null, while preserving the
existing return behavior and leaving the missing-key path unchanged.
In `@web/tests/relay-token.test.ts`:
- Around line 37-40: Add teardown for the environment variables modified by the
relay-token test suite, using afterEach or afterAll alongside beforeEach.
Capture their original values before mutation and restore
CMUX_RELAY_JWT_PRIVATE_KEY_PEM and CMUX_RELAY_URLS afterward, deleting them when
they were initially unset so subsequent tests see the original process
environment.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 94ec3b07-212d-470a-b930-ca2c65fe9af3
📒 Files selected for processing (3)
web/app/api/relay/token/route.tsweb/services/relay/token.tsweb/tests/relay-token.test.ts
| export function relaySigningKey(): KeyObject | null { | ||
| const pem = process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM; | ||
| if (!pem || !pem.includes("BEGIN")) return null; | ||
| if (cached && cached.pem === pem) return cached.key; | ||
| try { | ||
| const key = createPrivateKey(pem); | ||
| cached = { pem, key }; | ||
| return key; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Log parse errors in relaySigningKey before swallowing them.
The catch block silently returns null on malformed PEM input, making production 503s undiagnosable. Operators cannot distinguish "key not configured" from "key configured but invalid" without server-side logging.
🛠️ Proposed fix
try {
const key = createPrivateKey(pem);
cached = { pem, key };
return key;
- } catch {
+ } catch (error) {
+ console.error("[relay] Failed to parse signing key:", error);
return null;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export function relaySigningKey(): KeyObject | null { | |
| const pem = process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM; | |
| if (!pem || !pem.includes("BEGIN")) return null; | |
| if (cached && cached.pem === pem) return cached.key; | |
| try { | |
| const key = createPrivateKey(pem); | |
| cached = { pem, key }; | |
| return key; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| export function relaySigningKey(): KeyObject | null { | |
| const pem = process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM; | |
| if (!pem || !pem.includes("BEGIN")) return null; | |
| if (cached && cached.pem === pem) return cached.key; | |
| try { | |
| const key = createPrivateKey(pem); | |
| cached = { pem, key }; | |
| return key; | |
| } catch (error) { | |
| console.error("[relay] Failed to parse signing key:", error); | |
| return null; | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/services/relay/token.ts` around lines 53 - 64, Update the catch block in
relaySigningKey to log the createPrivateKey parse error before returning null,
while preserving the existing return behavior and leaving the missing-key path
unchanged.
| beforeEach(() => { | ||
| process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM = privatePem; | ||
| delete process.env.CMUX_RELAY_URLS; | ||
| }); |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add afterEach/afterAll to restore env vars after the suite.
beforeEach sets CMUX_RELAY_JWT_PRIVATE_KEY_PEM and deletes CMUX_RELAY_URLS, but there's no corresponding teardown. After the suite completes, these env vars remain modified, leaking global state to subsequent test files if Bun shares a process. The PR objectives explicitly call out preventing test leakage, so adding cleanup aligns with that goal.
As per coding guidelines: "Isolate shared static, global, UserDefaults, file, and related state per test, resetting it in setUp and tearDown as appropriate."
🛡️ Proposed fix
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { generateKeyPairSync, verify as edVerify } from "node:crypto";
@@ -37,6 +37,11 @@
beforeEach(() => {
process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM = privatePem;
delete process.env.CMUX_RELAY_URLS;
});
+
+afterEach(() => {
+ delete process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM;
+ delete process.env.CMUX_RELAY_URLS;
+});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| beforeEach(() => { | |
| process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM = privatePem; | |
| delete process.env.CMUX_RELAY_URLS; | |
| }); | |
| beforeEach(() => { | |
| process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM = privatePem; | |
| delete process.env.CMUX_RELAY_URLS; | |
| }); | |
| afterEach(() => { | |
| delete process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM; | |
| delete process.env.CMUX_RELAY_URLS; | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/tests/relay-token.test.ts` around lines 37 - 40, Add teardown for the
environment variables modified by the relay-token test suite, using afterEach or
afterAll alongside beforeEach. Capture their original values before mutation and
restore CMUX_RELAY_JWT_PRIVATE_KEY_PEM and CMUX_RELAY_URLS afterward, deleting
them when they were initially unset so subsequent tests see the original process
environment.
Source: Coding guidelines
Codex review fixes:
- native-only auth: verifyRequest({ allowCookie: false }) so a browser cookie
cannot mint an exportable relay JWT (matches /api/devices).
- stream + cancel the body at 4 KiB via readBoundedJsonObject (no buffer-first),
and reject non-object JSON (null/array/primitive) -> 400 instead of 500.
- relaySigningKey requires asymmetricKeyType === ed25519, else treated as
unconfigured (503) rather than minting an unusable token.
- route is now handleRelayTokenRequest(request, deps) with injected auth/key/clock,
so 401/503/400/413/endpoint_id behavior is unit-tested without leaking module
mocks (the prior leak broke 44 unrelated tests).
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@web/app/api/relay/token/route.ts`:
- Around line 70-81: Ensure endpoint ID casing is consistent between validation
and token signing: update the endpoint ID handling near rawEndpointId and
mintRelayToken so accepted mixed-case values are normalized before validation
and minting, or preserve their original casing by removing the signing-time
lowercase conversion. Apply the same chosen representation throughout the claim.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: de39d863-1dbf-498e-929c-a743bbd42202
📒 Files selected for processing (3)
web/app/api/relay/token/route.tsweb/services/relay/token.tsweb/tests/relay-token-route.test.ts
Second review round: - endpoint_id is now MANDATORY (mintRelayToken requires it; route 400s on missing/null) so no token is a bearer credential replayable from any key. - per-account issuance rate limit via @vercel/firewall keyed by user id (CMUX_RELAY_TOKEN_RATE_LIMIT_ID; disabled when unset, 429 when exhausted). The complementary per-relay connection cap belongs in the relay (separate repo). - isValidEndpointId now matches the relay's exact EndpointId contract: 64 hex or 52-char z-base-32; a token the relay could never parse is rejected 400 instead of returning a signed-but-useless 200.
On Vercel the rate limit is now mandatory: a missing rule id, a not-found rule, or any limiter error returns 503 instead of silently minting without a bound (matches /api/client-config). Local dev (no VERCEL env) still bypasses.
… validation scope - Wrap @vercel/firewall checkRateLimit in try/catch: it rejects (not via error) on network failure/unexpected status, which was an uncaught 500. Now 503, with a test exercising the rejection path. - Document why isValidEndpointId validates encoding shape (64 hex / 52 z-base-32) but defers on-curve validation to the relay: the endpoint_id is the caller's own key, so an invalid point is self-harm (a token bound to a key nobody holds, unredeemable), not a replay/availability risk — not worth a curve dependency.
- isValidEndpointId now accepts what iroh EndpointId::from_str actually parses: 64-char lowercase hex or 52-char RFC 4648 base32 (A-Z2-7). The previous z-base-32 alphabet rejected valid base32 ids and accepted z-base-32 the relay cannot parse (verified against iroh-base 1.0.0-rc.1 decode_base32_hex). - Race the @vercel/firewall check against a 3s deadline and fail closed (503) on a stall, so a hung limiter cannot block token issuance / attach. Test covers a never-settling limiter.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 907488f. Configure here.
- isValidEndpointId now requires the canonical RFC 4648 final symbol: a 52-char base32 of 32 bytes has 4 trailing zero bits, so the last char must be 'a' or 'q'. Non-canonical strings (which iroh's BASE32_NOPAD decoder rejects) are now 400 instead of a signed-but-unusable 200. - Drop the Promise.race timeout: @vercel/firewall has no abort signal, so a wrapper can only abandon (not cancel) the fetch. Match the repo convention (client-config/waitlist/feedback: plain awaited call; platform bounds duration) and keep the fail-closed try/catch for rejections. Remaining review items are conscious cross-boundary exceptions: the per-relay connection/traffic cap + expiry-disconnect is relay-side (cmux-relay/iroh-relay, separate repo), and on-curve endpoint validation is deferred to the relay (the id is the caller's own key; an invalid one is self-harm).
* feat(iroh): bridge the production Swift endpoint * feat(iroh): run the mac host transport * test(iroh): reject local binding substitution * test(ios): cover pooled fallback route * fix(iroh): bind discovery to the local app instance * fix(ios): retain successful pooled route * test(iroh): require online-first host policy fallback * feat(iroh): start hosts from verified offline policy * feat(iroh): persist mac offline host policy * fix(iroh): reject partial online binding rotation * test(iroh): reject unvalidated private fallback * feat(iroh): own iOS endpoint and multistream sessions * fix(iroh): revalidate private fallback paths * fix(iroh): accept JSON media type parameters * ci(iroh): test full app on Intel Sonoma * ci(iroh): run transport tests on Intel Sonoma * test(iroh): cover abandoned relay reservations * fix(iroh): expire abandoned relay reservations * feat(iroh): expose admitted host multistream sessions * test(iroh): require bounded incoming streams * fix(iroh): bound peer-created QUIC streams * feat(iroh): defer iOS transport activation * feat(ios): prefer verified Iroh routes * test(iroh): preserve endpoint on preferred port collision * test(iroh): cover LAN rendezvous consistency * fix(iroh): fall back from occupied preferred port * feat(iroh): derive private rotating LAN aliases * fix(iroh): serialize LAN discovery with revocation * feat(iroh): make secure pairing the default * docs(iroh): record offline and LAN trust boundaries * feat(iroh): cache verified client policy offline * docs(iroh): add Apple and proxy launch caveats * docs(iroh): clarify Apple local network prompting * test(iroh): cover offline cache teardown races * fix(iroh): fence offline cache teardown * test(iroh): cover online admission leases * feat(iroh): gate online admission leases * feat(iroh): add authenticated Bonjour LAN fallback * feat(iroh): enforce online revocation leases * test(iroh): cover offline admission leases * test(iroh): cover canonical trust errors * fix(iroh): harden trust broker boundaries * fix(iroh): bound offline admission leases * test(iroh): cover policy refresh revision races * fix(iroh): fence admission policy refreshes * docs(iroh): narrow private network release scope * test(tailscale): reject unbound bearer routes * fix(mobile): state private network boundaries * test(tailscale): reject unbound bearer routes Cover numeric-only Tailscale bearer routes and reject authorization, DNS, and route substitution before transport writes. * docs(iroh): specify NAT authorization barrier * fix(tailscale): bind bearer writes to live tunnel * test(iroh): require acknowledged NAT admission barrier * test(tailscale): reject route-only transport bypass * fix(tailscale): close route-only transport bypass * fix(iroh): acknowledge NAT admission before app streams * test(iroh): hide database failure details * fix(iroh): defer reservation constraint validation * test(iroh): retain revocation monitor after handoff * fix(iroh): retain revocation monitor for connection * test(iroh): reject broker credential redirects * fix(iroh): block broker credential redirects * test(iroh): fail closed on terminal foreground policy * fix(iroh): fail closed on terminal policy refresh * test(iroh): prevent raw fallback after admission failure * fix(iroh): pin authenticated pairings to Iroh * test(auth): reject device registry redirects * test(iroh): lock registration identity and relay bootstrap * fix(iroh): preserve registration trust identity * fix(auth): reject credentialed API redirects * test(iroh): keep direct paths out of cloud storage * fix(iroh): keep direct paths device local * test(iroh): evict remotely closed client sessions * test(iroh): recover dead session on foreground * test(iroh): prevent server path-hint disclosure * fix(iroh): recover suspended client sessions * fix(iroh): keep private paths off server surfaces * test(iroh): reject overlapping LAN bootstrap routes * fix(iroh): reject ambiguous LAN interfaces * test(iroh): bound pending admissions per identity * fix(iroh): limit pending admissions per peer * test(iroh): require owned server event stream * test(auth): bound credentialed HTTP responses * test(iroh): reject concurrent control owners * fix(auth): cap credentialed HTTP responses * test(iroh): cover firewall dependency failures * fix(iroh): bound firewall availability checks * feat(iroh): deliver server events on owned stream * test(iroh): cap stalled firewall work * test(iroh): bound active sessions per binding * fix(iroh): cap stalled firewall work * fix(iroh): cap active sessions per binding * test(iroh): require firewall timeout recovery * fix(iroh): abort stalled firewall checks * fix(ci): isolate Iroh transport test suites * test(iroh): route revocation to broker delete * fix(iroh): send revocation to broker route * test(mobile): bound concurrent RPC work * fix(mobile): cap concurrent RPC work * test(mobile): bound decoded frame batches * fix(mobile): cap decoded frame batches * test(iroh): bound pending Bonjour resolves * test(iroh): gate reserved application lanes * test(iroh): retain failed binding revocations * fix(iroh): bound pending Bonjour resolves * fix(iroh): gate reserved application lanes * docs(iroh): narrow production multistream claims * build(iroh): pin attested Swift fork release * test(iroh): require retry-safe binding revocation * fix(iroh): make binding revocation retry-safe * fix(iroh): durably retry binding revocations * build(iroh): lock iOS Swift fork release * test(iroh): retain Bonjour observation lifetime * test(auth): prepare before raced sign-out clear * test(iroh): quarantine failed sign-out persistence * test(ios): quarantine failed Iroh sign-out * fix(iroh): quarantine incomplete sign-out teardown * fix(iroh): clear host network state in quarantine * fix(auth): quarantine Iroh before sign-out clear * fix(ios): quarantine incomplete Iroh sign-out * fix(mobile): type Iroh binding snapshot * fix(ios): wait for auth clear before Iroh recovery * build(iroh): lock app Swift fork release * fix(iroh): persist secrets in ad-hoc debug builds * test(iroh): require local-only HTTP minter opt-in * feat(iroh): add loopback relay minter runner * test(tailscale): require numeric registry targets * feat(iroh): gate local relay minter HTTP * fix(iroh): normalize local minter opt-in * test(ios): require tagged API origin bake * fix(ios): bake tagged API origin * fix(tailscale): pin MagicDNS remotes to peer IPs * fix(tailscale): reject inactive peer snapshots * build(iroh): pin hardened FFI release * test(auth): preserve auto-login during token reads * test(auth): preserve manual sign-in during token reads * test(iroh): require startup network event delivery * fix(iroh): establish endpoint observation before activation * fix(auth): preserve active session writers * test(iroh): accept existing binding registration responses * fix(iroh): accept existing binding relay status * test(iroh): keep host active after refresh throttling * fix(iroh): preserve host during broker throttling * test(iroh): preserve client during broker throttling * fix(iroh): retain verified policy during broker outages * fix(iroh): decode broker dates on older macOS * test(iroh): reject synthetic network change floods * fix(iroh): observe address changes without feedback loop * test(iroh): accept canonical UUID identity case * fix(iroh): canonicalize pinned device UUIDs * fix(iroh): harden compatibility and private routes * refactor(iroh): split runtime ownership boundaries * test(iroh): repair authorization suite split boundaries * test(iroh): link mobile RPC authorization tests * test(iroh): support compatibility compilers * test(iroh): cover uppercase UUID fallback paths * fix(iroh): canonicalize device UUID authority * test(iroh): support Intel Sonoma compiler * test(iroh): avoid non-Sendable fixture captures * fix(updater): handle Intel-only Sparkle reason * test(iroh): cover bearer and discovery overload * fix(iroh): close route and discovery gaps * fix(ci): close Iroh compatibility regressions * feat(iroh): integrate endpoint-bound relay fleet * fix(ci): wrap command timers for Intel Swift * fix(ios): expose relay deployment to Sendable factory * test(ci): cover private networking on Intel Sonoma * test(ci): support Intel Swift Testing macros * test(iroh): expose relay refresh expiry gap * fix(iroh): retry relay refresh before expiry * fix(ios): serialize Iroh quarantine recovery * refactor(auth): isolate lifecycle revision API * fix(ci): eliminate Iroh Swift 6 warnings * fix(ci): support Intel Xcode 16.2 * fix(ci): mark canvas clock sleep sendable * fix(ci): bridge canvas preferences to main actor * fix(ci): support sidebar git on Xcode 16.2 * fix(ci): mark RPC termination handler sendable * fix(ci): support CLI on Xcode 16.2 * fix(ci): support app target on Xcode 16.2 * fix(ci): finish Xcode 16.2 source compatibility * iroh: point the broker relay fleet at the 7 self-hosted relay.cmux.dev URLs Replaces the 4 hosted iroh.link relays with our self-hosted fleet in both allowlists (web MANAGED_RELAY_URLS + presence worker APPROVED_IROH_RELAY_URLS, kept in lockstep) and the tests that referenced hosted URLs. The self-hosted relays run iroh-relay 1.0.2 behind per-region MIG+L4-LB (zero-downtime upgrades), gated by the cmux EdDSA JWT that /api/relay/token (merged, #7879) mints. * fix(ci): support trailing closure on Xcode 16.2 * ci: allow Intel compatibility suite to finish * test(ci): avoid Xcode 16.2 require recursion * ci: focus Intel compatibility coverage * fix(ci): stabilize replay ownership and Intel budget * test(ios): isolate authoritative resync coverage * feat(iroh): add secure flexible relay policy * test(iroh): split relay runtime coverage * test(iroh): allow self-hosted broker without legacy minter * fix(iroh): make hosted relay minter optional * test(iroh): cover public firewall host fallback * fix(iroh): use public host for firewall checks * fix(iroh): keep accepts and sign-out responsive * test(iroh): reproduce lost online reachability * fix(iroh): republish endpoint online routes * test(iroh): reproduce coalesced route refresh * fix(iroh): replay coalesced route refreshes * refactor(iroh): split oversized runtime files * test(iroh): cover lifecycle refresh races * fix(iroh): fence lifecycle refresh work * test(ios): reproduce loopback dev auto-pair race * test(ios): cover redacted dev Iroh attach URLs * fix(ios): wait for redacted Iroh dev attach ticket * test(ios): reproduce Iroh cold-start attach race * fix(ios): await Iroh before dev auto-pair * feat(iroh): add server-driven relay preferences * feat(iroh): complete relay controls and multistream runtime * ci: rehearse staging migrations from dispatched branch * Make managed Iroh credentials server-driven * feat(iroh): expose redacted live path diagnostics * security(iroh): stage relay policy key rotation * fix(iroh): use instance-scoped host display name * test(iroh): require dev attach targets to prefer identity routes * fix(iroh): prefer identity routes for dev attach * fix(web): include shared relay catalog in Next root * test(web): keep relay catalog inside Next boundary * fix(web): generate relay catalog inside runtime boundaries * test(mobile): cover transport lifetime ownership * fix(mobile): retain Iroh transport lifetime * test(iroh): cover relay policy clock skew * fix(iroh): tolerate bounded relay policy clock skew * test(iroh): cover admitted session lifetime * fix(iroh): separate admission and session lifetimes * test(iroh): cover relay and route renewal stalls * fix(iroh): keep relay routes renewed through storage stalls * test(iroh): cover nonblocking binding persistence * fix(iroh): publish bindings before secure persistence * test(iroh): cover strict transport verification modes * feat(iroh): add strict transport verification modes * test(iroh): await nonblocking relay persistence * test(iroh): cover live peer connection quotas * fix(iroh): bound live sessions per endpoint * test(iroh): cover broker-aware route renewal backoff * fix(iroh): back off broker route renewal retries * test(mobile): cover superseded Iroh transport cleanup * fix(iroh): close unowned mobile sessions * test(iroh): reproduce stale reconnect sessions * fix(iroh): replace stale peer sessions on admission * feat(iroh): add debug transport mode menu * Add regression coverage for Iroh merge blockers * Fix Iroh relay and reconnect merge blockers --------- Co-authored-by: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Co-authored-by: cmux reload-cloud <cmux-reload-cloud@users.noreply.github.com> Co-authored-by: Aziz Albahar <aziz@manaflow.ai>


Backend-only. Implements the web-API side of the private iroh relay: the endpoint that mints the access token cmux clients present to the JWT-gated relay fleet. No iOS/macOS app changes.
What
POST /api/relay/token(Node runtime). A signed-in cmux endpoint gets back:{ "token": "<EdDSA JWT>", "expiresAt": 1783..., "ttlSeconds": 300, "relays": ["https://usc1.relay.cmux.dev", ...7 URLs...] }X-Stack-Refresh-Token(same native-client path as/api/devices), viaverifyRequest. Unauthenticated → 401.iss=cmux,aud=cmux-relay,sub=<user id>,iat,exp, and optionalendpoint_id(anti-replay binding to the client's iroh key). Signed withnode:cryptoEd25519 — no new dependency.CMUX_RELAY_URLS.503 relay_token_not_configureduntilCMUX_RELAY_JWT_PRIVATE_KEY_PEMis set, so shipping this before provisioning the secret is a no-op.Why
The relay fleet is live and gated; each relay holds only the Ed25519 public key and verifies tokens offline. This endpoint is the issuer holding the private key. It's the last missing piece before the client (separate PR #7826, which already threads the token through the bind FFI) can actually authenticate — that wiring stays nil until this ships.
Config
CMUX_RELAY_JWT_PRIVATE_KEY_PEM— the Ed25519 PKCS8 private PEM (the match to the public key baked into the relays). Set in Vercel env / secret store. In dev it's the contents of~/.secrets/cmux-relay/jwt-private.pem.CMUX_RELAY_URLS(optional) — comma-separated override of the returned RelayMap.Tests
web/tests/relay-token-route.test.ts(bun): the minted token verifies against the matching public key (i.e. a relay would accept it), plus 401 (unauthenticated), 400 (malformedendpoint_id), 503 (key unset),endpoint_idbinding, and theCMUX_RELAY_URLSoverride.bun run typecheckclean.Follow-up (not here)
Populate
relayAuthTokenat the client composition roots (PR #7826) to call this endpoint and attach the token; set the client RelayMap from therelaysfield.Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.Note
High Risk
Introduces a new credential-issuing API backed by a private signing key; misconfiguration or weak rate-limit setup could enable abuse, though endpoint binding and fail-closed Vercel limits mitigate replay and unbounded minting.
Overview
Adds
POST /api/relay/tokenso native cmux clients can mint short-lived Ed25519 JWTs for the private iroh relay fleet and receive the relay URL map in the same response.Auth matches
/api/devices: Stack Bearer + refresh header only (allowCookie: false). On Vercel, per-account@vercel/firewallrate limiting is mandatory and fail-closed (missing rule, errors, or throws → 503); local dev skips it. Requests must include a validendpointId(64-char hex or canonical 52-char RFC base32); tokens bindendpoint_idin the JWT to limit replay. Minting usesCMUX_RELAY_JWT_PRIVATE_KEY_PEM; if unset or non-Ed25519 → 503 so deploy-before-secret is safe. Logic lives inservices/relay/token.tswith an injectablehandleRelayTokenRequestfor tests;web/tests/relay-token*.test.tscover auth, limits, validation, and signature verification.Reviewed by Cursor Bugbot for commit 399e124. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by cubic
Adds POST
/api/relay/tokento mint short‑lived Ed25519 JWTs for the private relay and return the relay map in one call, with mandatory endpoint binding and a fail‑closed per‑account rate limit on Vercel. Safe to deploy; returns 503 until the signing key is set and when the rate limiter is unavailable.New Features
X‑Stack‑Refresh‑Token; cookies refused (401 if missing).endpointId; exact 64‑hex or 52‑char RFC 4648 base32 (A‑Z2‑7), requiring the canonical final symbol (aorq); non‑object → 400; >4 KiB → 413. Encoding validated; relay enforces curve validity.iss=cmux,aud=cmux‑relay,sub,iat,exp,endpoint_id=lowercased); signed withnode:cryptoEd25519. Only an Ed25519 key is accepted.{ token, expiresAt, ttlSeconds, relays }; relays default to the 7‑region fleet, overridable viaCMUX_RELAY_URLS.@vercel/firewall, keyed by user id; 429 when exceeded. On Vercel, missing/not‑found/errors/throws → 503; local dev bypasses.Migration
CMUX_RELAY_JWT_PRIVATE_KEY_PEMto the Ed25519 PKCS8 private PEM.CMUX_RELAY_TOKEN_RATE_LIMIT_ID(mandatory; otherwise 503).CMUX_RELAY_URLSto customize relay URLs.Written for commit 399e124. Summary will update on new commits.
Summary by CodeRabbit
token,expiresAt,ttlSeconds(300s), and the applicable relay URLs.