| author | |
| committer | |
| log | 57b2418d4fbab56290ff4cb861dcd0a7d0f5ef63 |
| tree | 72b2ad9aeecb65ff4b954541137d794be6c55788 |
| parent | 7175edcff4f250b583ebed8877e27e36dc48411e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>15 files changed, 293 insertions(+), 33 deletions(-)
apps/mobile/src/features/usage/UsageRouteScreen.tsx+26-6| ... | @@ -125,6 +125,9 @@ export function UsageRouteScreen() { | ... | @@ -125,6 +125,9 @@ export function UsageRouteScreen() { |
| 125 | ), | 125 | ), |
| 126 | ), | 126 | ), |
| 127 | ]; | 127 | ]; |
| 128 | const canReadDiagnostics = selectedEnvironments.some( | ||
| 129 | (environment) => environment.canReadDiagnostics, | ||
| 130 | ); | ||
| 128 | 131 | ||
| 129 | const days = useMemo( | 132 | const days = useMemo( |
| 130 | () => enumerateDays(window.sinceDay, window.untilDay), | 133 | () => enumerateDays(window.sinceDay, window.untilDay), |
| ... | @@ -272,10 +275,12 @@ export function UsageRouteScreen() { | ... | @@ -272,10 +275,12 @@ export function UsageRouteScreen() { |
| 272 | contentContainerClassName="gap-6 px-5 pt-4" | 275 | contentContainerClassName="gap-6 px-5 pt-4" |
| 273 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} | 276 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} |
| 274 | refreshControl={ | 277 | refreshControl={ |
| 275 | <RefreshControl | 278 | showingLimits || canReadDiagnostics ? ( |
| 276 | refreshing={showingLimits ? limits.refreshing : refreshingUsage} | 279 | <RefreshControl |
| 277 | onRefresh={showingLimits ? () => void limits.refresh() : refreshWindow} | 280 | refreshing={showingLimits ? limits.refreshing : refreshingUsage} |
| 278 | /> | 281 | onRefresh={showingLimits ? () => void limits.refresh() : refreshWindow} |
| 282 | /> | ||
| 283 | ) : undefined | ||
| 279 | } | 284 | } |
| 280 | > | 285 | > |
| 281 | <SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={setTab} role="tab" /> | 286 | <SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={setTab} role="tab" /> |
| ... | @@ -335,6 +340,20 @@ export function UsageRouteScreen() { | ... | @@ -335,6 +340,20 @@ export function UsageRouteScreen() { |
| 335 | ? "Connect an environment to see usage." | 340 | ? "Connect an environment to see usage." |
| 336 | : "Select an environment to see usage."} | 341 | : "Select an environment to see usage."} |
| 337 | </Text> | 342 | </Text> |
| 343 | ) : !canReadDiagnostics ? ( | ||
| 344 | // Each environment explains itself: a denied grant and a failed | ||
| 345 | // access check are different problems. | ||
| 346 | <View className="gap-2 py-16"> | ||
| 347 | {selectedEnvironments.map((environment) => ( | ||
| 348 | <Text | ||
| 349 | key={environment.environmentId} | ||
| 350 | className="text-center text-base text-foreground-muted" | ||
| 351 | > | ||
| 352 | {selectedEnvironments.length > 1 ? `${environment.label}: ` : null} | ||
| 353 | {environment.error} | ||
| 354 | </Text> | ||
| 355 | ))} | ||
| 356 | </View> | ||
| 338 | ) : ( | 357 | ) : ( |
| 339 | <> | 358 | <> |
| 340 | {sourceMessages.map((message) => ( | 359 | {sourceMessages.map((message) => ( |
| ... | @@ -857,10 +876,11 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string { | ... | @@ -857,10 +876,11 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string { |
| 857 | : "clientBehind", | 876 | : "clientBehind", |
| 858 | }); | 877 | }); |
| 859 | } | 878 | } |
| 879 | // The reason matters: a denied grant and a failed scan need different fixes. | ||
| 880 | if (environment.error) | ||
| 881 | return environment.summary ? `${environment.error} Showing saved totals.` : environment.error; | ||
| 860 | if (!environment.isConnected) | 882 | if (!environment.isConnected) |
| 861 | return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…"; | 883 | return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…"; |
| 862 | if (environment.error) | ||
| 863 | return environment.summary ? "Usage unavailable · showing saved totals" : "Usage unavailable"; | ||
| 864 | if (isUsageLoading(environment)) | 884 | if (isUsageLoading(environment)) |
| 865 | return environment.summary ? "Updating usage…" : "Loading usage…"; | 885 | return environment.summary ? "Updating usage…" : "Loading usage…"; |
| 866 | return "Usage up to date"; | 886 | return "Usage up to date"; |
apps/mobile/src/state/usage.ts+31-1| ... | @@ -11,12 +11,14 @@ | ... | @@ -11,12 +11,14 @@ |
| 11 | */ | 11 | */ |
| 12 | import { useAtomValue } from "@effect/atom-react"; | 12 | import { useAtomValue } from "@effect/atom-react"; |
| 13 | import { | 13 | import { |
| 14 | AuthDiagnosticsReadScope, | ||
| 14 | USAGE_CONTRACT_VERSION, | 15 | USAGE_CONTRACT_VERSION, |
| 15 | type EnvironmentId, | 16 | type EnvironmentId, |
| 16 | type UsageSummary, | 17 | type UsageSummary, |
| 17 | type UsageSummaryInput, | 18 | type UsageSummaryInput, |
| 18 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| 19 | import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; | 20 | import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; |
| 21 | import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; | ||
| 20 | import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; | 22 | import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; |
| 21 | import * as Option from "effect/Option"; | 23 | import * as Option from "effect/Option"; |
| 22 | import { AsyncResult, Atom } from "effect/reactivity"; | 24 | import { AsyncResult, Atom } from "effect/reactivity"; |
| ... | @@ -25,12 +27,14 @@ import { useCallback, useMemo } from "react"; | ... | @@ -25,12 +27,14 @@ import { useCallback, useMemo } from "react"; |
| 25 | import { appAtomRegistry } from "./atom-registry"; | 27 | import { appAtomRegistry } from "./atom-registry"; |
| 26 | import { environmentPresentations } from "./presentation"; | 28 | import { environmentPresentations } from "./presentation"; |
| 27 | import { serverEnvironment } from "./server"; | 29 | import { serverEnvironment } from "./server"; |
| 30 | import { environmentSession, readEnvironmentScope } from "./session"; | ||
| 28 | 31 | ||
| 29 | export interface EnvironmentUsageStatus { | 32 | export interface EnvironmentUsageStatus { |
| 30 | readonly environmentId: EnvironmentId; | 33 | readonly environmentId: EnvironmentId; |
| 31 | readonly label: string; | 34 | readonly label: string; |
| 32 | readonly isPending: boolean; | 35 | readonly isPending: boolean; |
| 33 | readonly isConnected: boolean; | 36 | readonly isConnected: boolean; |
| 37 | readonly canReadDiagnostics: boolean; | ||
| 34 | readonly error: string | null; | 38 | readonly error: string | null; |
| 35 | readonly summary: UsageSummary | null; | 39 | readonly summary: UsageSummary | null; |
| 36 | readonly needsCursorKeychainAccess: boolean; | 40 | readonly needsCursorKeychainAccess: boolean; |
| ... | @@ -50,6 +54,23 @@ const usageByWindowAtom = Atom.family((windowKey: string) => | ... | @@ -50,6 +54,23 @@ const usageByWindowAtom = Atom.family((windowKey: string) => |
| 50 | 54 | ||
| 51 | const statuses: EnvironmentUsageStatus[] = []; | 55 | const statuses: EnvironmentUsageStatus[] = []; |
| 52 | for (const [environmentId, presentation] of presentations) { | 56 | for (const [environmentId, presentation] of presentations) { |
| 57 | const sessionResult = get(environmentSession.sessionStateAtom(environmentId)); | ||
| 58 | const access = resolveUsageAccess({ | ||
| 59 | connectionPhase: presentation.connection.phase, | ||
| 60 | session: Option.getOrNull(AsyncResult.value(sessionResult)), | ||
| 61 | hasSessionError: sessionResult._tag === "Failure", | ||
| 62 | }); | ||
| 63 | if (!access.canReadDiagnostics) { | ||
| 64 | statuses.push({ | ||
| 65 | environmentId, | ||
| 66 | label: presentation.entry.target.label, | ||
| 67 | isConnected: presentation.connection.phase === "connected", | ||
| 68 | ...access, | ||
| 69 | summary: null, | ||
| 70 | needsCursorKeychainAccess: false, | ||
| 71 | }); | ||
| 72 | continue; | ||
| 73 | } | ||
| 53 | const result = get(serverEnvironment.usageSummary({ environmentId, input })); | 74 | const result = get(serverEnvironment.usageSummary({ environmentId, input })); |
| 54 | const summary = Option.getOrNull(AsyncResult.value(result)); | 75 | const summary = Option.getOrNull(AsyncResult.value(result)); |
| 55 | statuses.push({ | 76 | statuses.push({ |
| ... | @@ -57,6 +78,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => | ... | @@ -57,6 +78,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => |
| 57 | label: presentation.entry.target.label, | 78 | label: presentation.entry.target.label, |
| 58 | isPending: result.waiting, | 79 | isPending: result.waiting, |
| 59 | isConnected: presentation.connection.phase === "connected", | 80 | isConnected: presentation.connection.phase === "connected", |
| 81 | canReadDiagnostics: true, | ||
| 60 | error: result._tag === "Failure" ? "This environment could not report usage." : null, | 82 | error: result._tag === "Failure" ? "This environment could not report usage." : null, |
| 61 | summary, | 83 | summary, |
| 62 | needsCursorKeychainAccess: needsCursorKeychainAccess( | 84 | needsCursorKeychainAccess: needsCursorKeychainAccess( |
| ... | @@ -123,7 +145,15 @@ export function useUsage( | ... | @@ -123,7 +145,15 @@ export function useUsage( |
| 123 | registry: appAtomRegistry, | 145 | registry: appAtomRegistry, |
| 124 | server: serverEnvironment, | 146 | server: serverEnvironment, |
| 125 | presentations: environmentPresentations, | 147 | presentations: environmentPresentations, |
| 126 | environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId), | 148 | // Only environments this connection may read; the others report a |
| 149 | // permission error instead of a stale or failed rescan. | ||
| 150 | environmentIds: selectedEnvironments | ||
| 151 | .filter( | ||
| 152 | (environment) => | ||
| 153 | environment.canReadDiagnostics && | ||
| 154 | readEnvironmentScope(environment.environmentId, AuthDiagnosticsReadScope), | ||
| 155 | ) | ||
| 156 | .map(({ environmentId }) => environmentId), | ||
| 127 | input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput), | 157 | input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput), |
| 128 | }), | 158 | }), |
| 129 | [selectedEnvironments, windowKey], | 159 | [selectedEnvironments, windowKey], |
apps/server/src/auth/RpcAuthorization.test.ts+14-3| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { | 1 | import { |
| 2 | AuthEnvironmentMaintainScope, | 2 | AuthEnvironmentMaintainScope, |
| 3 | AuthDiagnosticsReadScope, | ||
| 3 | AuthFilesystemReadScope, | 4 | AuthFilesystemReadScope, |
| 4 | AuthProvidersManageScope, | 5 | AuthProvidersManageScope, |
| 5 | AuthSettingsWriteScope, | 6 | AuthSettingsWriteScope, |
| ... | @@ -193,7 +194,17 @@ describe("RPC scope middleware", () => { | ... | @@ -193,7 +194,17 @@ describe("RPC scope middleware", () => { |
| 193 | ), | 194 | ), |
| 194 | ); | 195 | ); |
| 195 | 196 | ||
| 196 | it.effect("checks each RPC's declared scope before its handler runs", () => | 197 | it.effect.each([ |
| 198 | { scopes: [AuthOrchestrationReadScope], missing: AuthEnvironmentMaintainScope }, | ||
| 199 | { | ||
| 200 | scopes: [AuthOrchestrationReadScope, AuthEnvironmentMaintainScope], | ||
| 201 | missing: AuthDiagnosticsReadScope, | ||
| 202 | }, | ||
| 203 | { | ||
| 204 | scopes: [AuthOrchestrationReadScope, AuthDiagnosticsReadScope], | ||
| 205 | missing: AuthEnvironmentMaintainScope, | ||
| 206 | }, | ||
| 207 | ])("rejects telemetry retry without $missing before its handler runs", ({ scopes, missing }) => | ||
| 197 | Effect.gen(function* () { | 208 | Effect.gen(function* () { |
| 198 | const handled: Array<string> = []; | 209 | const handled: Array<string> = []; |
| 199 | const client = yield* RpcTest.makeClient(group).pipe( | 210 | const client = yield* RpcTest.makeClient(group).pipe( |
| ... | @@ -203,7 +214,7 @@ describe("RPC scope middleware", () => { | ... | @@ -203,7 +214,7 @@ describe("RPC scope middleware", () => { |
| 203 | group.toLayerHandler(WS_METHODS.serverRetryResourceTelemetry, () => | 214 | group.toLayerHandler(WS_METHODS.serverRetryResourceTelemetry, () => |
| 204 | Effect.sync(() => handled.push("retry")).pipe(Effect.andThen(Effect.never)), | 215 | Effect.sync(() => handled.push("retry")).pipe(Effect.andThen(Effect.never)), |
| 205 | ), | 216 | ), |
| 206 | RpcAuthorization.layer([AuthOrchestrationReadScope]), | 217 | RpcAuthorization.layer(scopes), |
| 207 | ), | 218 | ), |
| 208 | ), | 219 | ), |
| 209 | ); | 220 | ); |
| ... | @@ -213,7 +224,7 @@ describe("RPC scope middleware", () => { | ... | @@ -213,7 +224,7 @@ describe("RPC scope middleware", () => { |
| 213 | yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip), | 224 | yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip), |
| 214 | ).toMatchObject({ | 225 | ).toMatchObject({ |
| 215 | _tag: "EnvironmentAuthorizationError", | 226 | _tag: "EnvironmentAuthorizationError", |
| 216 | requiredScope: AuthEnvironmentMaintainScope, | 227 | requiredScope: missing, |
| 217 | }); | 228 | }); |
| 218 | expect(handled).toEqual([]); | 229 | expect(handled).toEqual([]); |
| 219 | }).pipe(Effect.scoped), | 230 | }).pipe(Effect.scoped), |
apps/server/src/auth/RpcAuthorization.ts+14-8| ... | @@ -12,6 +12,7 @@ import { | ... | @@ -12,6 +12,7 @@ import { |
| 12 | AuthEnvironmentMaintainScope, | 12 | AuthEnvironmentMaintainScope, |
| 13 | AuthFilesystemReadScope, | 13 | AuthFilesystemReadScope, |
| 14 | AuthFilesystemWriteScope, | 14 | AuthFilesystemWriteScope, |
| 15 | AuthDiagnosticsReadScope, | ||
| 15 | AuthOrchestrationOperateScope, | 16 | AuthOrchestrationOperateScope, |
| 16 | AuthOrchestrationReadScope, | 17 | AuthOrchestrationReadScope, |
| 17 | AuthPreviewOperateScope, | 18 | AuthPreviewOperateScope, |
| ... | @@ -90,14 +91,16 @@ export const RPC_REQUIRED_SCOPES = { | ... | @@ -90,14 +91,16 @@ export const RPC_REQUIRED_SCOPES = { |
| 90 | [WS_METHODS.serverDisableAcpRegistryProvider]: AuthProvidersManageScope, | 91 | [WS_METHODS.serverDisableAcpRegistryProvider]: AuthProvidersManageScope, |
| 91 | [WS_METHODS.serverLogoutAcpRegistry]: AuthProvidersManageScope, | 92 | [WS_METHODS.serverLogoutAcpRegistry]: AuthProvidersManageScope, |
| 92 | [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, | 93 | [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, |
| 93 | [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, | 94 | [WS_METHODS.serverGetTraceDiagnostics]: AuthDiagnosticsReadScope, |
| 94 | [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, | 95 | [WS_METHODS.serverGetProcessDiagnostics]: AuthDiagnosticsReadScope, |
| 96 | // Load-balancing new threads reads host load; that is part of operating | ||
| 97 | // threads, not of inspecting diagnostics. | ||
| 95 | [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope, | 98 | [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope, |
| 96 | [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, | 99 | [WS_METHODS.serverGetProcessResourceHistory]: AuthDiagnosticsReadScope, |
| 97 | [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, | 100 | [WS_METHODS.serverGetResourceTelemetryHistory]: AuthDiagnosticsReadScope, |
| 98 | [WS_METHODS.serverRetryResourceTelemetry]: AuthEnvironmentMaintainScope, | 101 | [WS_METHODS.serverRetryResourceTelemetry]: AuthDiagnosticsReadScope, |
| 99 | [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, | 102 | [WS_METHODS.serverGetUsageSummary]: AuthDiagnosticsReadScope, |
| 100 | [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope, | 103 | [WS_METHODS.serverRefreshUsageRates]: AuthDiagnosticsReadScope, |
| 101 | [WS_METHODS.serverSignalProcess]: AuthEnvironmentMaintainScope, | 104 | [WS_METHODS.serverSignalProcess]: AuthEnvironmentMaintainScope, |
| 102 | [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, | 105 | [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, |
| 103 | [WS_METHODS.serverReportHostPowerState]: AuthEnvironmentMaintainScope, | 106 | [WS_METHODS.serverReportHostPowerState]: AuthEnvironmentMaintainScope, |
| ... | @@ -153,7 +156,7 @@ export const RPC_REQUIRED_SCOPES = { | ... | @@ -153,7 +156,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 153 | [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, | 156 | [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, |
| 154 | [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, | 157 | [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, |
| 155 | [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, | 158 | [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, |
| 156 | [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, | 159 | [WS_METHODS.subscribeResourceTelemetry]: AuthDiagnosticsReadScope, |
| 157 | [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, | 160 | [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, |
| 158 | [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope, | 161 | [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope, |
| 159 | [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, | 162 | [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, |
| ... | @@ -230,6 +233,9 @@ const requiredScopesForRpcCall = ( | ... | @@ -230,6 +233,9 @@ const requiredScopesForRpcCall = ( |
| 230 | method: string, | 233 | method: string, |
| 231 | payload: unknown, | 234 | payload: unknown, |
| 232 | ): ReadonlyArray<AuthEnvironmentScope> => { | 235 | ): ReadonlyArray<AuthEnvironmentScope> => { |
| 236 | if (method === WS_METHODS.serverRetryResourceTelemetry) { | ||
| 237 | return [AuthEnvironmentMaintainScope, AuthDiagnosticsReadScope]; | ||
| 238 | } | ||
| 233 | if (method === WS_METHODS.assetsCreateUrl) { | 239 | if (method === WS_METHODS.assetsCreateUrl) { |
| 234 | const { resource } = Schema.decodeUnknownSync(AssetCreateUrlInput)(payload); | 240 | const { resource } = Schema.decodeUnknownSync(AssetCreateUrlInput)(payload); |
| 235 | return [ | 241 | return [ |
apps/web/src/components/settings/ConnectionsSettings.tsx+11-3| ... | @@ -26,6 +26,7 @@ import { | ... | @@ -26,6 +26,7 @@ import { |
| 26 | AuthSettingsWriteScope, | 26 | AuthSettingsWriteScope, |
| 27 | AuthProvidersManageScope, | 27 | AuthProvidersManageScope, |
| 28 | AuthEnvironmentMaintainScope, | 28 | AuthEnvironmentMaintainScope, |
| 29 | AuthDiagnosticsReadScope, | ||
| 29 | AuthOrchestrationOperateScope, | 30 | AuthOrchestrationOperateScope, |
| 30 | AuthOrchestrationReadScope, | 31 | AuthOrchestrationReadScope, |
| 31 | AuthPreviewOperateScope, | 32 | AuthPreviewOperateScope, |
| ... | @@ -263,6 +264,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ | ... | @@ -263,6 +264,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ |
| 263 | title: "Control previews", | 264 | title: "Control previews", |
| 264 | description: "Open browser previews and host browser automation.", | 265 | description: "Open browser previews and host browser automation.", |
| 265 | }, | 266 | }, |
| 267 | { | ||
| 268 | scope: AuthDiagnosticsReadScope, | ||
| 269 | title: "View diagnostics and usage", | ||
| 270 | description: "Read process diagnostics, resource history, and usage totals.", | ||
| 271 | }, | ||
| 266 | { | 272 | { |
| 267 | scope: AuthTerminalOperateScope, | 273 | scope: AuthTerminalOperateScope, |
| 268 | title: "Use terminals", | 274 | title: "Use terminals", |
| ... | @@ -1230,9 +1236,11 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio | ... | @@ -1230,9 +1236,11 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1230 | disabled={isCreatingPairingLink} | 1236 | disabled={isCreatingPairingLink} |
| 1231 | onClick={() => | 1237 | onClick={() => |
| 1232 | setPairingScopes( | 1238 | setPairingScopes( |
| 1233 | [AuthOrchestrationReadScope, AuthFilesystemReadScope].filter((scope) => | 1239 | [ |
| 1234 | delegatableScopes.includes(scope), | 1240 | AuthOrchestrationReadScope, |
| 1235 | ), | 1241 | AuthFilesystemReadScope, |
| 1242 | AuthDiagnosticsReadScope, | ||
| 1243 | ].filter((scope) => delegatableScopes.includes(scope)), | ||
| 1236 | ) | 1244 | ) |
| 1237 | } | 1245 | } |
| 1238 | > | 1246 | > |
apps/web/src/components/settings/DiagnosticsSettings.tsx+28-4| ... | @@ -1,4 +1,6 @@ | ... | @@ -1,4 +1,6 @@ |
| 1 | import { ProcessSignalActions } from "./ProcessSignalActions"; | 1 | import { ProcessSignalActions } from "./ProcessSignalActions"; |
| 2 | import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; | ||
| 3 | import { environmentSession } from "../../state/session"; | ||
| 2 | import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; | 4 | import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; |
| 3 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; | 5 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; |
| 4 | import { AuthEnvironmentMaintainScope } from "@t3tools/contracts"; | 6 | import { AuthEnvironmentMaintainScope } from "@t3tools/contracts"; |
| ... | @@ -24,7 +26,6 @@ import { useOpenInPreferredEditor } from "../../editorPreferences"; | ... | @@ -24,7 +26,6 @@ import { useOpenInPreferredEditor } from "../../editorPreferences"; |
| 24 | import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; | 26 | import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; |
| 25 | import { useEnvironmentQuery } from "../../state/query"; | 27 | import { useEnvironmentQuery } from "../../state/query"; |
| 26 | import { serverEnvironment } from "../../state/server"; | 28 | import { serverEnvironment } from "../../state/server"; |
| 27 | import { shellEnvironment } from "../../state/shell"; | ||
| 28 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | 29 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 29 | import { Button } from "../ui/button"; | 30 | import { Button } from "../ui/button"; |
| 30 | import { MorphIcon } from "~/components/MorphIcon"; | 31 | import { MorphIcon } from "~/components/MorphIcon"; |
| ... | @@ -719,6 +720,15 @@ export function DiagnosticsSettingsPanel() { | ... | @@ -719,6 +720,15 @@ export function DiagnosticsSettingsPanel() { |
| 719 | const observability = environment?.serverConfig?.observability; | 720 | const observability = environment?.serverConfig?.observability; |
| 720 | const availableEditors = environment?.serverConfig?.availableEditors; | 721 | const availableEditors = environment?.serverConfig?.availableEditors; |
| 721 | const canOpenHostEditor = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); | 722 | const canOpenHostEditor = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); |
| 723 | const session = useEnvironmentQuery( | ||
| 724 | environmentId === null ? null : environmentSession.sessionStateAtom(environmentId), | ||
| 725 | ); | ||
| 726 | const diagnosticsAccess = resolveUsageAccess({ | ||
| 727 | connectionPhase: environment?.connection.phase ?? "available", | ||
| 728 | session: session.data, | ||
| 729 | hasSessionError: session.error !== null, | ||
| 730 | }); | ||
| 731 | const canReadDiagnostics = diagnosticsAccess.canReadDiagnostics; | ||
| 722 | const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { | 732 | const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { |
| 723 | reportFailure: false, | 733 | reportFailure: false, |
| 724 | }); | 734 | }); |
| ... | @@ -728,7 +738,7 @@ export function DiagnosticsSettingsPanel() { | ... | @@ -728,7 +738,7 @@ export function DiagnosticsSettingsPanel() { |
| 728 | RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ?? | 738 | RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ?? |
| 729 | RESOURCE_HISTORY_WINDOWS[1]; | 739 | RESOURCE_HISTORY_WINDOWS[1]; |
| 730 | const { data, error, isPending, refresh } = useEnvironmentQuery( | 740 | const { data, error, isPending, refresh } = useEnvironmentQuery( |
| 731 | environmentId === null | 741 | environmentId === null || !canReadDiagnostics |
| 732 | ? null | 742 | ? null |
| 733 | : serverEnvironment.traceDiagnostics({ environmentId, input: {} }), | 743 | : serverEnvironment.traceDiagnostics({ environmentId, input: {} }), |
| 734 | ); | 744 | ); |
| ... | @@ -738,7 +748,7 @@ export function DiagnosticsSettingsPanel() { | ... | @@ -738,7 +748,7 @@ export function DiagnosticsSettingsPanel() { |
| 738 | isPending: isProcessPending, | 748 | isPending: isProcessPending, |
| 739 | refresh: refreshProcesses, | 749 | refresh: refreshProcesses, |
| 740 | } = useEnvironmentQuery( | 750 | } = useEnvironmentQuery( |
| 741 | environmentId === null | 751 | environmentId === null || !canReadDiagnostics |
| 742 | ? null | 752 | ? null |
| 743 | : serverEnvironment.processDiagnostics({ environmentId, input: {} }), | 753 | : serverEnvironment.processDiagnostics({ environmentId, input: {} }), |
| 744 | ); | 754 | ); |
| ... | @@ -748,7 +758,7 @@ export function DiagnosticsSettingsPanel() { | ... | @@ -748,7 +758,7 @@ export function DiagnosticsSettingsPanel() { |
| 748 | isPending: isResourcePending, | 758 | isPending: isResourcePending, |
| 749 | refresh: refreshResources, | 759 | refresh: refreshResources, |
| 750 | } = useEnvironmentQuery( | 760 | } = useEnvironmentQuery( |
| 751 | environmentId === null | 761 | environmentId === null || !canReadDiagnostics |
| 752 | ? null | 762 | ? null |
| 753 | : serverEnvironment.processResourceHistory({ | 763 | : serverEnvironment.processResourceHistory({ |
| 754 | environmentId, | 764 | environmentId, |
| ... | @@ -907,6 +917,20 @@ export function DiagnosticsSettingsPanel() { | ... | @@ -907,6 +917,20 @@ export function DiagnosticsSettingsPanel() { |
| 907 | ? Option.getOrElse(data.partialFailure, () => false) | 917 | ? Option.getOrElse(data.partialFailure, () => false) |
| 908 | : false; | 918 | : false; |
| 909 | 919 | ||
| 920 | if (!canReadDiagnostics) { | ||
| 921 | return ( | ||
| 922 | <SettingsPageContainer> | ||
| 923 | <p className="text-sm text-muted-foreground"> | ||
| 924 | {environmentId === null | ||
| 925 | ? "Connect an environment to see diagnostics." | ||
| 926 | : diagnosticsAccess.isPending | ||
| 927 | ? "Checking diagnostics access…" | ||
| 928 | : diagnosticsAccess.error} | ||
| 929 | </p> | ||
| 930 | </SettingsPageContainer> | ||
| 931 | ); | ||
| 932 | } | ||
| 933 | |||
| 910 | return ( | 934 | return ( |
| 911 | <SettingsPageContainer width="expanded" className="gap-10"> | 935 | <SettingsPageContainer width="expanded" className="gap-10"> |
| 912 | <ResourceTelemetryDiagnostics environmentId={environmentId} /> | 936 | <ResourceTelemetryDiagnostics environmentId={environmentId} /> |
apps/web/src/components/usage/UsagePage.test.tsx+1| ... | @@ -52,6 +52,7 @@ const environments = [ | ... | @@ -52,6 +52,7 @@ const environments = [ |
| 52 | environmentId: EnvironmentId.make("test-environment"), | 52 | environmentId: EnvironmentId.make("test-environment"), |
| 53 | label: "Test environment", | 53 | label: "Test environment", |
| 54 | isPending: false, | 54 | isPending: false, |
| 55 | canReadDiagnostics: true, | ||
| 55 | error: null, | 56 | error: null, |
| 56 | summary: { | 57 | summary: { |
| 57 | contractVersion: USAGE_CONTRACT_VERSION, | 58 | contractVersion: USAGE_CONTRACT_VERSION, |
apps/web/src/components/usage/UsagePage.tsx+18-3| ... | @@ -167,6 +167,10 @@ export function UsagePage() { | ... | @@ -167,6 +167,10 @@ export function UsagePage() { |
| 167 | reportFailure: false, | 167 | reportFailure: false, |
| 168 | }); | 168 | }); |
| 169 | 169 | ||
| 170 | const canReadDiagnostics = selectedEnvironments.some( | ||
| 171 | (environment) => environment.canReadDiagnostics, | ||
| 172 | ); | ||
| 173 | |||
| 170 | const days = useMemo( | 174 | const days = useMemo( |
| 171 | () => enumerateDays(window.sinceDay, window.untilDay), | 175 | () => enumerateDays(window.sinceDay, window.untilDay), |
| 172 | [window.sinceDay, window.untilDay], | 176 | [window.sinceDay, window.untilDay], |
| ... | @@ -387,7 +391,7 @@ export function UsagePage() { | ... | @@ -387,7 +391,7 @@ export function UsagePage() { |
| 387 | onClick={refreshWindow} | 391 | onClick={refreshWindow} |
| 388 | aria-label={showingLimits ? "Refresh limits" : "Refresh usage"} | 392 | aria-label={showingLimits ? "Refresh limits" : "Refresh usage"} |
| 389 | aria-busy={isRefreshing} | 393 | aria-busy={isRefreshing} |
| 390 | disabled={isRefreshing} | 394 | disabled={isRefreshing || (!showingLimits && !canReadDiagnostics)} |
| 391 | size="icon-sm" | 395 | size="icon-sm" |
| 392 | variant="ghost" | 396 | variant="ghost" |
| 393 | > | 397 | > |
| ... | @@ -450,7 +454,7 @@ export function UsagePage() { | ... | @@ -450,7 +454,7 @@ export function UsagePage() { |
| 450 | onClick={refreshWindow} | 454 | onClick={refreshWindow} |
| 451 | aria-label={showingLimits ? "Refresh limits" : "Refresh usage"} | 455 | aria-label={showingLimits ? "Refresh limits" : "Refresh usage"} |
| 452 | aria-busy={isRefreshing} | 456 | aria-busy={isRefreshing} |
| 453 | disabled={isRefreshing} | 457 | disabled={isRefreshing || (!showingLimits && !canReadDiagnostics)} |
| 454 | size="icon-sm" | 458 | size="icon-sm" |
| 455 | variant="ghost" | 459 | variant="ghost" |
| 456 | > | 460 | > |
| ... | @@ -493,6 +497,15 @@ export function UsagePage() { | ... | @@ -493,6 +497,15 @@ export function UsagePage() { |
| 493 | /> | 497 | /> |
| 494 | ) : isPending ? ( | 498 | ) : isPending ? ( |
| 495 | <UsageSkeleton /> | 499 | <UsageSkeleton /> |
| 500 | ) : !canReadDiagnostics ? ( | ||
| 501 | <div className="space-y-2 py-12 text-center text-sm text-muted-foreground"> | ||
| 502 | {selectedEnvironments.map((environment) => ( | ||
| 503 | <p key={environment.environmentId}> | ||
| 504 | {selectedEnvironments.length > 1 ? `${environment.label}: ` : null} | ||
| 505 | {environment.error} | ||
| 506 | </p> | ||
| 507 | ))} | ||
| 508 | </div> | ||
| 496 | ) : ( | 509 | ) : ( |
| 497 | <> | 510 | <> |
| 498 | {sourceMessages.map((message) => ( | 511 | {sourceMessages.map((message) => ( |
| ... | @@ -1060,7 +1073,9 @@ function UsageCoverageNotice({ | ... | @@ -1060,7 +1073,9 @@ function UsageCoverageNotice({ |
| 1060 | return ( | 1073 | return ( |
| 1061 | <div className="flex flex-col gap-1 border-t border-border px-2 py-2 text-xs text-muted-foreground"> | 1074 | <div className="flex flex-col gap-1 border-t border-border px-2 py-2 text-xs text-muted-foreground"> |
| 1062 | {failed.map((environment) => ( | 1075 | {failed.map((environment) => ( |
| 1063 | <span key={environment.label}>{environment.label} could not report usage.</span> | 1076 | <span key={environment.label}> |
| 1077 | {environment.label}: {environment.error} | ||
| 1078 | </span> | ||
| 1064 | ))} | 1079 | ))} |
| 1065 | {incompatible.map(({ environment, mismatch }) => ( | 1080 | {incompatible.map(({ environment, mismatch }) => ( |
| 1066 | <span key={environment.environmentId}> | 1081 | <span key={environment.environmentId}> |
apps/web/src/lib/resourceTelemetryState.ts+10-4| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { AuthDiagnosticsReadScope } from "@t3tools/contracts"; | ||
| 1 | import { AuthEnvironmentMaintainScope } from "@t3tools/contracts"; | 2 | import { AuthEnvironmentMaintainScope } from "@t3tools/contracts"; |
| 2 | import type { | 3 | import type { |
| 3 | EnvironmentId, | 4 | EnvironmentId, |
| ... | @@ -10,7 +11,7 @@ import { useCallback } from "react"; | ... | @@ -10,7 +11,7 @@ import { useCallback } from "react"; |
| 10 | import { usePrimaryEnvironment } from "../state/environments"; | 11 | import { usePrimaryEnvironment } from "../state/environments"; |
| 11 | import { useEnvironmentQuery } from "../state/query"; | 12 | import { useEnvironmentQuery } from "../state/query"; |
| 12 | import { serverEnvironment } from "../state/server"; | 13 | import { serverEnvironment } from "../state/server"; |
| 13 | import { readEnvironmentScope } from "../state/session"; | 14 | import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; |
| 14 | import { useAtomCommand } from "../state/use-atom-command"; | 15 | import { useAtomCommand } from "../state/use-atom-command"; |
| 15 | 16 | ||
| 16 | export interface ResourceTelemetryState { | 17 | export interface ResourceTelemetryState { |
| ... | @@ -29,8 +30,9 @@ export function useResourceTelemetry( | ... | @@ -29,8 +30,9 @@ export function useResourceTelemetry( |
| 29 | targetEnvironmentId === undefined | 30 | targetEnvironmentId === undefined |
| 30 | ? (primaryEnvironment?.environmentId ?? null) | 31 | ? (primaryEnvironment?.environmentId ?? null) |
| 31 | : targetEnvironmentId; | 32 | : targetEnvironmentId; |
| 33 | const canReadDiagnostics = useEnvironmentScope(environmentId, AuthDiagnosticsReadScope); | ||
| 32 | const query = useEnvironmentQuery( | 34 | const query = useEnvironmentQuery( |
| 33 | environmentId === null | 35 | environmentId === null || !canReadDiagnostics |
| 34 | ? null | 36 | ? null |
| 35 | : serverEnvironment.resourceTelemetry({ environmentId, input: {} }), | 37 | : serverEnvironment.resourceTelemetry({ environmentId, input: {} }), |
| 36 | ); | 38 | ); |
| ... | @@ -41,7 +43,10 @@ export function useResourceTelemetry( | ... | @@ -41,7 +43,10 @@ export function useResourceTelemetry( |
| 41 | if (environmentId === null) { | 43 | if (environmentId === null) { |
| 42 | throw new Error("No environment is selected."); | 44 | throw new Error("No environment is selected."); |
| 43 | } | 45 | } |
| 44 | if (!readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope)) { | 46 | if ( |
| 47 | !readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope) || | ||
| 48 | !readEnvironmentScope(environmentId, AuthDiagnosticsReadScope) | ||
| 49 | ) { | ||
| 45 | throw new Error("This connection cannot restart the resource monitor."); | 50 | throw new Error("This connection cannot restart the resource monitor."); |
| 46 | } | 51 | } |
| 47 | const result = await retryCommand({ environmentId, input: {} }); | 52 | const result = await retryCommand({ environmentId, input: {} }); |
| ... | @@ -63,8 +68,9 @@ export function useResourceTelemetryHistory( | ... | @@ -63,8 +68,9 @@ export function useResourceTelemetryHistory( |
| 63 | targetEnvironmentId === undefined | 68 | targetEnvironmentId === undefined |
| 64 | ? (primaryEnvironment?.environmentId ?? null) | 69 | ? (primaryEnvironment?.environmentId ?? null) |
| 65 | : targetEnvironmentId; | 70 | : targetEnvironmentId; |
| 71 | const canReadDiagnostics = useEnvironmentScope(environmentId, AuthDiagnosticsReadScope); | ||
| 66 | return useEnvironmentQuery( | 72 | return useEnvironmentQuery( |
| 67 | environmentId === null | 73 | environmentId === null || !canReadDiagnostics |
| 68 | ? null | 74 | ? null |
| 69 | : serverEnvironment.resourceTelemetryHistory({ environmentId, input }), | 75 | : serverEnvironment.resourceTelemetryHistory({ environmentId, input }), |
| 70 | ); | 76 | ); |
apps/web/src/state/usage.test.tsx+1| ... | @@ -22,6 +22,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU | ... | @@ -22,6 +22,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU |
| 22 | environmentId: EnvironmentId.make(id), | 22 | environmentId: EnvironmentId.make(id), |
| 23 | label: id, | 23 | label: id, |
| 24 | isPending: cost === null, | 24 | isPending: cost === null, |
| 25 | canReadDiagnostics: true, | ||
| 25 | error: null, | 26 | error: null, |
| 26 | needsCursorKeychainAccess: false, | 27 | needsCursorKeychainAccess: false, |
| 27 | summary: | 28 | summary: |
apps/web/src/state/usage.ts+30-1| ... | @@ -8,6 +8,7 @@ | ... | @@ -8,6 +8,7 @@ |
| 8 | */ | 8 | */ |
| 9 | import { useAtomValue } from "@effect/atom-react"; | 9 | import { useAtomValue } from "@effect/atom-react"; |
| 10 | import { | 10 | import { |
| 11 | AuthDiagnosticsReadScope, | ||
| 11 | USAGE_CONTRACT_VERSION, | 12 | USAGE_CONTRACT_VERSION, |
| 12 | type EnvironmentId, | 13 | type EnvironmentId, |
| 13 | type UsageBucket, | 14 | type UsageBucket, |
| ... | @@ -15,6 +16,7 @@ import { | ... | @@ -15,6 +16,7 @@ import { |
| 15 | type UsageSummaryInput, | 16 | type UsageSummaryInput, |
| 16 | } from "@t3tools/contracts"; | 17 | } from "@t3tools/contracts"; |
| 17 | import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; | 18 | import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; |
| 19 | import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; | ||
| 18 | import * as Option from "effect/Option"; | 20 | import * as Option from "effect/Option"; |
| 19 | import { AsyncResult, Atom } from "effect/reactivity"; | 21 | import { AsyncResult, Atom } from "effect/reactivity"; |
| 20 | import { useCallback, useMemo } from "react"; | 22 | import { useCallback, useMemo } from "react"; |
| ... | @@ -23,11 +25,13 @@ import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/sh | ... | @@ -23,11 +25,13 @@ import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/sh |
| 23 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 25 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 24 | import { environmentPresentations } from "./presentation"; | 26 | import { environmentPresentations } from "./presentation"; |
| 25 | import { serverEnvironment } from "./server"; | 27 | import { serverEnvironment } from "./server"; |
| 28 | import { environmentSession, readEnvironmentScope } from "./session"; | ||
| 26 | 29 | ||
| 27 | export interface EnvironmentUsageStatus { | 30 | export interface EnvironmentUsageStatus { |
| 28 | readonly environmentId: EnvironmentId; | 31 | readonly environmentId: EnvironmentId; |
| 29 | readonly label: string; | 32 | readonly label: string; |
| 30 | readonly isPending: boolean; | 33 | readonly isPending: boolean; |
| 34 | readonly canReadDiagnostics: boolean; | ||
| 31 | readonly error: string | null; | 35 | readonly error: string | null; |
| 32 | readonly summary: UsageSummary | null; | 36 | readonly summary: UsageSummary | null; |
| 33 | readonly needsCursorKeychainAccess: boolean; | 37 | readonly needsCursorKeychainAccess: boolean; |
| ... | @@ -47,12 +51,29 @@ const usageByWindowAtom = Atom.family((windowKey: string) => | ... | @@ -47,12 +51,29 @@ const usageByWindowAtom = Atom.family((windowKey: string) => |
| 47 | 51 | ||
| 48 | const statuses: EnvironmentUsageStatus[] = []; | 52 | const statuses: EnvironmentUsageStatus[] = []; |
| 49 | for (const [environmentId, presentation] of presentations) { | 53 | for (const [environmentId, presentation] of presentations) { |
| 54 | const sessionResult = get(environmentSession.sessionStateAtom(environmentId)); | ||
| 55 | const access = resolveUsageAccess({ | ||
| 56 | connectionPhase: presentation.connection.phase, | ||
| 57 | session: Option.getOrNull(AsyncResult.value(sessionResult)), | ||
| 58 | hasSessionError: sessionResult._tag === "Failure", | ||
| 59 | }); | ||
| 60 | if (!access.canReadDiagnostics) { | ||
| 61 | statuses.push({ | ||
| 62 | environmentId, | ||
| 63 | label: presentation.entry.target.label, | ||
| 64 | ...access, | ||
| 65 | summary: null, | ||
| 66 | needsCursorKeychainAccess: false, | ||
| 67 | }); | ||
| 68 | continue; | ||
| 69 | } | ||
| 50 | const result = get(serverEnvironment.usageSummary({ environmentId, input })); | 70 | const result = get(serverEnvironment.usageSummary({ environmentId, input })); |
| 51 | const summary = Option.getOrNull(AsyncResult.value(result)); | 71 | const summary = Option.getOrNull(AsyncResult.value(result)); |
| 52 | statuses.push({ | 72 | statuses.push({ |
| 53 | environmentId, | 73 | environmentId, |
| 54 | label: presentation.entry.target.label, | 74 | label: presentation.entry.target.label, |
| 55 | isPending: result.waiting, | 75 | isPending: result.waiting, |
| 76 | canReadDiagnostics: true, | ||
| 56 | error: result._tag === "Failure" ? "This environment could not report usage." : null, | 77 | error: result._tag === "Failure" ? "This environment could not report usage." : null, |
| 57 | summary, | 78 | summary, |
| 58 | needsCursorKeychainAccess: needsCursorKeychainAccess( | 79 | needsCursorKeychainAccess: needsCursorKeychainAccess( |
| ... | @@ -148,7 +169,15 @@ export function useUsage( | ... | @@ -148,7 +169,15 @@ export function useUsage( |
| 148 | registry: appAtomRegistry, | 169 | registry: appAtomRegistry, |
| 149 | server: serverEnvironment, | 170 | server: serverEnvironment, |
| 150 | presentations: environmentPresentations, | 171 | presentations: environmentPresentations, |
| 151 | environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId), | 172 | // Only environments this connection may read; the others report a |
| 173 | // permission error instead of a stale or failed rescan. | ||
| 174 | environmentIds: selectedEnvironments | ||
| 175 | .filter( | ||
| 176 | (environment) => | ||
| 177 | environment.canReadDiagnostics && | ||
| 178 | readEnvironmentScope(environment.environmentId, AuthDiagnosticsReadScope), | ||
| 179 | ) | ||
| 180 | .map(({ environmentId }) => environmentId), | ||
| 152 | input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput), | 181 | input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput), |
| 153 | }), | 182 | }), |
| 154 | [selectedEnvironments, windowKey], | 183 | [selectedEnvironments, windowKey], |
packages/client-runtime/package.json+4| ... | @@ -243,6 +243,10 @@ | ... | @@ -243,6 +243,10 @@ |
| 243 | "types": "./src/state/session.ts", | 243 | "types": "./src/state/session.ts", |
| 244 | "default": "./src/state/session.ts" | 244 | "default": "./src/state/session.ts" |
| 245 | }, | 245 | }, |
| 246 | "./state/usage-access": { | ||
| 247 | "types": "./src/state/usageAccess.ts", | ||
| 248 | "default": "./src/state/usageAccess.ts" | ||
| 249 | }, | ||
| 246 | "./state/shared-settings": { | 250 | "./state/shared-settings": { |
| 247 | "types": "./src/state/sharedSettings.ts", | 251 | "types": "./src/state/sharedSettings.ts", |
| 248 | "default": "./src/state/sharedSettings.ts" | 252 | "default": "./src/state/sharedSettings.ts" |
packages/client-runtime/src/state/usageAccess.test.ts created+62| ... | @@ -0,0 +1,62 @@ | ||
| 1 | import { AuthDiagnosticsReadScope, AuthOrchestrationReadScope } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { resolveUsageAccess } from "./usageAccess.ts"; | ||
| 5 | |||
| 6 | describe("resolveUsageAccess", () => { | ||
| 7 | it.each(["available", "offline", "error"] as const)( | ||
| 8 | "reports %s connections without a session instead of keeping totals pending", | ||
| 9 | (connectionPhase) => { | ||
| 10 | expect( | ||
| 11 | resolveUsageAccess({ connectionPhase, session: null, hasSessionError: false }), | ||
| 12 | ).toEqual({ | ||
| 13 | canReadDiagnostics: false, | ||
| 14 | isPending: false, | ||
| 15 | error: "This environment is not connected.", | ||
| 16 | }); | ||
| 17 | }, | ||
| 18 | ); | ||
| 19 | |||
| 20 | it.each(["connecting", "reconnecting", "connected"] as const)( | ||
| 21 | "waits for a session check while %s", | ||
| 22 | (connectionPhase) => { | ||
| 23 | expect( | ||
| 24 | resolveUsageAccess({ connectionPhase, session: null, hasSessionError: false }), | ||
| 25 | ).toEqual({ canReadDiagnostics: false, isPending: true, error: null }); | ||
| 26 | }, | ||
| 27 | ); | ||
| 28 | |||
| 29 | it("preserves a known grant so disconnected usage can retain its cached summary", () => { | ||
| 30 | expect( | ||
| 31 | resolveUsageAccess({ | ||
| 32 | connectionPhase: "offline", | ||
| 33 | session: { authenticated: true, scopes: [AuthDiagnosticsReadScope] }, | ||
| 34 | hasSessionError: false, | ||
| 35 | }), | ||
| 36 | ).toEqual({ canReadDiagnostics: true, isPending: false, error: null }); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("distinguishes a failed check from a resolved grant without diagnostics access", () => { | ||
| 40 | const denied = resolveUsageAccess({ | ||
| 41 | connectionPhase: "connected", | ||
| 42 | session: { authenticated: true, scopes: [AuthOrchestrationReadScope] }, | ||
| 43 | hasSessionError: false, | ||
| 44 | }); | ||
| 45 | const failed = resolveUsageAccess({ | ||
| 46 | connectionPhase: "connected", | ||
| 47 | session: null, | ||
| 48 | hasSessionError: true, | ||
| 49 | }); | ||
| 50 | |||
| 51 | expect(denied).toEqual({ | ||
| 52 | canReadDiagnostics: false, | ||
| 53 | isPending: false, | ||
| 54 | error: "This connection does not have access to diagnostics and usage.", | ||
| 55 | }); | ||
| 56 | expect(failed).toEqual({ | ||
| 57 | canReadDiagnostics: false, | ||
| 58 | isPending: false, | ||
| 59 | error: "Could not check this connection's access to diagnostics and usage.", | ||
| 60 | }); | ||
| 61 | }); | ||
| 62 | }); | ||
packages/client-runtime/src/state/usageAccess.ts created+40| ... | @@ -0,0 +1,40 @@ | ||
| 1 | import { AuthDiagnosticsReadScope, type AuthSessionState } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; | ||
| 4 | |||
| 5 | export function resolveUsageAccess(input: { | ||
| 6 | readonly connectionPhase: EnvironmentConnectionPhase; | ||
| 7 | readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null; | ||
| 8 | readonly hasSessionError: boolean; | ||
| 9 | }) { | ||
| 10 | if (input.hasSessionError) { | ||
| 11 | return { | ||
| 12 | canReadDiagnostics: false, | ||
| 13 | isPending: false, | ||
| 14 | error: "Could not check this connection's access to diagnostics and usage.", | ||
| 15 | }; | ||
| 16 | } | ||
| 17 | if (input.session === null) { | ||
| 18 | // An unprepared offline connection cannot finish its session check. Keep | ||
| 19 | // it out of pending totals until the connection starts another attempt. | ||
| 20 | const isPending = | ||
| 21 | input.connectionPhase === "connected" || | ||
| 22 | input.connectionPhase === "connecting" || | ||
| 23 | input.connectionPhase === "reconnecting"; | ||
| 24 | return { | ||
| 25 | canReadDiagnostics: false, | ||
| 26 | isPending, | ||
| 27 | error: isPending ? null : "This environment is not connected.", | ||
| 28 | }; | ||
| 29 | } | ||
| 30 | const canReadDiagnostics = | ||
| 31 | input.session.authenticated && | ||
| 32 | input.session.scopes?.includes(AuthDiagnosticsReadScope) === true; | ||
| 33 | return { | ||
| 34 | canReadDiagnostics, | ||
| 35 | isPending: false, | ||
| 36 | error: canReadDiagnostics | ||
| 37 | ? null | ||
| 38 | : "This connection does not have access to diagnostics and usage.", | ||
| 39 | }; | ||
| 40 | } | ||
packages/contracts/src/auth.ts+3| ... | @@ -84,6 +84,7 @@ export const AuthSettingsWriteScope = "settings:write" as const; | ... | @@ -84,6 +84,7 @@ export const AuthSettingsWriteScope = "settings:write" as const; |
| 84 | export const AuthProvidersManageScope = "providers:manage" as const; | 84 | export const AuthProvidersManageScope = "providers:manage" as const; |
| 85 | export const AuthEnvironmentMaintainScope = "environment:maintain" as const; | 85 | export const AuthEnvironmentMaintainScope = "environment:maintain" as const; |
| 86 | export const AuthPreviewOperateScope = "preview:operate" as const; | 86 | export const AuthPreviewOperateScope = "preview:operate" as const; |
| 87 | export const AuthDiagnosticsReadScope = "diagnostics:read" as const; | ||
| 87 | export const AuthTerminalOperateScope = "terminal:operate" as const; | 88 | export const AuthTerminalOperateScope = "terminal:operate" as const; |
| 88 | export const AuthSourceControlWriteScope = "source-control:write" as const; | 89 | export const AuthSourceControlWriteScope = "source-control:write" as const; |
| 89 | export const AuthFilesystemReadScope = "filesystem:read" as const; | 90 | export const AuthFilesystemReadScope = "filesystem:read" as const; |
| ... | @@ -101,6 +102,7 @@ export const AuthEnvironmentScope = Schema.Literals([ | ... | @@ -101,6 +102,7 @@ export const AuthEnvironmentScope = Schema.Literals([ |
| 101 | AuthProvidersManageScope, | 102 | AuthProvidersManageScope, |
| 102 | AuthEnvironmentMaintainScope, | 103 | AuthEnvironmentMaintainScope, |
| 103 | AuthPreviewOperateScope, | 104 | AuthPreviewOperateScope, |
| 105 | AuthDiagnosticsReadScope, | ||
| 104 | AuthTerminalOperateScope, | 106 | AuthTerminalOperateScope, |
| 105 | AuthFilesystemReadScope, | 107 | AuthFilesystemReadScope, |
| 106 | AuthFilesystemWriteScope, | 108 | AuthFilesystemWriteScope, |
| ... | @@ -129,6 +131,7 @@ export const AuthStandardClientScopes = [ | ... | @@ -129,6 +131,7 @@ export const AuthStandardClientScopes = [ |
| 129 | AuthProvidersManageScope, | 131 | AuthProvidersManageScope, |
| 130 | AuthEnvironmentMaintainScope, | 132 | AuthEnvironmentMaintainScope, |
| 131 | AuthPreviewOperateScope, | 133 | AuthPreviewOperateScope, |
| 134 | AuthDiagnosticsReadScope, | ||
| 132 | AuthTerminalOperateScope, | 135 | AuthTerminalOperateScope, |
| 133 | AuthSourceControlWriteScope, | 136 | AuthSourceControlWriteScope, |
| 134 | AuthFilesystemReadScope, | 137 | AuthFilesystemReadScope, |