Skip to content

Let clients without a managed backend manage pairing#4496

Open
colonelpanic8 wants to merge 4 commits into
pingdotgg:mainfrom
colonelpanic8:t3code/env-scoped-pairing-access
Open

Let clients without a managed backend manage pairing#4496
colonelpanic8 wants to merge 4 commits into
pingdotgg:mainfrom
colonelpanic8:t3code/env-scoped-pairing-access

Conversation

@colonelpanic8

@colonelpanic8 colonelpanic8 commented Jul 25, 2026

Copy link
Copy Markdown

What Changed

Access management (pairing links, authorized clients, and the pairing QR code) is now scoped to whichever server the Connections page is administering, instead of always the primary environment.

  • resolveAccessEnvironment() administers the primary when one exists, and the active environment otherwise. A desktop that manages its own backend is unaffected.
  • New packages/client-runtime/src/state/authHttp.ts routes /api/auth/session, /api/auth/pairing-token, /api/auth/pairing-links/revoke, /api/auth/clients/revoke, and /api/auth/clients/revoke-others through the environment supervisor's prepared connection.
  • environmentPairingBaseUrl() produces a shareable pairing URL only for a directly addressable HTTP origin. Relay and SSH environments fall back to showing the bare pairing code.
  • Pairing no longer requests an explicit scope set, so a client keeps exactly what its credential carries.
  • t3 pair prints the same scannable QR code that t3 serve already prints at startup.

Why

platformConnectionSourceLayer returns no registrations when isHostedStaticApp(), so a hosted-app client has no primary environment at all. canManageLocalBackend reads scopes from the primary session, which is null in that case, so the entire "Authorized clients" section was hidden. A user who had paired the hosted app to their own server could not create a pairing link, revoke a client, or display a pairing QR code for that server.

Worse, the page rendered a contradictory "Administrative access — pairing links and client-session management require the access:write scope for this backend" row even when the client had paired using the server's own administrative startup token.

That second part had its own cause: onboarding always requested the standard client scopes, and the server rejects a wider request rather than clamping it, so a client bootstrapped from a server's startup token was silently downgraded to a standard session and could not manage the access it had just been handed. Requesting no scopes and accepting what the credential carries fixes it.

The access-management endpoints were already per-environment; only the client bound them to the primary.

UI Changes

Hosted web client (no primary environment, served from a non-loopback origin on :53595) paired to a
standalone t3 serve on :53141, at Settings → Connections. Same viewport, theme, and backend for
every shot; only the apps/web build was swapped between commits.

Note: these captures were taken at b38c6b5. The branch has since been rebased onto upstream main as f14509692ccb; the rebase changed no file content, so every image below still reflects the current head exactly.

Before (38cfc25, main) — no "Authorized clients" section at all, so there is no way to create a
pairing link or revoke a client, plus a misleading scope message despite having paired with the
server's administrative startup token:

Before — no access management on main

After (b38c6b5) — the section renders against the saved environment, with Create link, revoke
controls, and the pairing link's QR affordance:

After — Authorized clients renders for the saved environment

After (b38c6b5) — the pairing link and its QR resolve to the administered backend's own origin
(:53141), not to the client app's origin (:53595). The token is redacted and the QR blurred; the
link was left to expire and the disposable backend was destroyed after capture:

After — pairing link resolves to the administered backend

Control for the resolveShareablePairingUrl review fix (unchanged rendering)

The review fix only changes the case where the administered environment has no directly
addressable HTTP origin — a relay environment, an SSH environment, or a bearer entry with no profile.
Those states are not reachable from a locally runnable web client (relay needs managed-relay
infrastructure to connect; SSH needs window.desktopBridge; a connected bearer environment always has
a base URL, since the resolver reads it from the same profile environmentPairingBaseUrl() reads), so
that branch is covered by the unit tests in ConnectionsSettings.logic.test.ts rather than by a
screenshot. For the case that is reachable here — an environment with a usable HTTP base URL — the
rendering is unchanged, as it should be:

Before the fix (dcd149d):

Control before — dcd149d

After the fix (b38c6b5):

Control after — b38c6b5

Only the expiry countdown differs.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes
  • I included a video for animation/interaction changes

Validation

vp test run apps/server/src/cliAuthFormat.test.ts \
  apps/web/src/components/settings/ConnectionsSettings.logic.test.ts \
  packages/client-runtime/src/connection/onboarding.test.ts   # 20 passed
vp run typecheck   # no errors
vp run lint        # no new warnings
vp run fmt:check   # clean

Integrated verification: hosted-mode web client (VITE_HOSTED_APP_CHANNEL set, no VITE_HTTP_URL/VITE_WS_URL) paired to an isolated t3 serve, then created a pairing link, opened its QR popover, and confirmed the link resolves to the environment's own base URL. Also confirmed a client holding only standard scopes still sees no access-management section.

Note for reviewers

The section's visibility is gated on access:write while its data stream requires access:read, so a credential granted one but not the other renders an error state. That mismatch already exists on main for the primary path, so it is left unchanged here.


Note

Medium Risk
Touches auth pairing scope exchange and per-environment access mutations; incorrect URL or environment routing could misdirect pairing or revoke the wrong server’s sessions.

