diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 5a8a4b1a15d4..e2022058616b 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -125,6 +125,9 @@ export function UsageRouteScreen() { ), ), ]; + const canReadDiagnostics = selectedEnvironments.some( + (environment) => environment.canReadDiagnostics, + ); const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), @@ -272,10 +275,12 @@ export function UsageRouteScreen() { contentContainerClassName="gap-6 px-5 pt-4" contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} refreshControl={ - void limits.refresh() : refreshWindow} - /> + showingLimits || canReadDiagnostics ? ( + void limits.refresh() : refreshWindow} + /> + ) : undefined } > @@ -335,6 +340,20 @@ export function UsageRouteScreen() { ? "Connect an environment to see usage." : "Select an environment to see usage."} + ) : !canReadDiagnostics ? ( + // Each environment explains itself: a denied grant and a failed + // access check are different problems. + + {selectedEnvironments.map((environment) => ( + + {selectedEnvironments.length > 1 ? `${environment.label}: ` : null} + {environment.error} + + ))} + ) : ( <> {sourceMessages.map((message) => ( @@ -857,10 +876,11 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string { : "clientBehind", }); } + // The reason matters: a denied grant and a failed scan need different fixes. + if (environment.error) + return environment.summary ? `${environment.error} Showing saved totals.` : environment.error; if (!environment.isConnected) return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…"; - if (environment.error) - return environment.summary ? "Usage unavailable · showing saved totals" : "Usage unavailable"; if (isUsageLoading(environment)) return environment.summary ? "Updating usage…" : "Loading usage…"; return "Usage up to date"; diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index a2e7589aadb6..184cc91498cf 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -11,12 +11,14 @@ */ import { useAtomValue } from "@effect/atom-react"; import { + AuthDiagnosticsReadScope, USAGE_CONTRACT_VERSION, type EnvironmentId, type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; +import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -25,12 +27,14 @@ import { useCallback, useMemo } from "react"; import { appAtomRegistry } from "./atom-registry"; import { environmentPresentations } from "./presentation"; import { serverEnvironment } from "./server"; +import { environmentSession, readEnvironmentScope } from "./session"; export interface EnvironmentUsageStatus { readonly environmentId: EnvironmentId; readonly label: string; readonly isPending: boolean; readonly isConnected: boolean; + readonly canReadDiagnostics: boolean; readonly error: string | null; readonly summary: UsageSummary | null; readonly needsCursorKeychainAccess: boolean; @@ -50,6 +54,23 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const statuses: EnvironmentUsageStatus[] = []; for (const [environmentId, presentation] of presentations) { + const sessionResult = get(environmentSession.sessionStateAtom(environmentId)); + const access = resolveUsageAccess({ + connectionPhase: presentation.connection.phase, + session: Option.getOrNull(AsyncResult.value(sessionResult)), + hasSessionError: sessionResult._tag === "Failure", + }); + if (!access.canReadDiagnostics) { + statuses.push({ + environmentId, + label: presentation.entry.target.label, + isConnected: presentation.connection.phase === "connected", + ...access, + summary: null, + needsCursorKeychainAccess: false, + }); + continue; + } const result = get(serverEnvironment.usageSummary({ environmentId, input })); const summary = Option.getOrNull(AsyncResult.value(result)); statuses.push({ @@ -57,6 +78,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => label: presentation.entry.target.label, isPending: result.waiting, isConnected: presentation.connection.phase === "connected", + canReadDiagnostics: true, error: result._tag === "Failure" ? "This environment could not report usage." : null, summary, needsCursorKeychainAccess: needsCursorKeychainAccess( @@ -123,7 +145,15 @@ export function useUsage( registry: appAtomRegistry, server: serverEnvironment, presentations: environmentPresentations, - environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId), + // Only environments this connection may read; the others report a + // permission error instead of a stale or failed rescan. + environmentIds: selectedEnvironments + .filter( + (environment) => + environment.canReadDiagnostics && + readEnvironmentScope(environment.environmentId, AuthDiagnosticsReadScope), + ) + .map(({ environmentId }) => environmentId), input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput), }), [selectedEnvironments, windowKey], diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index be5f02ab3f06..a40489bea611 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -1,5 +1,6 @@ import { AuthEnvironmentMaintainScope, + AuthDiagnosticsReadScope, AuthFilesystemReadScope, AuthProvidersManageScope, AuthSettingsWriteScope, @@ -193,7 +194,17 @@ describe("RPC scope middleware", () => { ), ); - it.effect("checks each RPC's declared scope before its handler runs", () => + it.effect.each([ + { scopes: [AuthOrchestrationReadScope], missing: AuthEnvironmentMaintainScope }, + { + scopes: [AuthOrchestrationReadScope, AuthEnvironmentMaintainScope], + missing: AuthDiagnosticsReadScope, + }, + { + scopes: [AuthOrchestrationReadScope, AuthDiagnosticsReadScope], + missing: AuthEnvironmentMaintainScope, + }, + ])("rejects telemetry retry without $missing before its handler runs", ({ scopes, missing }) => Effect.gen(function* () { const handled: Array = []; const client = yield* RpcTest.makeClient(group).pipe( @@ -203,7 +214,7 @@ describe("RPC scope middleware", () => { group.toLayerHandler(WS_METHODS.serverRetryResourceTelemetry, () => Effect.sync(() => handled.push("retry")).pipe(Effect.andThen(Effect.never)), ), - RpcAuthorization.layer([AuthOrchestrationReadScope]), + RpcAuthorization.layer(scopes), ), ), ); @@ -213,7 +224,7 @@ describe("RPC scope middleware", () => { yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip), ).toMatchObject({ _tag: "EnvironmentAuthorizationError", - requiredScope: AuthEnvironmentMaintainScope, + requiredScope: missing, }); expect(handled).toEqual([]); }).pipe(Effect.scoped), diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 28815d53cfa4..63f282ec9e5a 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -12,6 +12,7 @@ import { AuthEnvironmentMaintainScope, AuthFilesystemReadScope, AuthFilesystemWriteScope, + AuthDiagnosticsReadScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthPreviewOperateScope, @@ -90,14 +91,16 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverDisableAcpRegistryProvider]: AuthProvidersManageScope, [WS_METHODS.serverLogoutAcpRegistry]: AuthProvidersManageScope, [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, - [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, - [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetTraceDiagnostics]: AuthDiagnosticsReadScope, + [WS_METHODS.serverGetProcessDiagnostics]: AuthDiagnosticsReadScope, + // Load-balancing new threads reads host load; that is part of operating + // threads, not of inspecting diagnostics. [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope, - [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, - [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, - [WS_METHODS.serverRetryResourceTelemetry]: AuthEnvironmentMaintainScope, - [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, - [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetProcessResourceHistory]: AuthDiagnosticsReadScope, + [WS_METHODS.serverGetResourceTelemetryHistory]: AuthDiagnosticsReadScope, + [WS_METHODS.serverRetryResourceTelemetry]: AuthDiagnosticsReadScope, + [WS_METHODS.serverGetUsageSummary]: AuthDiagnosticsReadScope, + [WS_METHODS.serverRefreshUsageRates]: AuthDiagnosticsReadScope, [WS_METHODS.serverSignalProcess]: AuthEnvironmentMaintainScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.serverReportHostPowerState]: AuthEnvironmentMaintainScope, @@ -153,7 +156,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, - [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, + [WS_METHODS.subscribeResourceTelemetry]: AuthDiagnosticsReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope, [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, @@ -230,6 +233,9 @@ const requiredScopesForRpcCall = ( method: string, payload: unknown, ): ReadonlyArray => { + if (method === WS_METHODS.serverRetryResourceTelemetry) { + return [AuthEnvironmentMaintainScope, AuthDiagnosticsReadScope]; + } if (method === WS_METHODS.assetsCreateUrl) { const { resource } = Schema.decodeUnknownSync(AssetCreateUrlInput)(payload); return [ diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index e6a5d88942e6..e095e64af56b 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -26,6 +26,7 @@ import { AuthSettingsWriteScope, AuthProvidersManageScope, AuthEnvironmentMaintainScope, + AuthDiagnosticsReadScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthPreviewOperateScope, @@ -263,6 +264,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ title: "Control previews", description: "Open browser previews and host browser automation.", }, + { + scope: AuthDiagnosticsReadScope, + title: "View diagnostics and usage", + description: "Read process diagnostics, resource history, and usage totals.", + }, { scope: AuthTerminalOperateScope, title: "Use terminals", @@ -1230,9 +1236,11 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio disabled={isCreatingPairingLink} onClick={() => setPairingScopes( - [AuthOrchestrationReadScope, AuthFilesystemReadScope].filter((scope) => - delegatableScopes.includes(scope), - ), + [ + AuthOrchestrationReadScope, + AuthFilesystemReadScope, + AuthDiagnosticsReadScope, + ].filter((scope) => delegatableScopes.includes(scope)), ) } > diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 18abfa37e1aa..466deaa13ab9 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -1,4 +1,6 @@ import { ProcessSignalActions } from "./ProcessSignalActions"; +import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; +import { environmentSession } from "../../state/session"; import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { AuthEnvironmentMaintainScope } from "@t3tools/contracts"; @@ -24,7 +26,6 @@ import { useOpenInPreferredEditor } from "../../editorPreferences"; import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; import { useEnvironmentQuery } from "../../state/query"; import { serverEnvironment } from "../../state/server"; -import { shellEnvironment } from "../../state/shell"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { Button } from "../ui/button"; import { MorphIcon } from "~/components/MorphIcon"; @@ -719,6 +720,15 @@ export function DiagnosticsSettingsPanel() { const observability = environment?.serverConfig?.observability; const availableEditors = environment?.serverConfig?.availableEditors; const canOpenHostEditor = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); + const session = useEnvironmentQuery( + environmentId === null ? null : environmentSession.sessionStateAtom(environmentId), + ); + const diagnosticsAccess = resolveUsageAccess({ + connectionPhase: environment?.connection.phase ?? "available", + session: session.data, + hasSessionError: session.error !== null, + }); + const canReadDiagnostics = diagnosticsAccess.canReadDiagnostics; const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { reportFailure: false, }); @@ -728,7 +738,7 @@ export function DiagnosticsSettingsPanel() { RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ?? RESOURCE_HISTORY_WINDOWS[1]; const { data, error, isPending, refresh } = useEnvironmentQuery( - environmentId === null + environmentId === null || !canReadDiagnostics ? null : serverEnvironment.traceDiagnostics({ environmentId, input: {} }), ); @@ -738,7 +748,7 @@ export function DiagnosticsSettingsPanel() { isPending: isProcessPending, refresh: refreshProcesses, } = useEnvironmentQuery( - environmentId === null + environmentId === null || !canReadDiagnostics ? null : serverEnvironment.processDiagnostics({ environmentId, input: {} }), ); @@ -748,7 +758,7 @@ export function DiagnosticsSettingsPanel() { isPending: isResourcePending, refresh: refreshResources, } = useEnvironmentQuery( - environmentId === null + environmentId === null || !canReadDiagnostics ? null : serverEnvironment.processResourceHistory({ environmentId, @@ -907,6 +917,20 @@ export function DiagnosticsSettingsPanel() { ? Option.getOrElse(data.partialFailure, () => false) : false; + if (!canReadDiagnostics) { + return ( + +

+ {environmentId === null + ? "Connect an environment to see diagnostics." + : diagnosticsAccess.isPending + ? "Checking diagnostics access…" + : diagnosticsAccess.error} +

+
+ ); + } + return ( diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 6d8caac6a36d..53093e7ef23f 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -52,6 +52,7 @@ const environments = [ environmentId: EnvironmentId.make("test-environment"), label: "Test environment", isPending: false, + canReadDiagnostics: true, error: null, summary: { contractVersion: USAGE_CONTRACT_VERSION, diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 969ca5f4e907..0a525054915f 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -167,6 +167,10 @@ export function UsagePage() { reportFailure: false, }); + const canReadDiagnostics = selectedEnvironments.some( + (environment) => environment.canReadDiagnostics, + ); + const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), [window.sinceDay, window.untilDay], @@ -387,7 +391,7 @@ export function UsagePage() { onClick={refreshWindow} aria-label={showingLimits ? "Refresh limits" : "Refresh usage"} aria-busy={isRefreshing} - disabled={isRefreshing} + disabled={isRefreshing || (!showingLimits && !canReadDiagnostics)} size="icon-sm" variant="ghost" > @@ -450,7 +454,7 @@ export function UsagePage() { onClick={refreshWindow} aria-label={showingLimits ? "Refresh limits" : "Refresh usage"} aria-busy={isRefreshing} - disabled={isRefreshing} + disabled={isRefreshing || (!showingLimits && !canReadDiagnostics)} size="icon-sm" variant="ghost" > @@ -493,6 +497,15 @@ export function UsagePage() { /> ) : isPending ? ( + ) : !canReadDiagnostics ? ( +
+ {selectedEnvironments.map((environment) => ( +

+ {selectedEnvironments.length > 1 ? `${environment.label}: ` : null} + {environment.error} +

+ ))} +
) : ( <> {sourceMessages.map((message) => ( @@ -1060,7 +1073,9 @@ function UsageCoverageNotice({ return (
{failed.map((environment) => ( - {environment.label} could not report usage. + + {environment.label}: {environment.error} + ))} {incompatible.map(({ environment, mismatch }) => ( diff --git a/apps/web/src/lib/resourceTelemetryState.ts b/apps/web/src/lib/resourceTelemetryState.ts index f6561ecffcb6..26a7dcd13993 100644 --- a/apps/web/src/lib/resourceTelemetryState.ts +++ b/apps/web/src/lib/resourceTelemetryState.ts @@ -1,3 +1,4 @@ +import { AuthDiagnosticsReadScope } from "@t3tools/contracts"; import { AuthEnvironmentMaintainScope } from "@t3tools/contracts"; import type { EnvironmentId, @@ -10,7 +11,7 @@ import { useCallback } from "react"; import { usePrimaryEnvironment } from "../state/environments"; import { useEnvironmentQuery } from "../state/query"; import { serverEnvironment } from "../state/server"; -import { readEnvironmentScope } from "../state/session"; +import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; export interface ResourceTelemetryState { @@ -29,8 +30,9 @@ export function useResourceTelemetry( targetEnvironmentId === undefined ? (primaryEnvironment?.environmentId ?? null) : targetEnvironmentId; + const canReadDiagnostics = useEnvironmentScope(environmentId, AuthDiagnosticsReadScope); const query = useEnvironmentQuery( - environmentId === null + environmentId === null || !canReadDiagnostics ? null : serverEnvironment.resourceTelemetry({ environmentId, input: {} }), ); @@ -41,7 +43,10 @@ export function useResourceTelemetry( if (environmentId === null) { throw new Error("No environment is selected."); } - if (!readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope)) { + if ( + !readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope) || + !readEnvironmentScope(environmentId, AuthDiagnosticsReadScope) + ) { throw new Error("This connection cannot restart the resource monitor."); } const result = await retryCommand({ environmentId, input: {} }); @@ -63,8 +68,9 @@ export function useResourceTelemetryHistory( targetEnvironmentId === undefined ? (primaryEnvironment?.environmentId ?? null) : targetEnvironmentId; + const canReadDiagnostics = useEnvironmentScope(environmentId, AuthDiagnosticsReadScope); return useEnvironmentQuery( - environmentId === null + environmentId === null || !canReadDiagnostics ? null : serverEnvironment.resourceTelemetryHistory({ environmentId, input }), ); diff --git a/apps/web/src/state/usage.test.tsx b/apps/web/src/state/usage.test.tsx index c80b642b76b7..afe7457ea80e 100644 --- a/apps/web/src/state/usage.test.tsx +++ b/apps/web/src/state/usage.test.tsx @@ -22,6 +22,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU environmentId: EnvironmentId.make(id), label: id, isPending: cost === null, + canReadDiagnostics: true, error: null, needsCursorKeychainAccess: false, summary: diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 91999750d407..d489c0d62410 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -8,6 +8,7 @@ */ import { useAtomValue } from "@effect/atom-react"; import { + AuthDiagnosticsReadScope, USAGE_CONTRACT_VERSION, type EnvironmentId, type UsageBucket, @@ -15,6 +16,7 @@ import { type UsageSummaryInput, } from "@t3tools/contracts"; import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; +import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useCallback, useMemo } from "react"; @@ -23,11 +25,13 @@ import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/sh import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentPresentations } from "./presentation"; import { serverEnvironment } from "./server"; +import { environmentSession, readEnvironmentScope } from "./session"; export interface EnvironmentUsageStatus { readonly environmentId: EnvironmentId; readonly label: string; readonly isPending: boolean; + readonly canReadDiagnostics: boolean; readonly error: string | null; readonly summary: UsageSummary | null; readonly needsCursorKeychainAccess: boolean; @@ -47,12 +51,29 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const statuses: EnvironmentUsageStatus[] = []; for (const [environmentId, presentation] of presentations) { + const sessionResult = get(environmentSession.sessionStateAtom(environmentId)); + const access = resolveUsageAccess({ + connectionPhase: presentation.connection.phase, + session: Option.getOrNull(AsyncResult.value(sessionResult)), + hasSessionError: sessionResult._tag === "Failure", + }); + if (!access.canReadDiagnostics) { + statuses.push({ + environmentId, + label: presentation.entry.target.label, + ...access, + summary: null, + needsCursorKeychainAccess: false, + }); + continue; + } const result = get(serverEnvironment.usageSummary({ environmentId, input })); const summary = Option.getOrNull(AsyncResult.value(result)); statuses.push({ environmentId, label: presentation.entry.target.label, isPending: result.waiting, + canReadDiagnostics: true, error: result._tag === "Failure" ? "This environment could not report usage." : null, summary, needsCursorKeychainAccess: needsCursorKeychainAccess( @@ -148,7 +169,15 @@ export function useUsage( registry: appAtomRegistry, server: serverEnvironment, presentations: environmentPresentations, - environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId), + // Only environments this connection may read; the others report a + // permission error instead of a stale or failed rescan. + environmentIds: selectedEnvironments + .filter( + (environment) => + environment.canReadDiagnostics && + readEnvironmentScope(environment.environmentId, AuthDiagnosticsReadScope), + ) + .map(({ environmentId }) => environmentId), input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput), }), [selectedEnvironments, windowKey], diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 5ede41722e60..3b72e948454a 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -243,6 +243,10 @@ "types": "./src/state/session.ts", "default": "./src/state/session.ts" }, + "./state/usage-access": { + "types": "./src/state/usageAccess.ts", + "default": "./src/state/usageAccess.ts" + }, "./state/shared-settings": { "types": "./src/state/sharedSettings.ts", "default": "./src/state/sharedSettings.ts" diff --git a/packages/client-runtime/src/state/usageAccess.test.ts b/packages/client-runtime/src/state/usageAccess.test.ts new file mode 100644 index 000000000000..1280a61fe943 --- /dev/null +++ b/packages/client-runtime/src/state/usageAccess.test.ts @@ -0,0 +1,62 @@ +import { AuthDiagnosticsReadScope, AuthOrchestrationReadScope } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveUsageAccess } from "./usageAccess.ts"; + +describe("resolveUsageAccess", () => { + it.each(["available", "offline", "error"] as const)( + "reports %s connections without a session instead of keeping totals pending", + (connectionPhase) => { + expect( + resolveUsageAccess({ connectionPhase, session: null, hasSessionError: false }), + ).toEqual({ + canReadDiagnostics: false, + isPending: false, + error: "This environment is not connected.", + }); + }, + ); + + it.each(["connecting", "reconnecting", "connected"] as const)( + "waits for a session check while %s", + (connectionPhase) => { + expect( + resolveUsageAccess({ connectionPhase, session: null, hasSessionError: false }), + ).toEqual({ canReadDiagnostics: false, isPending: true, error: null }); + }, + ); + + it("preserves a known grant so disconnected usage can retain its cached summary", () => { + expect( + resolveUsageAccess({ + connectionPhase: "offline", + session: { authenticated: true, scopes: [AuthDiagnosticsReadScope] }, + hasSessionError: false, + }), + ).toEqual({ canReadDiagnostics: true, isPending: false, error: null }); + }); + + it("distinguishes a failed check from a resolved grant without diagnostics access", () => { + const denied = resolveUsageAccess({ + connectionPhase: "connected", + session: { authenticated: true, scopes: [AuthOrchestrationReadScope] }, + hasSessionError: false, + }); + const failed = resolveUsageAccess({ + connectionPhase: "connected", + session: null, + hasSessionError: true, + }); + + expect(denied).toEqual({ + canReadDiagnostics: false, + isPending: false, + error: "This connection does not have access to diagnostics and usage.", + }); + expect(failed).toEqual({ + canReadDiagnostics: false, + isPending: false, + error: "Could not check this connection's access to diagnostics and usage.", + }); + }); +}); diff --git a/packages/client-runtime/src/state/usageAccess.ts b/packages/client-runtime/src/state/usageAccess.ts new file mode 100644 index 000000000000..66ddf4944143 --- /dev/null +++ b/packages/client-runtime/src/state/usageAccess.ts @@ -0,0 +1,40 @@ +import { AuthDiagnosticsReadScope, type AuthSessionState } from "@t3tools/contracts"; + +import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; + +export function resolveUsageAccess(input: { + readonly connectionPhase: EnvironmentConnectionPhase; + readonly session: Pick | null; + readonly hasSessionError: boolean; +}) { + if (input.hasSessionError) { + return { + canReadDiagnostics: false, + isPending: false, + error: "Could not check this connection's access to diagnostics and usage.", + }; + } + if (input.session === null) { + // An unprepared offline connection cannot finish its session check. Keep + // it out of pending totals until the connection starts another attempt. + const isPending = + input.connectionPhase === "connected" || + input.connectionPhase === "connecting" || + input.connectionPhase === "reconnecting"; + return { + canReadDiagnostics: false, + isPending, + error: isPending ? null : "This environment is not connected.", + }; + } + const canReadDiagnostics = + input.session.authenticated && + input.session.scopes?.includes(AuthDiagnosticsReadScope) === true; + return { + canReadDiagnostics, + isPending: false, + error: canReadDiagnostics + ? null + : "This connection does not have access to diagnostics and usage.", + }; +} diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 3f474fc31a24..6ac40ae8241d 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -84,6 +84,7 @@ export const AuthSettingsWriteScope = "settings:write" as const; export const AuthProvidersManageScope = "providers:manage" as const; export const AuthEnvironmentMaintainScope = "environment:maintain" as const; export const AuthPreviewOperateScope = "preview:operate" as const; +export const AuthDiagnosticsReadScope = "diagnostics:read" as const; export const AuthTerminalOperateScope = "terminal:operate" as const; export const AuthSourceControlWriteScope = "source-control:write" as const; export const AuthFilesystemReadScope = "filesystem:read" as const; @@ -101,6 +102,7 @@ export const AuthEnvironmentScope = Schema.Literals([ AuthProvidersManageScope, AuthEnvironmentMaintainScope, AuthPreviewOperateScope, + AuthDiagnosticsReadScope, AuthTerminalOperateScope, AuthFilesystemReadScope, AuthFilesystemWriteScope, @@ -129,6 +131,7 @@ export const AuthStandardClientScopes = [ AuthProvidersManageScope, AuthEnvironmentMaintainScope, AuthPreviewOperateScope, + AuthDiagnosticsReadScope, AuthTerminalOperateScope, AuthSourceControlWriteScope, AuthFilesystemReadScope,