diff --git a/apps/server/src/cliAuthFormat.test.ts b/apps/server/src/cliAuthFormat.test.ts index a428adc7bea..2e48fa3dbbf 100644 --- a/apps/server/src/cliAuthFormat.test.ts +++ b/apps/server/src/cliAuthFormat.test.ts @@ -23,6 +23,44 @@ it("formats issued pairing credentials with the secret and optional pair URL", ( expect(output).toContain("secret-pairing-token"); expect(output).toContain("https://example.com/pair#token=secret-pairing-token"); + // Scannable from another device, matching `t3 serve`'s startup output. + expect(output).toContain("█"); +}); + +it("omits the QR code when there is no pair URL worth scanning", () => { + const output = formatIssuedPairingCredential( + { + id: "pairing-1", + credential: "secret-pairing-token", + scopes: ["orchestration:read"], + subject: "one-time-token", + createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"), + expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"), + }, + { json: false }, + ); + + expect(output).toContain("secret-pairing-token"); + expect(output).not.toContain("█"); +}); + +it("keeps JSON output machine-readable rather than embedding a QR code", () => { + const output = formatIssuedPairingCredential( + { + id: "pairing-1", + credential: "secret-pairing-token", + scopes: ["orchestration:read"], + subject: "one-time-token", + createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"), + expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"), + }, + { baseUrl: "https://example.com", json: true }, + ); + + expect(output).not.toContain("█"); + expect(JSON.parse(output)).toMatchObject({ + pairUrl: "https://example.com/pair#token=secret-pairing-token", + }); }); it("formats pairing listings without exposing the secret token", () => { diff --git a/apps/server/src/cliAuthFormat.ts b/apps/server/src/cliAuthFormat.ts index 2ef5ba10a80..51e1e6154e5 100644 --- a/apps/server/src/cliAuthFormat.ts +++ b/apps/server/src/cliAuthFormat.ts @@ -2,6 +2,7 @@ import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3 import * as DateTime from "effect/DateTime"; import type { IssuedBearerSession, IssuedPairingLink } from "./auth/EnvironmentAuth.ts"; +import { renderTerminalQrCode } from "./startupAccess.ts"; const newline = "\n"; @@ -62,6 +63,10 @@ export function formatIssuedPairingCredential( `Token: ${credential.credential}`, ...(pairUrl ? [`Pair URL: ${pairUrl}`] : []), `Expires at: ${credential.expiresAt}`, + // Match `t3 serve`, which prints a scannable code alongside its startup + // pairing URL. Only a URL is worth encoding; a bare token is not + // actionable on the device that scans it. + ...(pairUrl ? ["", renderTerminalQrCode(pairUrl)] : []), ].join(newline) + newline ); } diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index c8f039e6428..b5b2874f276 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -1,6 +1,21 @@ -import type { DesktopWslState } from "@t3tools/contracts"; +import { + BearerConnectionProfile, + BearerConnectionTarget, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionProfile, + SshConnectionTarget, + type ConnectionCatalogEntry, +} from "@t3tools/client-runtime/connection"; +import { EnvironmentId, type DesktopWslState } from "@t3tools/contracts"; +import * as Option from "effect/Option"; import { describe, expect, it, vi } from "vite-plus/test"; -import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; +import { + applyWslEnableSelection, + environmentPairingBaseUrl, + resolveAccessEnvironment, + resolveShareablePairingUrl, +} from "./ConnectionsSettings.logic"; const baseWslState: DesktopWslState = { enabled: false, @@ -73,3 +88,232 @@ describe("applyWslEnableSelection", () => { expect(state).toMatchObject({ enabled: true, wslOnly: true }); }); }); + +const primaryId = EnvironmentId.make("primary-env"); +const savedId = EnvironmentId.make("saved-env"); + +describe("resolveAccessEnvironment", () => { + it("administers the managed backend when one exists, even while viewing a saved environment", () => { + expect( + resolveAccessEnvironment({ + primaryEnvironmentId: primaryId, + activeEnvironmentId: savedId, + }), + ).toEqual({ environmentId: primaryId, isPrimary: true }); + }); + + it("administers the selected environment when there is no managed backend", () => { + expect( + resolveAccessEnvironment({ + primaryEnvironmentId: null, + activeEnvironmentId: savedId, + }), + ).toEqual({ environmentId: savedId, isPrimary: false }); + }); + + it("has nothing to administer when no environment is selected either", () => { + expect( + resolveAccessEnvironment({ primaryEnvironmentId: null, activeEnvironmentId: null }), + ).toEqual({ environmentId: null, isPrimary: false }); + }); +}); + +describe("environmentPairingBaseUrl", () => { + const entry = ( + target: ConnectionCatalogEntry["target"], + profile?: unknown, + ): ConnectionCatalogEntry => ({ + target, + profile: (profile === undefined + ? Option.none() + : Option.some(profile)) as ConnectionCatalogEntry["profile"], + }); + + it("uses a bearer environment's own base URL", () => { + expect( + environmentPairingBaseUrl( + entry( + new BearerConnectionTarget({ + environmentId: savedId, + label: "headless", + connectionId: "bearer:saved-env", + }), + new BearerConnectionProfile({ + connectionId: "bearer:saved-env", + environmentId: savedId, + label: "headless", + httpBaseUrl: "https://box.tail.ts.net/", + wsBaseUrl: "wss://box.tail.ts.net/", + }), + ), + ), + ).toBe("https://box.tail.ts.net/"); + }); + + it("uses the primary's base URL", () => { + expect( + environmentPairingBaseUrl( + entry( + new PrimaryConnectionTarget({ + environmentId: primaryId, + label: "local", + httpBaseUrl: "http://127.0.0.1:3773/", + wsBaseUrl: "ws://127.0.0.1:3773/", + }), + ), + ), + ).toBe("http://127.0.0.1:3773/"); + }); + + it("has no shareable URL for a bearer environment whose profile is missing", () => { + expect( + environmentPairingBaseUrl( + entry( + new BearerConnectionTarget({ + environmentId: savedId, + label: "headless", + connectionId: "bearer:saved-env", + }), + ), + ), + ).toBeNull(); + }); + + it("has no shareable URL for an SSH tunnel, whose local address is meaningless elsewhere", () => { + expect( + environmentPairingBaseUrl( + entry( + new SshConnectionTarget({ + environmentId: savedId, + label: "box", + connectionId: "ssh:box", + }), + new SshConnectionProfile({ + connectionId: "ssh:box", + environmentId: savedId, + label: "box", + target: { alias: "box", hostname: "box", username: "ivan", port: 22 }, + }), + ), + ), + ).toBeNull(); + }); + + it("has no shareable URL for a relay environment", () => { + expect( + environmentPairingBaseUrl( + entry(new RelayConnectionTarget({ environmentId: savedId, label: "cloud" })), + ), + ).toBeNull(); + }); +}); + +describe("resolveShareablePairingUrl", () => { + const currentOriginPairingUrl = "https://client.example/pair?token=secret"; + + it("prefers the selected advertised endpoint", () => { + expect( + resolveShareablePairingUrl({ + endpointPairingUrl: "https://box.tail.ts.net/pair?token=secret", + basePairingUrl: "http://192.168.1.5:3773/pair?token=secret", + currentOriginPairingUrl, + servesCurrentOrigin: true, + }), + ).toBe("https://box.tail.ts.net/pair?token=secret"); + }); + + it("falls back to the administered server's own base URL", () => { + expect( + resolveShareablePairingUrl({ + endpointPairingUrl: null, + basePairingUrl: "http://192.168.1.5:3773/pair?token=secret", + currentOriginPairingUrl, + servesCurrentOrigin: false, + }), + ).toBe("http://192.168.1.5:3773/pair?token=secret"); + }); + + it("uses this page's origin only for the server that serves this page", () => { + expect( + resolveShareablePairingUrl({ + endpointPairingUrl: null, + basePairingUrl: null, + currentOriginPairingUrl, + servesCurrentOrigin: true, + }), + ).toBe(currentOriginPairingUrl); + }); + + it("shows the bare code for another server with no address, not a link to this client", () => { + // A relay or SSH environment, or a bearer environment whose profile is + // missing: an origin-relative link would pair the scanning device to this + // client app instead of the server the link belongs to. + expect( + resolveShareablePairingUrl({ + endpointPairingUrl: null, + basePairingUrl: null, + currentOriginPairingUrl, + servesCurrentOrigin: false, + }), + ).toBeNull(); + }); + + it("shows the bare code when this page's origin is loopback", () => { + expect( + resolveShareablePairingUrl({ + endpointPairingUrl: null, + basePairingUrl: null, + currentOriginPairingUrl: "http://localhost:3773/pair?token=secret", + servesCurrentOrigin: true, + }), + ).toBeNull(); + }); + + it.each([ + "http://localhost:3773/pair?token=secret", + "http://127.0.0.1:3773/pair?token=secret", + "http://[::1]:3773/pair?token=secret", + ])("skips the loopback advertised endpoint %s", (endpointPairingUrl) => { + expect( + resolveShareablePairingUrl({ + endpointPairingUrl, + basePairingUrl: "http://192.168.1.5:3773/pair?token=secret", + currentOriginPairingUrl, + servesCurrentOrigin: true, + }), + ).toBe("http://192.168.1.5:3773/pair?token=secret"); + }); + + it("skips a loopback base URL and falls back to a shareable current origin", () => { + expect( + resolveShareablePairingUrl({ + endpointPairingUrl: null, + basePairingUrl: "http://127.0.0.1:3773/pair?token=secret", + currentOriginPairingUrl, + servesCurrentOrigin: true, + }), + ).toBe(currentOriginPairingUrl); + }); + + it("shows the bare code when another server only has a loopback base URL", () => { + expect( + resolveShareablePairingUrl({ + endpointPairingUrl: null, + basePairingUrl: "http://127.0.0.1:3773/pair?token=secret", + currentOriginPairingUrl, + servesCurrentOrigin: false, + }), + ).toBeNull(); + }); + + it("skips a hosted app link whose wrapped backend URL is loopback", () => { + expect( + resolveShareablePairingUrl({ + endpointPairingUrl: "https://t3.chat/pair?host=https%3A%2F%2Flocalhost%3A3773&token=secret", + basePairingUrl: null, + currentOriginPairingUrl, + servesCurrentOrigin: false, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index 362a24dd3ff..738280ba1e0 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -1,4 +1,93 @@ -import type { DesktopBridge, DesktopWslState } from "@t3tools/contracts"; +import type { ConnectionCatalogEntry } from "@t3tools/client-runtime/connection"; +import type { DesktopBridge, DesktopWslState, EnvironmentId } from "@t3tools/contracts"; +import { isLoopbackHost } from "@t3tools/shared/preview"; +import * as Option from "effect/Option"; + +/** + * Pick the server whose pairing links and client sessions the Connections page + * administers. + * + * A desktop that manages its own backend keeps administering that backend, so + * managed mode is unchanged. A client-only desktop — or a browser pointed at a + * saved server — has no primary, and administers the selected environment + * instead. Access management was previously reachable only through the primary, + * which left those clients with no way to mint a pairing link at all. + */ +export function resolveAccessEnvironment(input: { + readonly primaryEnvironmentId: EnvironmentId | null; + readonly activeEnvironmentId: EnvironmentId | null; +}): { readonly environmentId: EnvironmentId | null; readonly isPrimary: boolean } { + const environmentId = input.primaryEnvironmentId ?? input.activeEnvironmentId; + return { + environmentId, + isPrimary: environmentId !== null && environmentId === input.primaryEnvironmentId, + }; +} + +/** + * The address to build a shareable pairing URL from for an environment the + * desktop does not manage. Only a directly addressable HTTP origin is usable: + * 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 instead of an unusable link. + */ +export function environmentPairingBaseUrl(entry: ConnectionCatalogEntry): string | null { + switch (entry.target._tag) { + case "PrimaryConnectionTarget": + return entry.target.httpBaseUrl; + case "BearerConnectionTarget": + return Option.isSome(entry.profile) && entry.profile.value._tag === "BearerConnectionProfile" + ? entry.profile.value.httpBaseUrl + : null; + case "RelayConnectionTarget": + case "SshConnectionTarget": + return null; + } +} + +function isLoopbackPairingUrl(value: string): boolean { + const pairingUrl = new URL(value); + if (isLoopbackHost(pairingUrl.hostname)) return true; + + // Hosted app links wrap the administered server's address in `host`, so the + // 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); +} + +/** + * Pick the pairing URL to show for a link, from the addresses that reach the + * server being administered. + * + * The page's own origin is only one of those addresses when this page is served + * by the administered server — the managed-backend case. For any other + * environment (a saved server reached over the network, a relay, or an SSH + * tunnel) an origin-relative link would pair the scanning device to this client + * app instead of the server the link belongs to, so there is no shareable URL + * and the caller falls back to showing the bare pairing code. Any loopback + * candidate is likewise unshareable: it resolves to the scanning device, not + * to us. + */ +export function resolveShareablePairingUrl(input: { + /** Pairing URL for the advertised endpoint the user picked, when there is one. */ + readonly endpointPairingUrl: string | null; + /** Pairing URL built from the administered server's own base URL. */ + readonly basePairingUrl: string | null; + /** Pairing URL on this page's origin. */ + readonly currentOriginPairingUrl: string; + /** Whether the administered server is the one serving this page. */ + readonly servesCurrentOrigin: boolean; +}): string | null { + const candidates = [ + input.endpointPairingUrl, + input.basePairingUrl, + input.servesCurrentOrigin ? input.currentOriginPairingUrl : null, + ]; + return ( + candidates.find((candidate) => candidate !== null && !isLoopbackPairingUrl(candidate)) ?? null + ); +} type WslEnableBridge = Pick; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 52b89530127..60004966be8 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -41,7 +41,12 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { cn } from "../../lib/utils"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; -import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; +import { + applyWslEnableSelection, + environmentPairingBaseUrl, + resolveAccessEnvironment, + resolveShareablePairingUrl, +} from "./ConnectionsSettings.logic"; import { SettingsPageContainer, SettingsRow, @@ -99,7 +104,6 @@ import { revokeOtherServerClientSessions, revokeServerClientSession, revokeServerPairingLink, - isLoopbackHostname, usePrimarySessionState, type ServerClientSessionRecord, type ServerPairingLinkRecord, @@ -113,6 +117,7 @@ import { import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; import { authEnvironment } from "~/state/auth"; +import { useActiveEnvironmentId } from "~/state/entities"; import { environmentCatalog } from "~/connection/catalog"; import { connectPairing as connectPairingAtom, @@ -508,6 +513,8 @@ type PairingLinkListRowProps = { endpointUrl: string | null | undefined; endpoints: ReadonlyArray; defaultEndpointKey: string | null; + // Whether the server these links belong to is the one serving this page. + servesCurrentOrigin: boolean; presentation?: AccessSectionPresentation; revokingPairingLinkId: string | null; onRevoke: (id: string) => void; @@ -518,6 +525,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ endpointUrl, endpoints, defaultEndpointKey, + servesCurrentOrigin, presentation = "current", revokingPairingLinkId, onRevoke, @@ -533,10 +541,11 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ () => resolveCurrentOriginPairingUrl(pairingLink.credential), [pairingLink.credential], ); - const hostedPairingUrl = useMemo( + const basePairingUrl = useMemo( () => endpointUrl != null && endpointUrl !== "" - ? resolveHostedPairingUrl(endpointUrl, pairingLink.credential) + ? (resolveHostedPairingUrl(endpointUrl, pairingLink.credential) ?? + resolveDesktopPairingUrl(endpointUrl, pairingLink.credential)) : null, [endpointUrl, pairingLink.credential], ); @@ -565,13 +574,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ } return options; }, [endpoints, pairingLink.credential]); - const shareablePairingUrl = - endpointPairingUrl ?? - (endpointUrl != null && endpointUrl !== "" - ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential)) - : isLoopbackHostname(window.location.hostname) - ? null - : currentOriginPairingUrl); + const shareablePairingUrl = resolveShareablePairingUrl({ + endpointPairingUrl, + basePairingUrl, + currentOriginPairingUrl, + servesCurrentOrigin, + }); const revealValue = shareablePairingUrl ?? pairingLink.credential; const isShareableHostedAppPairingUrl = shareablePairingUrl !== null && isHostedAppPairingUrl(shareablePairingUrl); @@ -972,12 +980,19 @@ type AuthorizedClientsHeaderActionProps = { clientSessions: ReadonlyArray; isRevokingOtherClients: boolean; onRevokeOtherClients: () => void; + // Supplied by the parent so the link is minted on whichever environment the + // page is administering, rather than always on the primary. + onCreatePairingLink: (input: { + readonly label: string; + readonly scopes: ReadonlyArray; + }) => Promise; }; const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderAction({ clientSessions, isRevokingOtherClients, onRevokeOtherClients, + onCreatePairingLink, }: AuthorizedClientsHeaderActionProps) { const [dialogOpen, setDialogOpen] = useState(false); const [pairingLabel, setPairingLabel] = useState(""); @@ -989,7 +1004,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio const handleCreatePairingLink = useCallback(async () => { setIsCreatingPairingLink(true); try { - await createServerPairingCredential({ label: pairingLabel, scopes: pairingScopes }); + await onCreatePairingLink({ label: pairingLabel, scopes: pairingScopes }); setPairingLabel(""); setPairingScopes([...AuthStandardClientScopes]); setDialogOpen(false); @@ -1005,7 +1020,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio } finally { setIsCreatingPairingLink(false); } - }, [pairingLabel, pairingScopes]); + }, [onCreatePairingLink, pairingLabel, pairingScopes]); const togglePairingScope = useCallback((scope: AuthEnvironmentScope, checked: boolean) => { setPairingScopes((current) => @@ -1146,6 +1161,7 @@ type PairingClientsListProps = { endpointUrl: string | null | undefined; endpoints: ReadonlyArray; defaultEndpointKey: string | null; + servesCurrentOrigin: boolean; presentation?: AccessSectionPresentation; isLoading: boolean; pairingLinks: ReadonlyArray; @@ -1160,6 +1176,7 @@ const PairingClientsList = memo(function PairingClientsList({ endpointUrl, endpoints, defaultEndpointKey, + servesCurrentOrigin, presentation = "current", isLoading, pairingLinks, @@ -1178,6 +1195,7 @@ const PairingClientsList = memo(function PairingClientsList({ endpointUrl={endpointUrl} endpoints={endpoints} defaultEndpointKey={defaultEndpointKey} + servesCurrentOrigin={servesCurrentOrigin} presentation={presentation} revokingPairingLinkId={revokingPairingLinkId} onRevoke={onRevokePairingLink} @@ -1713,12 +1731,26 @@ export function ConnectionsSettings() { const desktopBridge = window.desktopBridge; const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); + const activeEnvironmentId = useActiveEnvironmentId(); const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, { reportFailure: false, }); const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false }); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); + const createEnvironmentPairingLink = useAtomCommand(authEnvironment.createPairingCredential, { + reportFailure: false, + }); + const revokeEnvironmentPairingLink = useAtomCommand(authEnvironment.revokePairingLink, { + reportFailure: false, + }); + const revokeEnvironmentClientSession = useAtomCommand(authEnvironment.revokeClientSession, { + reportFailure: false, + }); + const revokeOtherEnvironmentClientSessions = useAtomCommand( + authEnvironment.revokeOtherClientSessions, + { reportFailure: false }, + ); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; const primarySessionState = usePrimarySessionState(); const currentSessionScopes = desktopBridge @@ -1843,10 +1875,41 @@ export function ConnectionsSettings() { ); const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false; const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false; + + const { environmentId: accessEnvironmentId, isPrimary: isAccessEnvironmentPrimary } = + resolveAccessEnvironment({ primaryEnvironmentId, activeEnvironmentId }); + const accessEnvironment = + accessEnvironmentId === null + ? null + : (environments.find( + (environment) => environment.entry.target.environmentId === accessEnvironmentId, + ) ?? null); + // Scopes come from the session on the server being administered. For a + // non-primary environment that is the credential this client paired with, + // which is administrative when it was bootstrapped from the server's own + // startup token and standard-only when it came from an ordinary pairing link. + const accessEnvironmentSession = useEnvironmentQuery( + !isAccessEnvironmentPrimary && accessEnvironmentId !== null + ? authEnvironment.sessionState({ environmentId: accessEnvironmentId, input: null }) + : null, + ); + const accessSessionScopes = isAccessEnvironmentPrimary + ? currentSessionScopes + : accessEnvironmentSession.data?.authenticated + ? (accessEnvironmentSession.data.scopes ?? null) + : null; + const canManageEnvironmentAccess = accessSessionScopes?.includes(AuthAccessWriteScope) ?? false; + // An environment the desktop does not manage has no advertised-endpoint list. + // Its own base URL is the address this client reaches it on, and it needs no + // reachability gate: being connected through it is the proof. + const accessEnvironmentPairingBaseUrl = + isAccessEnvironmentPrimary || accessEnvironment === null + ? null + : environmentPairingBaseUrl(accessEnvironment.entry); const authAccessChanges = useEnvironmentQuery( - canManageLocalBackend && primaryEnvironmentId !== null + canManageEnvironmentAccess && accessEnvironmentId !== null ? authEnvironment.accessChanges({ - environmentId: primaryEnvironmentId, + environmentId: accessEnvironmentId, input: null, }) : null, @@ -2036,32 +2099,78 @@ export function ConnectionsSettings() { setDisableTailscaleServeDialogOpen(true); }, []); - const handleRevokeDesktopPairingLink = useCallback(async (id: string) => { - setRevokingDesktopPairingLinkId(id); - setDesktopAccessManagementMutationError(null); - try { - await revokeServerPairingLink(id); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to revoke pairing link."; - setDesktopAccessManagementMutationError(message); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not revoke pairing link", - description: message, - }), - ); - } finally { - setRevokingDesktopPairingLinkId(null); - } - }, []); + const handleCreateAccessPairingLink = useCallback( + async (input: { + readonly label: string; + readonly scopes: ReadonlyArray; + }) => { + if (isAccessEnvironmentPrimary || accessEnvironmentId === null) { + await createServerPairingCredential(input); + return; + } + const result = await createEnvironmentPairingLink({ + environmentId: accessEnvironmentId, + input, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + throw squashAtomCommandFailure(result); + } + }, + [accessEnvironmentId, createEnvironmentPairingLink, isAccessEnvironmentPrimary], + ); + + const handleRevokeDesktopPairingLink = useCallback( + async (id: string) => { + setRevokingDesktopPairingLinkId(id); + setDesktopAccessManagementMutationError(null); + try { + if (isAccessEnvironmentPrimary || accessEnvironmentId === null) { + await revokeServerPairingLink(id); + } else { + const result = await revokeEnvironmentPairingLink({ + environmentId: accessEnvironmentId, + input: { id }, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + throw squashAtomCommandFailure(result); + } + } + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to revoke pairing link."; + setDesktopAccessManagementMutationError(message); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not revoke pairing link", + description: message, + }), + ); + } finally { + setRevokingDesktopPairingLinkId(null); + } + }, + [accessEnvironmentId, isAccessEnvironmentPrimary, revokeEnvironmentPairingLink], + ); const handleRevokeDesktopClientSession = useCallback( async (sessionId: ServerClientSessionRecord["sessionId"]) => { setRevokingDesktopClientSessionId(sessionId); setDesktopAccessManagementMutationError(null); try { - await revokeServerClientSession(sessionId); + if (isAccessEnvironmentPrimary || accessEnvironmentId === null) { + await revokeServerClientSession(sessionId); + } else { + const result = await revokeEnvironmentClientSession({ + environmentId: accessEnvironmentId, + input: { sessionId }, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + throw squashAtomCommandFailure(result); + } + } } catch (error) { const message = error instanceof Error ? error.message : "Failed to revoke client access."; setDesktopAccessManagementMutationError(message); @@ -2076,14 +2185,27 @@ export function ConnectionsSettings() { setRevokingDesktopClientSessionId(null); } }, - [], + [accessEnvironmentId, isAccessEnvironmentPrimary, revokeEnvironmentClientSession], ); const handleRevokeOtherDesktopClients = useCallback(async () => { setIsRevokingOtherDesktopClients(true); setDesktopAccessManagementMutationError(null); try { - const revokedCount = await revokeOtherServerClientSessions(); + let revokedCount: number; + if (isAccessEnvironmentPrimary || accessEnvironmentId === null) { + revokedCount = await revokeOtherServerClientSessions(); + } else { + const result = await revokeOtherEnvironmentClientSessions({ + environmentId: accessEnvironmentId, + input: null, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + throw squashAtomCommandFailure(result); + } + revokedCount = result.value.revokedCount; + } toastManager.add({ type: "success", title: revokedCount === 1 ? "Revoked 1 other client" : `Revoked ${revokedCount} clients`, @@ -2102,7 +2224,7 @@ export function ConnectionsSettings() { } finally { setIsRevokingOtherDesktopClients(false); } - }, []); + }, [accessEnvironmentId, isAccessEnvironmentPrimary, revokeOtherEnvironmentClientSessions]); const handleAddSavedBackend = useCallback(async () => { if (savedBackendMode === "ssh") { @@ -2891,7 +3013,15 @@ export function ConnectionsSettings() { } /> ); - const renderAuthorizedClients = (presentation: AccessSectionPresentation) => ( + const renderAuthorizedClients = ( + presentation: AccessSectionPresentation, + endpoints: { + readonly endpointUrl: string | null | undefined; + readonly endpoints: ReadonlyArray; + readonly defaultEndpointKey: string | null; + readonly servesCurrentOrigin: boolean; + }, + ) => ( <> {desktopAccessManagementError ? (
@@ -2899,9 +3029,10 @@ export function ConnectionsSettings() {
) : null} ); + const renderAuthorizedClientsSection = (endpoints: { + readonly endpointUrl: string | null | undefined; + readonly endpoints: ReadonlyArray; + readonly defaultEndpointKey: string | null; + readonly servesCurrentOrigin: boolean; + }) => ( + + } + > + + {renderAuthorizedClients("current", endpoints)} + + + ); const renderNetworkAccessRow = () => ( + {/* + * Pairing links and client sessions belong to whichever server this page + * is administering. When that server is not a desktop-managed backend — + * a browser or hosted app pointed at a saved server — it has no "This + * environment" block to live under, so it gets its own section. + */} + {!isAccessEnvironmentPrimary && canManageEnvironmentAccess + ? renderAuthorizedClientsSection({ + endpointUrl: accessEnvironmentPairingBaseUrl, + endpoints: EMPTY_ADVERTISED_ENDPOINTS, + defaultEndpointKey: null, + // This page is not served by the environment being administered, so + // its own origin never stands in for that server's address. + servesCurrentOrigin: false, + }) + : null} + {canManageLocalBackend ? ( <> @@ -3019,26 +3193,16 @@ export function ConnectionsSettings() { )} - {isLocalBackendRemotelyReachable ? ( - - } - > - - {renderAuthorizedClients("current")} - - - ) : null} + {isLocalBackendRemotelyReachable + ? renderAuthorizedClientsSection({ + endpointUrl: desktopServerExposureState?.endpointUrl, + endpoints: visibleDesktopAdvertisedEndpoints, + defaultEndpointKey: defaultDesktopAdvertisedEndpointKey, + // The managed backend is the origin that served this page, so a + // non-loopback current-origin link does reach it. + servesCurrentOrigin: true, + }) + : null} { @@ -3305,7 +3469,14 @@ export function ConnectionsSettings() { - ) : ( + ) : /* + * Only explain the missing scope when there is a backend to be missing + * it on. A client with no primary at all (hosted app, or a client-only + * desktop) administers a saved environment in its own section above, so + * this row would contradict it — and an empty "This environment" + * heading is worse than none. + */ + primaryEnvironmentId !== null ? ( + ) : ( + )} { : String(tokenRequest?.init.body); const tokenParams = new URLSearchParams(tokenBody); expect(tokenParams.get("subject_token")).toBe("pairing-token"); - expect(tokenParams.get("scope")).toBe(AuthStandardClientScopes.join(" ")); + // No requested scope: the server grants exactly what the pairing + // credential carries. Naming a set is rejected rather than clamped when + // it exceeds the grant, and naming the standard set would silently drop + // the administrative scopes on a server's own startup token. + expect(tokenParams.get("scope")).toBeNull(); expect(tokenParams.get("client_label")).toBe("T3 Code Test"); }), ); diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index e76bcd50a2c..2448a322ae0 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -91,10 +91,15 @@ export const preparePairingRegistration = Effect.fn( const descriptor = yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl: target.httpBaseUrl, }).pipe(Effect.mapError(mapRemoteEnvironmentError)); + // Deliberately request no scopes: the server then grants exactly what the + // pairing credential carries. Naming a wider set is rejected outright rather + // than clamped, so asking for administrative scopes would break pairing with + // an ordinary link — while always asking for the standard set silently + // discarded the administrative scopes on a server's own startup token, + // leaving that client unable to manage the access it was handed. const access = yield* bootstrapRemoteBearerSession({ httpBaseUrl: target.httpBaseUrl, credential: target.credential, - scopes: presentation.scopes, clientMetadata: presentation.metadata, }).pipe(Effect.mapError(mapRemoteEnvironmentError)); const connectionId = `bearer:${descriptor.environmentId}`; diff --git a/packages/client-runtime/src/state/auth.ts b/packages/client-runtime/src/state/auth.ts index 074b89627af..d0fed97a12f 100644 --- a/packages/client-runtime/src/state/auth.ts +++ b/packages/client-runtime/src/state/auth.ts @@ -2,14 +2,30 @@ import type { AuthAccessSnapshot, AuthAccessStreamEvent, AuthAccessStreamSnapshotEvent, + AuthEnvironmentScope, + AuthSessionId, } from "@t3tools/contracts"; import { WS_METHODS } from "@t3tools/contracts"; import * as Stream from "effect/Stream"; +import type { HttpClient } from "effect/unstable/http"; import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { subscribe } from "../rpc/client.ts"; -import { createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; +export { EnvironmentNotConnectedError } from "./authHttp.ts"; + +import { + createEnvironmentPairingCredential, + fetchEnvironmentSessionState, + revokeEnvironmentClientSession, + revokeEnvironmentPairingLink, + revokeOtherEnvironmentClientSessions, +} from "./authHttp.ts"; +import { + createEnvironmentCommand, + createEnvironmentQueryAtomFamily, + createEnvironmentSubscriptionAtomFamily, +} from "./runtime.ts"; export const EMPTY_AUTH_ACCESS_SNAPSHOT: AuthAccessSnapshot = { pairingLinks: [], @@ -76,7 +92,7 @@ export function projectAuthAccessSnapshot( } export function createAuthEnvironmentAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, ) { return { accessChanges: createEnvironmentSubscriptionAtomFamily(runtime, { @@ -86,5 +102,32 @@ export function createAuthEnvironmentAtoms( Stream.mapAccum(() => EMPTY_AUTH_ACCESS_SNAPSHOT, projectAuthAccessSnapshot), ), }), + sessionState: createEnvironmentQueryAtomFamily(runtime, { + label: "environment-data:server:auth-session-state", + execute: (_input: null) => fetchEnvironmentSessionState(), + }), + // Access mutations address the environment they run in rather than the + // primary, so a client with no managed backend can still administer a saved + // server it holds an `access:write` credential for. + createPairingCredential: createEnvironmentCommand(runtime, { + label: "environment-command:server:create-pairing-credential", + execute: (input: { + readonly label?: string; + readonly scopes?: ReadonlyArray; + }) => createEnvironmentPairingCredential(input), + }), + revokePairingLink: createEnvironmentCommand(runtime, { + label: "environment-command:server:revoke-pairing-link", + execute: (input: { readonly id: string }) => revokeEnvironmentPairingLink(input), + }), + revokeClientSession: createEnvironmentCommand(runtime, { + label: "environment-command:server:revoke-client-session", + execute: (input: { readonly sessionId: AuthSessionId }) => + revokeEnvironmentClientSession(input), + }), + revokeOtherClientSessions: createEnvironmentCommand(runtime, { + label: "environment-command:server:revoke-other-client-sessions", + execute: (_input: null) => revokeOtherEnvironmentClientSessions(), + }), }; } diff --git a/packages/client-runtime/src/state/authHttp.ts b/packages/client-runtime/src/state/authHttp.ts new file mode 100644 index 00000000000..4c0bffab39d --- /dev/null +++ b/packages/client-runtime/src/state/authHttp.ts @@ -0,0 +1,152 @@ +import type { AuthEnvironmentScope, AuthSessionId } from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import type { HttpMethod } from "effect/unstable/http"; + +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; +import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; + +// Access management is interactive: the user is waiting on a dialog, so fail +// fast rather than leaving a spinner up while a wedged endpoint stalls. +const AUTH_MUTATION_TIMEOUT_MS = 10_000; + +/** + * Raised when an access-management call is attempted against an environment + * that has no live prepared connection. The catalog can hold an environment + * that is reconnecting or unreachable, and those have no endpoint or credential + * to address, so the mutation cannot be attempted at all. + */ +export class EnvironmentNotConnectedError extends Data.TaggedError( + "@t3tools/client-runtime/state/authHttp/EnvironmentNotConnectedError", +)<{ readonly message: string }> {} + +/** + * Resolve the endpoint, credential and authorization headers for an + * authenticated request against the environment this effect is running in. + * + * The access-management endpoints have always existed per-environment; only the + * client bound them to the primary. Routing them through the supervisor's + * prepared connection lets a client with no managed backend of its own manage a + * saved server's pairing links and sessions, using whichever credential that + * connection was established with. + */ +const prepareAuthRequest = Effect.fn("clientRuntime.state.authHttp.prepareAuthRequest")(function* ( + method: HttpMethod.HttpMethod, + pathname: string, +) { + const supervisor = yield* EnvironmentSupervisor; + const prepared = yield* SubscriptionRef.get(supervisor.prepared); + if (Option.isNone(prepared)) { + return yield* new EnvironmentNotConnectedError({ + message: "This environment is not connected, so its access settings cannot be changed.", + }); + } + const { httpAuthorization, httpBaseUrl } = prepared.value; + const requestUrl = environmentEndpointUrl(httpBaseUrl, pathname); + const client = yield* makeEnvironmentHttpApiClient(httpBaseUrl); + // Optional: only relay/DPoP connections need a signer, so bearer and + // same-origin session connections must still work without one. + const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); + const headers = yield* buildEnvironmentAuthHeaders(httpAuthorization, method, requestUrl, signer); + return { client, headers, httpAuthorization, requestUrl }; +}); + +/** + * Read the calling client's own session state for this environment, including + * the scopes it was granted. Access-management UI keys off this rather than the + * primary environment's session, so it reflects what this client may actually do + * on the server it is looking at. + */ +export const fetchEnvironmentSessionState = Effect.fn( + "clientRuntime.state.authHttp.fetchEnvironmentSessionState", +)(function* () { + const { client, headers, httpAuthorization, requestUrl } = yield* prepareAuthRequest( + "GET", + "/api/auth/session", + ); + return yield* executeEnvironmentHttpRequest( + requestUrl, + AUTH_MUTATION_TIMEOUT_MS, + withEnvironmentCredentials(httpAuthorization, client.auth.session({ headers })), + ); +}); + +export const createEnvironmentPairingCredential = Effect.fn( + "clientRuntime.state.authHttp.createEnvironmentPairingCredential", +)(function* (input: { + readonly label?: string; + readonly scopes?: ReadonlyArray; +}) { + const { client, headers, httpAuthorization, requestUrl } = yield* prepareAuthRequest( + "POST", + "/api/auth/pairing-token", + ); + const trimmedLabel = input.label?.trim(); + return yield* executeEnvironmentHttpRequest( + requestUrl, + AUTH_MUTATION_TIMEOUT_MS, + withEnvironmentCredentials( + httpAuthorization, + client.auth.pairingCredential({ + headers, + payload: { + ...(trimmedLabel ? { label: trimmedLabel } : {}), + ...(input.scopes ? { scopes: input.scopes } : {}), + }, + }), + ), + ); +}); + +export const revokeEnvironmentPairingLink = Effect.fn( + "clientRuntime.state.authHttp.revokeEnvironmentPairingLink", +)(function* (input: { readonly id: string }) { + const { client, headers, httpAuthorization, requestUrl } = yield* prepareAuthRequest( + "POST", + "/api/auth/pairing-links/revoke", + ); + return yield* executeEnvironmentHttpRequest( + requestUrl, + AUTH_MUTATION_TIMEOUT_MS, + withEnvironmentCredentials( + httpAuthorization, + client.auth.revokePairingLink({ headers, payload: { id: input.id } }), + ), + ); +}); + +export const revokeEnvironmentClientSession = Effect.fn( + "clientRuntime.state.authHttp.revokeEnvironmentClientSession", +)(function* (input: { readonly sessionId: AuthSessionId }) { + const { client, headers, httpAuthorization, requestUrl } = yield* prepareAuthRequest( + "POST", + "/api/auth/clients/revoke", + ); + return yield* executeEnvironmentHttpRequest( + requestUrl, + AUTH_MUTATION_TIMEOUT_MS, + withEnvironmentCredentials( + httpAuthorization, + client.auth.revokeClient({ headers, payload: { sessionId: input.sessionId } }), + ), + ); +}); + +export const revokeOtherEnvironmentClientSessions = Effect.fn( + "clientRuntime.state.authHttp.revokeOtherEnvironmentClientSessions", +)(function* () { + const { client, headers, httpAuthorization, requestUrl } = yield* prepareAuthRequest( + "POST", + "/api/auth/clients/revoke-others", + ); + return yield* executeEnvironmentHttpRequest( + requestUrl, + AUTH_MUTATION_TIMEOUT_MS, + withEnvironmentCredentials(httpAuthorization, client.auth.revokeOtherClients({ headers })), + ); +}); diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 7bfeb81f5db..dea82f1fe08 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -444,7 +444,7 @@ export function followStreamInEnvironment( ); } -function createEnvironmentQueryAtomFamily( +export function createEnvironmentQueryAtomFamily( runtime: Atom.AtomRuntime, options: EnvironmentQueryAtomOptions, ): (target: {