Overview
Connections now administers pairing links and authorized clients on whichever server the page targets—primary when present, otherwise the active saved environment—so hosted or client-only setups can manage access without a managed backend.

New client-runtime authHttp paths wire session state and pairing/revocation mutations through each environment’s prepared connection and credentials. ConnectionsSettings uses those atoms for non-primary environments, gates UI on that environment’s access:write scope, and shows a dedicated Authorized clients section when there is no primary.

Shareable pairing URLs are chosen via resolveShareablePairingUrl / environmentPairingBaseUrl: prefer advertised or base HTTP origins, skip loopback and hosted links that wrap loopback, and avoid pointing scanners at the client app when administering a remote server (fallback to bare token).

Pairing onboarding no longer sends a scope on token exchange so the server grants exactly what the credential carries (fixes admin startup tokens being downgraded to standard scopes).

CLI human-readable t3 pair output adds a terminal QR when a pair URL exists; JSON output stays machine-readable without QR.

Reviewed by Cursor Bugbot for commit b824828. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Allow clients without a managed backend to manage pairing links and client sessions

  • Adds environment-scoped auth commands (createPairingCredential, revokePairingLink, revokeClientSession, revokeOtherClientSessions) and a sessionState query in authHttp.ts, enabling direct auth management against a connected non-primary environment.
  • The Connections settings page now shows an 'Authorized clients' section for selected saved environments when no managed backend is present, and routes pairing/revocation actions to the appropriate environment.
  • Shareable pairing URLs are resolved from multiple candidates (endpoint, base, current-origin) with loopback URLs filtered out; falls back to showing the bare code when no shareable URL is available.
  • Terminal QR codes are appended to human-readable formatIssuedPairingCredential output when a pairUrl is present; JSON output is unchanged.
  • OAuth token requests during pairing no longer send a scope parameter — scopes are now granted strictly per the pairing credential. Behavioral Change: previously, scopes from presentation.scopes were forwarded during token exchange.

Macroscope summarized b824828.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b916559e-e485-44af-92dc-f41bdd01dc10

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list. labels Jul 25, 2026
Comment thread apps/web/src/components/settings/ConnectionsSettings.tsx
@macroscopeapp

macroscopeapp Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces new authentication/authorization capability for managing pairing on non-primary environments. Changes to auth-sensitive code paths and scope handling, combined with an unresolved bug report about potential URL parsing crashes, warrant human review.

You can customize Macroscope's approvability policy. Learn more.

@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jul 25, 2026
Comment thread apps/web/src/components/settings/ConnectionsSettings.logic.ts
Access management was bound to the primary environment, so it was
reachable only from a client that owns a local backend. A hosted-app
client has no primary at all — `platformConnectionSourceLayer` emits no
registrations for `isHostedStaticApp()` — so the Connections page hid
the whole "Authorized clients" section. A user who had paired the
hosted app to their own server could not mint a pairing link, revoke a
client, or see a pairing QR code for that server.

Route the access-management endpoints through the environment
supervisor's prepared connection instead of the primary, and administer
the active environment when there is no primary. A desktop that manages
its own backend keeps administering that backend, so managed mode is
unchanged.

Pairing also silently lost administrative scope: onboarding always
requested the standard client scopes, and the server rejects a wider
request rather than clamping it, so a client bootstrapped from a
server's own startup token was downgraded to a standard session and
could not manage the access it had just been handed. Request no scopes
and take exactly what the credential carries.

Only a directly addressable HTTP origin yields a shareable pairing URL;
relay and SSH environments are reached through a broker or a local
tunnel whose address means nothing on another device, so those fall
back to showing the bare pairing code.
`t3 serve` already prints a QR code next to its startup pairing URL, but
a link minted later with `t3 pair` printed only text, so pairing a phone
meant transcribing a URL by hand.

Encode the pair URL when there is one. A bare token is not actionable on
the device that scans it, and JSON output stays machine-readable.
When the Connections page administers an environment it is not served by
— a relay or SSH environment, or a bearer environment whose profile is
missing — that environment has no addressable base URL, and the pairing
row is meant to fall back to the bare pairing code. It instead fell
through to the current page's `/pair` URL whenever this page was itself
on a non-loopback host, so the QR code and the copy button handed out a
link to this client app rather than to the server the link belongs to.

The page's own origin only reaches the administered server when that
server is the one that served the page, which is the managed-backend
case. Make that a stated input of the fallback rather than an accident
of the page's hostname, and move the choice into a tested helper.
@colonelpanic8
colonelpanic8 force-pushed the t3code/env-scoped-pairing-access branch from b38c6b5 to f145096 Compare July 25, 2026 03:23

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b824828. Configure here.

// outer URL can be public even when the target still resolves to the scanning
// device itself.
const targetUrl = pairingUrl.searchParams.get("host");
return targetUrl !== null && isLoopbackHost(new URL(targetUrl).hostname);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Malformed host crashes pairing row

Medium Severity

isLoopbackPairingUrl calls new URL(targetUrl) on the host query param without guarding parse failures. Pairing URLs built from resolveCurrentOriginPairingUrl inherit the page’s search string, so a missing, empty, or non-absolute host value can throw during render and break the Authorized clients list.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b824828. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant