authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 20:30:12-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 20:30:12-07:00
log57b2418d4fbab56290ff4cb861dcd0a7d0f5ef63
tree72b2ad9aeecb65ff4b954541137d794be6c55788
parent7175edcff4f250b583ebed8877e27e36dc48411e
signature Signed by PGP key B5690EEEBB952194

feat(auth): separate diagnostics and usage permissions (#9790)

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() {
125125 ),
126126 ),
127127 ];
128 const canReadDiagnostics = selectedEnvironments.some(
129 (environment) => environment.canReadDiagnostics,
130 );
128131
129132 const days = useMemo(
130133 () => enumerateDays(window.sinceDay, window.untilDay),
......@@ -272,10 +275,12 @@ export function UsageRouteScreen() {
272275 contentContainerClassName="gap-6 px-5 pt-4"
273276 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
274277 refreshControl={
275 <RefreshControl
276 refreshing={showingLimits ? limits.refreshing : refreshingUsage}
277 onRefresh={showingLimits ? () => void limits.refresh() : refreshWindow}
278 />
278 showingLimits || canReadDiagnostics ? (
279 <RefreshControl
280 refreshing={showingLimits ? limits.refreshing : refreshingUsage}
281 onRefresh={showingLimits ? () => void limits.refresh() : refreshWindow}
282 />
283 ) : undefined
279284 }
280285 >
281286 <SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={setTab} role="tab" />
......@@ -335,6 +340,20 @@ export function UsageRouteScreen() {
335340 ? "Connect an environment to see usage."
336341 : "Select an environment to see usage."}
337342 </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>
338357 ) : (
339358 <>
340359 {sourceMessages.map((message) => (
......@@ -857,10 +876,11 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string {
857876 : "clientBehind",
858877 });
859878 }
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;
860882 if (!environment.isConnected)
861883 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";
864884 if (isUsageLoading(environment))
865885 return environment.summary ? "Updating usage…" : "Loading usage…";
866886 return "Usage up to date";
apps/mobile/src/state/usage.ts+31-1
......@@ -11,12 +11,14 @@
1111 */
1212import { useAtomValue } from "@effect/atom-react";
1313import {
14 AuthDiagnosticsReadScope,
1415 USAGE_CONTRACT_VERSION,
1516 type EnvironmentId,
1617 type UsageSummary,
1718 type UsageSummaryInput,
1819} from "@t3tools/contracts";
1920import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage";
21import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access";
2022import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";
2123import * as Option from "effect/Option";
2224import { AsyncResult, Atom } from "effect/reactivity";
......@@ -25,12 +27,14 @@ import { useCallback, useMemo } from "react";
2527import { appAtomRegistry } from "./atom-registry";
2628import { environmentPresentations } from "./presentation";
2729import { serverEnvironment } from "./server";
30import { environmentSession, readEnvironmentScope } from "./session";
2831
2932export interface EnvironmentUsageStatus {
3033 readonly environmentId: EnvironmentId;
3134 readonly label: string;
3235 readonly isPending: boolean;
3336 readonly isConnected: boolean;
37 readonly canReadDiagnostics: boolean;
3438 readonly error: string | null;
3539 readonly summary: UsageSummary | null;
3640 readonly needsCursorKeychainAccess: boolean;
......@@ -50,6 +54,23 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
5054
5155 const statuses: EnvironmentUsageStatus[] = [];
5256 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 }
5374 const result = get(serverEnvironment.usageSummary({ environmentId, input }));
5475 const summary = Option.getOrNull(AsyncResult.value(result));
5576 statuses.push({
......@@ -57,6 +78,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
5778 label: presentation.entry.target.label,
5879 isPending: result.waiting,
5980 isConnected: presentation.connection.phase === "connected",
81 canReadDiagnostics: true,
6082 error: result._tag === "Failure" ? "This environment could not report usage." : null,
6183 summary,
6284 needsCursorKeychainAccess: needsCursorKeychainAccess(
......@@ -123,7 +145,15 @@ export function useUsage(
123145 registry: appAtomRegistry,
124146 server: serverEnvironment,
125147 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),
127157 input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput),
128158 }),
129159 [selectedEnvironments, windowKey],
apps/server/src/auth/RpcAuthorization.test.ts+14-3
......@@ -1,5 +1,6 @@
11import {
22 AuthEnvironmentMaintainScope,
3 AuthDiagnosticsReadScope,
34 AuthFilesystemReadScope,
45 AuthProvidersManageScope,
56 AuthSettingsWriteScope,
......@@ -193,7 +194,17 @@ describe("RPC scope middleware", () => {
193194 ),
194195 );
195196
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 }) =>
197208 Effect.gen(function* () {
198209 const handled: Array<string> = [];
199210 const client = yield* RpcTest.makeClient(group).pipe(
......@@ -203,7 +214,7 @@ describe("RPC scope middleware", () => {
203214 group.toLayerHandler(WS_METHODS.serverRetryResourceTelemetry, () =>
204215 Effect.sync(() => handled.push("retry")).pipe(Effect.andThen(Effect.never)),
205216 ),
206 RpcAuthorization.layer([AuthOrchestrationReadScope]),
217 RpcAuthorization.layer(scopes),
207218 ),
208219 ),
209220 );
......@@ -213,7 +224,7 @@ describe("RPC scope middleware", () => {
213224 yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip),
214225 ).toMatchObject({
215226 _tag: "EnvironmentAuthorizationError",
216 requiredScope: AuthEnvironmentMaintainScope,
227 requiredScope: missing,
217228 });
218229 expect(handled).toEqual([]);
219230 }).pipe(Effect.scoped),
apps/server/src/auth/RpcAuthorization.ts+14-8
......@@ -12,6 +12,7 @@ import {
1212 AuthEnvironmentMaintainScope,
1313 AuthFilesystemReadScope,
1414 AuthFilesystemWriteScope,
15 AuthDiagnosticsReadScope,
1516 AuthOrchestrationOperateScope,
1617 AuthOrchestrationReadScope,
1718 AuthPreviewOperateScope,
......@@ -90,14 +91,16 @@ export const RPC_REQUIRED_SCOPES = {
9091 [WS_METHODS.serverDisableAcpRegistryProvider]: AuthProvidersManageScope,
9192 [WS_METHODS.serverLogoutAcpRegistry]: AuthProvidersManageScope,
9293 [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope,
93 [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope,
94 [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope,
94 [WS_METHODS.serverGetTraceDiagnostics]: AuthDiagnosticsReadScope,
95 [WS_METHODS.serverGetProcessDiagnostics]: AuthDiagnosticsReadScope,
96 // Load-balancing new threads reads host load; that is part of operating
97 // threads, not of inspecting diagnostics.
9598 [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope,
96 [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope,
97 [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope,
98 [WS_METHODS.serverRetryResourceTelemetry]: AuthEnvironmentMaintainScope,
99 [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope,
100 [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope,
99 [WS_METHODS.serverGetProcessResourceHistory]: AuthDiagnosticsReadScope,
100 [WS_METHODS.serverGetResourceTelemetryHistory]: AuthDiagnosticsReadScope,
101 [WS_METHODS.serverRetryResourceTelemetry]: AuthDiagnosticsReadScope,
102 [WS_METHODS.serverGetUsageSummary]: AuthDiagnosticsReadScope,
103 [WS_METHODS.serverRefreshUsageRates]: AuthDiagnosticsReadScope,
101104 [WS_METHODS.serverSignalProcess]: AuthEnvironmentMaintainScope,
102105 [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope,
103106 [WS_METHODS.serverReportHostPowerState]: AuthEnvironmentMaintainScope,
......@@ -153,7 +156,7 @@ export const RPC_REQUIRED_SCOPES = {
153156 [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope,
154157 [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope,
155158 [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope,
156 [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope,
159 [WS_METHODS.subscribeResourceTelemetry]: AuthDiagnosticsReadScope,
157160 [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope,
158161 [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope,
159162 [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,
......@@ -230,6 +233,9 @@ const requiredScopesForRpcCall = (
230233 method: string,
231234 payload: unknown,
232235): ReadonlyArray<AuthEnvironmentScope> => {
236 if (method === WS_METHODS.serverRetryResourceTelemetry) {
237 return [AuthEnvironmentMaintainScope, AuthDiagnosticsReadScope];
238 }
233239 if (method === WS_METHODS.assetsCreateUrl) {
234240 const { resource } = Schema.decodeUnknownSync(AssetCreateUrlInput)(payload);
235241 return [
apps/web/src/components/settings/ConnectionsSettings.tsx+11-3
......@@ -26,6 +26,7 @@ import {
2626 AuthSettingsWriteScope,
2727 AuthProvidersManageScope,
2828 AuthEnvironmentMaintainScope,
29 AuthDiagnosticsReadScope,
2930 AuthOrchestrationOperateScope,
3031 AuthOrchestrationReadScope,
3132 AuthPreviewOperateScope,
......@@ -263,6 +264,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{
263264 title: "Control previews",
264265 description: "Open browser previews and host browser automation.",
265266 },
267 {
268 scope: AuthDiagnosticsReadScope,
269 title: "View diagnostics and usage",
270 description: "Read process diagnostics, resource history, and usage totals.",
271 },
266272 {
267273 scope: AuthTerminalOperateScope,
268274 title: "Use terminals",
......@@ -1230,9 +1236,11 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
12301236 disabled={isCreatingPairingLink}
12311237 onClick={() =>
12321238 setPairingScopes(
1233 [AuthOrchestrationReadScope, AuthFilesystemReadScope].filter((scope) =>
1234 delegatableScopes.includes(scope),
1235 ),
1239 [
1240 AuthOrchestrationReadScope,
1241 AuthFilesystemReadScope,
1242 AuthDiagnosticsReadScope,
1243 ].filter((scope) => delegatableScopes.includes(scope)),
12361244 )
12371245 }
12381246 >
apps/web/src/components/settings/DiagnosticsSettings.tsx+28-4
......@@ -1,4 +1,6 @@
11import { ProcessSignalActions } from "./ProcessSignalActions";
2import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access";
3import { environmentSession } from "../../state/session";
24import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
35import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
46import { AuthEnvironmentMaintainScope } from "@t3tools/contracts";
......@@ -24,7 +26,6 @@ import { useOpenInPreferredEditor } from "../../editorPreferences";
2426import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat";
2527import { useEnvironmentQuery } from "../../state/query";
2628import { serverEnvironment } from "../../state/server";
27import { shellEnvironment } from "../../state/shell";
2829import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
2930import { Button } from "../ui/button";
3031import { MorphIcon } from "~/components/MorphIcon";
......@@ -719,6 +720,15 @@ export function DiagnosticsSettingsPanel() {
719720 const observability = environment?.serverConfig?.observability;
720721 const availableEditors = environment?.serverConfig?.availableEditors;
721722 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;
722732 const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, {
723733 reportFailure: false,
724734 });
......@@ -728,7 +738,7 @@ export function DiagnosticsSettingsPanel() {
728738 RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ??
729739 RESOURCE_HISTORY_WINDOWS[1];
730740 const { data, error, isPending, refresh } = useEnvironmentQuery(
731 environmentId === null
741 environmentId === null || !canReadDiagnostics
732742 ? null
733743 : serverEnvironment.traceDiagnostics({ environmentId, input: {} }),
734744 );
......@@ -738,7 +748,7 @@ export function DiagnosticsSettingsPanel() {
738748 isPending: isProcessPending,
739749 refresh: refreshProcesses,
740750 } = useEnvironmentQuery(
741 environmentId === null
751 environmentId === null || !canReadDiagnostics
742752 ? null
743753 : serverEnvironment.processDiagnostics({ environmentId, input: {} }),
744754 );
......@@ -748,7 +758,7 @@ export function DiagnosticsSettingsPanel() {
748758 isPending: isResourcePending,
749759 refresh: refreshResources,
750760 } = useEnvironmentQuery(
751 environmentId === null
761 environmentId === null || !canReadDiagnostics
752762 ? null
753763 : serverEnvironment.processResourceHistory({
754764 environmentId,
......@@ -907,6 +917,20 @@ export function DiagnosticsSettingsPanel() {
907917 ? Option.getOrElse(data.partialFailure, () => false)
908918 : false;
909919
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
910934 return (
911935 <SettingsPageContainer width="expanded" className="gap-10">
912936 <ResourceTelemetryDiagnostics environmentId={environmentId} />
apps/web/src/components/usage/UsagePage.test.tsx+1
......@@ -52,6 +52,7 @@ const environments = [
5252 environmentId: EnvironmentId.make("test-environment"),
5353 label: "Test environment",
5454 isPending: false,
55 canReadDiagnostics: true,
5556 error: null,
5657 summary: {
5758 contractVersion: USAGE_CONTRACT_VERSION,
apps/web/src/components/usage/UsagePage.tsx+18-3
......@@ -167,6 +167,10 @@ export function UsagePage() {
167167 reportFailure: false,
168168 });
169169
170 const canReadDiagnostics = selectedEnvironments.some(
171 (environment) => environment.canReadDiagnostics,
172 );
173
170174 const days = useMemo(
171175 () => enumerateDays(window.sinceDay, window.untilDay),
172176 [window.sinceDay, window.untilDay],
......@@ -387,7 +391,7 @@ export function UsagePage() {
387391 onClick={refreshWindow}
388392 aria-label={showingLimits ? "Refresh limits" : "Refresh usage"}
389393 aria-busy={isRefreshing}
390 disabled={isRefreshing}
394 disabled={isRefreshing || (!showingLimits && !canReadDiagnostics)}
391395 size="icon-sm"
392396 variant="ghost"
393397 >
......@@ -450,7 +454,7 @@ export function UsagePage() {
450454 onClick={refreshWindow}
451455 aria-label={showingLimits ? "Refresh limits" : "Refresh usage"}
452456 aria-busy={isRefreshing}
453 disabled={isRefreshing}
457 disabled={isRefreshing || (!showingLimits && !canReadDiagnostics)}
454458 size="icon-sm"
455459 variant="ghost"
456460 >
......@@ -493,6 +497,15 @@ export function UsagePage() {
493497 />
494498 ) : isPending ? (
495499 <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>
496509 ) : (
497510 <>
498511 {sourceMessages.map((message) => (
......@@ -1060,7 +1073,9 @@ function UsageCoverageNotice({
10601073 return (
10611074 <div className="flex flex-col gap-1 border-t border-border px-2 py-2 text-xs text-muted-foreground">
10621075 {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>
10641079 ))}
10651080 {incompatible.map(({ environment, mismatch }) => (
10661081 <span key={environment.environmentId}>
apps/web/src/lib/resourceTelemetryState.ts+10-4
......@@ -1,3 +1,4 @@
1import { AuthDiagnosticsReadScope } from "@t3tools/contracts";
12import { AuthEnvironmentMaintainScope } from "@t3tools/contracts";
23import type {
34 EnvironmentId,
......@@ -10,7 +11,7 @@ import { useCallback } from "react";
1011import { usePrimaryEnvironment } from "../state/environments";
1112import { useEnvironmentQuery } from "../state/query";
1213import { serverEnvironment } from "../state/server";
13import { readEnvironmentScope } from "../state/session";
14import { readEnvironmentScope, useEnvironmentScope } from "../state/session";
1415import { useAtomCommand } from "../state/use-atom-command";
1516
1617export interface ResourceTelemetryState {
......@@ -29,8 +30,9 @@ export function useResourceTelemetry(
2930 targetEnvironmentId === undefined
3031 ? (primaryEnvironment?.environmentId ?? null)
3132 : targetEnvironmentId;
33 const canReadDiagnostics = useEnvironmentScope(environmentId, AuthDiagnosticsReadScope);
3234 const query = useEnvironmentQuery(
33 environmentId === null
35 environmentId === null || !canReadDiagnostics
3436 ? null
3537 : serverEnvironment.resourceTelemetry({ environmentId, input: {} }),
3638 );
......@@ -41,7 +43,10 @@ export function useResourceTelemetry(
4143 if (environmentId === null) {
4244 throw new Error("No environment is selected.");
4345 }
44 if (!readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope)) {
46 if (
47 !readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope) ||
48 !readEnvironmentScope(environmentId, AuthDiagnosticsReadScope)
49 ) {
4550 throw new Error("This connection cannot restart the resource monitor.");
4651 }
4752 const result = await retryCommand({ environmentId, input: {} });
......@@ -63,8 +68,9 @@ export function useResourceTelemetryHistory(
6368 targetEnvironmentId === undefined
6469 ? (primaryEnvironment?.environmentId ?? null)
6570 : targetEnvironmentId;
71 const canReadDiagnostics = useEnvironmentScope(environmentId, AuthDiagnosticsReadScope);
6672 return useEnvironmentQuery(
67 environmentId === null
73 environmentId === null || !canReadDiagnostics
6874 ? null
6975 : serverEnvironment.resourceTelemetryHistory({ environmentId, input }),
7076 );
apps/web/src/state/usage.test.tsx+1
......@@ -22,6 +22,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU
2222 environmentId: EnvironmentId.make(id),
2323 label: id,
2424 isPending: cost === null,
25 canReadDiagnostics: true,
2526 error: null,
2627 needsCursorKeychainAccess: false,
2728 summary:
apps/web/src/state/usage.ts+30-1
......@@ -8,6 +8,7 @@
88 */
99import { useAtomValue } from "@effect/atom-react";
1010import {
11 AuthDiagnosticsReadScope,
1112 USAGE_CONTRACT_VERSION,
1213 type EnvironmentId,
1314 type UsageBucket,
......@@ -15,6 +16,7 @@ import {
1516 type UsageSummaryInput,
1617} from "@t3tools/contracts";
1718import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage";
19import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access";
1820import * as Option from "effect/Option";
1921import { AsyncResult, Atom } from "effect/reactivity";
2022import { useCallback, useMemo } from "react";
......@@ -23,11 +25,13 @@ import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/sh
2325import { appAtomRegistry } from "../rpc/atomRegistry";
2426import { environmentPresentations } from "./presentation";
2527import { serverEnvironment } from "./server";
28import { environmentSession, readEnvironmentScope } from "./session";
2629
2730export interface EnvironmentUsageStatus {
2831 readonly environmentId: EnvironmentId;
2932 readonly label: string;
3033 readonly isPending: boolean;
34 readonly canReadDiagnostics: boolean;
3135 readonly error: string | null;
3236 readonly summary: UsageSummary | null;
3337 readonly needsCursorKeychainAccess: boolean;
......@@ -47,12 +51,29 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
4751
4852 const statuses: EnvironmentUsageStatus[] = [];
4953 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 }
5070 const result = get(serverEnvironment.usageSummary({ environmentId, input }));
5171 const summary = Option.getOrNull(AsyncResult.value(result));
5272 statuses.push({
5373 environmentId,
5474 label: presentation.entry.target.label,
5575 isPending: result.waiting,
76 canReadDiagnostics: true,
5677 error: result._tag === "Failure" ? "This environment could not report usage." : null,
5778 summary,
5879 needsCursorKeychainAccess: needsCursorKeychainAccess(
......@@ -148,7 +169,15 @@ export function useUsage(
148169 registry: appAtomRegistry,
149170 server: serverEnvironment,
150171 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),
152181 input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput),
153182 }),
154183 [selectedEnvironments, windowKey],
packages/client-runtime/package.json+4
......@@ -243,6 +243,10 @@
243243 "types": "./src/state/session.ts",
244244 "default": "./src/state/session.ts"
245245 },
246 "./state/usage-access": {
247 "types": "./src/state/usageAccess.ts",
248 "default": "./src/state/usageAccess.ts"
249 },
246250 "./state/shared-settings": {
247251 "types": "./src/state/sharedSettings.ts",
248252 "default": "./src/state/sharedSettings.ts"
packages/client-runtime/src/state/usageAccess.test.ts created+62
......@@ -0,0 +1,62 @@
1import { AuthDiagnosticsReadScope, AuthOrchestrationReadScope } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { resolveUsageAccess } from "./usageAccess.ts";
5
6describe("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 @@
1import { AuthDiagnosticsReadScope, type AuthSessionState } from "@t3tools/contracts";
2
3import type { EnvironmentConnectionPhase } from "../connection/presentation.ts";
4
5export 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;
8484export const AuthProvidersManageScope = "providers:manage" as const;
8585export const AuthEnvironmentMaintainScope = "environment:maintain" as const;
8686export const AuthPreviewOperateScope = "preview:operate" as const;
87export const AuthDiagnosticsReadScope = "diagnostics:read" as const;
8788export const AuthTerminalOperateScope = "terminal:operate" as const;
8889export const AuthSourceControlWriteScope = "source-control:write" as const;
8990export const AuthFilesystemReadScope = "filesystem:read" as const;
......@@ -101,6 +102,7 @@ export const AuthEnvironmentScope = Schema.Literals([
101102 AuthProvidersManageScope,
102103 AuthEnvironmentMaintainScope,
103104 AuthPreviewOperateScope,
105 AuthDiagnosticsReadScope,
104106 AuthTerminalOperateScope,
105107 AuthFilesystemReadScope,
106108 AuthFilesystemWriteScope,
......@@ -129,6 +131,7 @@ export const AuthStandardClientScopes = [
129131 AuthProvidersManageScope,
130132 AuthEnvironmentMaintainScope,
131133 AuthPreviewOperateScope,
134 AuthDiagnosticsReadScope,
132135 AuthTerminalOperateScope,
133136 AuthSourceControlWriteScope,
134137 AuthFilesystemReadScope,