authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 18:29:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 18:29:17-07:00
log456930a09db29efdd3d4a397fe214e9ede2d6975
tree2ef0b4e6c2ab60bbe81a131372a0e058f1235a71
parent2c7a446e657e705c257db28540819f4c0115b2fc
signature Signed by PGP key B5690EEEBB952194

fix(auth): show connection permissions and enforce session lifetime (#17370)

Co-authored-by: Bear Huddleston <bear@bearhuddleston.dev>

16 files changed, 536 insertions(+), 140 deletions(-)

apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx+36-1
...@@ -9,7 +9,9 @@ import {...@@ -9,7 +9,9 @@ import {
9 connectionRoutes,9 connectionRoutes,
10 isLearned,10 isLearned,
11} from "@t3tools/client-runtime/connection";11} from "@t3tools/client-runtime/connection";
12import type { EnvironmentId } from "@t3tools/contracts";12import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts";
13import { AUTH_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions";
14import { AsyncResult } from "effect/reactivity";
13import * as Option from "effect/Option";15import * as Option from "effect/Option";
14import { useEffect, useMemo, useRef, useState } from "react";16import { useEffect, useMemo, useRef, useState } from "react";
15import { Alert, Platform, Pressable, View } from "react-native";17import { Alert, Platform, Pressable, View } from "react-native";
...@@ -56,6 +58,9 @@ export function EnvironmentRoutesSection({...@@ -56,6 +58,9 @@ export function EnvironmentRoutesSection({
56}) {58}) {
57 const entry = useAtomValue(environmentCatalog.catalogValueAtom).entries.get(environmentId);59 const entry = useAtomValue(environmentCatalog.catalogValueAtom).entries.get(environmentId);
58 const prepared = useAtomValue(environmentSession.preparedConnectionValueAtom(environmentId));60 const prepared = useAtomValue(environmentSession.preparedConnectionValueAtom(environmentId));
61 const sessionResult = useAtomValue(environmentSession.sessionStateAtom(environmentId));
62 const session = Option.getOrNull(AsyncResult.value(sessionResult));
63 const [permissionsOpen, setPermissionsOpen] = useState(false);
59 const reorder = useAtomCommand(environmentCatalog.reorderRoutes, "route reorder");64 const reorder = useAtomCommand(environmentCatalog.reorderRoutes, "route reorder");
60 const removeRoute = useAtomCommand(environmentCatalog.removeRoute, "route removal");65 const removeRoute = useAtomCommand(environmentCatalog.removeRoute, "route removal");
61 const [editing, setEditing] = useState(false);66 const [editing, setEditing] = useState(false);
...@@ -146,6 +151,36 @@ export function EnvironmentRoutesSection({...@@ -146,6 +151,36 @@ export function EnvironmentRoutesSection({
146 ) : undefined151 ) : undefined
147 }152 }
148 >153 >
154 <SettingsActionRow
155 icon="checkmark.circle"
156 label="Your permissions"
157 onPress={() => setPermissionsOpen((open) => !open)}
158 />
159 {permissionsOpen ? (
160 <View className="gap-2 px-4 py-3">
161 {activeRouteId !== null &&
162 sessionResult._tag !== "Failure" &&
163 !sessionResult.waiting &&
164 session?.authenticated ? (
165 <>
166 <Text className="text-sm text-foreground-muted">
167 Applies to the route marked In use. Other routes may have different permissions and
168 have not been checked.
169 </Text>
170 {AUTH_SCOPE_OPTIONS.map(({ scope, title }) => (
171 <Text key={scope} className="text-sm text-foreground">
172 {title}: {sessionGrantsScope(session, scope) ? "Allowed" : "Not granted"}
173 </Text>
174 ))}
175 </>
176 ) : (
177 <Text className="text-sm text-foreground-muted">
178 Permissions not checked. Connect to this environment to view this session’s
179 permissions.
180 </Text>
181 )}
182 </View>
183 ) : null}
149 {routes.map((route, index) => {184 {routes.map((route, index) => {
150 const id = connectionRouteId(route.target);185 const id = connectionRouteId(route.target);
151 // Rows between the lifted row and its drop slot shift to make room.186 // Rows between the lifted row and its drop slot shift to make room.
apps/server/src/auth/SessionStore.test.ts+53
...@@ -3,6 +3,8 @@ import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts";...@@ -3,6 +3,8 @@ import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts";
3import { expect, it } from "@effect/vitest";3import { expect, it } from "@effect/vitest";
4import * as Duration from "effect/Duration";4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as Deferred from "effect/Deferred";
6import * as FileSystem from "effect/FileSystem";8import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";10import * as Option from "effect/Option";
...@@ -113,6 +115,57 @@ const layerFailingSessionLookupCredential = Layer.effect(...@@ -113,6 +115,57 @@ const layerFailingSessionLookupCredential = Layer.effect(
113);115);
114116
115it.layer(NodeServices.layer)("SessionStore.layer", (it) => {117it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
118 it.effect.each(["revoke", "revokeAllExcept", "replace"] as const)(
119 "invalidates every connection for a session on %s and leaves other sessions active",
120 (operation) =>
121 Effect.gen(function* () {
122 const sessions = yield* SessionStore.SessionStore;
123 const owner = yield* sessions.issue({ subject: "owner" });
124 const client = yield* sessions.issue(relaySessionInput);
125 const ownerInvalidated = yield* Deferred.make<void>();
126 yield* sessions
127 .awaitInvalidation(owner.sessionId)
128 .pipe(
129 Effect.andThen(Deferred.succeed(ownerInvalidated, undefined)),
130 Effect.forkScoped({ startImmediately: true }),
131 );
132 const first = yield* sessions
133 .awaitInvalidation(client.sessionId)
134 .pipe(Effect.forkScoped({ startImmediately: true }));
135 const second = yield* sessions
136 .awaitInvalidation(client.sessionId)
137 .pipe(Effect.forkScoped({ startImmediately: true }));
138 if (operation === "revoke") yield* sessions.revoke(client.sessionId);
139 else if (operation === "revokeAllExcept") yield* sessions.revokeAllExcept(owner.sessionId);
140 else yield* sessions.issue({ ...relaySessionInput, replaceSessionId: client.sessionId });
141 yield* Fiber.join(first);
142 yield* Fiber.join(second);
143 expect(yield* Deferred.isDone(ownerInvalidated)).toBe(false);
144 // A connection whose upgrade authenticated just before revocation must also stop.
145 yield* sessions.awaitInvalidation(client.sessionId);
146 }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),
147 );
148
149 it.effect("expires a connected session at its credential deadline", () =>
150 Effect.gen(function* () {
151 const sessions = yield* SessionStore.SessionStore;
152 const client = yield* sessions.issue(relaySessionInput);
153 yield* sessions.markConnected(client.sessionId);
154 const invalidated = yield* Deferred.make<void>();
155 const watcher = yield* sessions
156 .awaitInvalidation(client.sessionId)
157 .pipe(
158 Effect.andThen(Deferred.succeed(invalidated, undefined)),
159 Effect.forkScoped({ startImmediately: true }),
160 );
161 yield* TestClock.adjust(Duration.minutes(59));
162 expect(yield* Deferred.isDone(invalidated)).toBe(false);
163 yield* TestClock.adjust(Duration.minutes(1));
164 yield* Fiber.join(watcher);
165 yield* sessions.awaitInvalidation(client.sessionId);
166 }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),
167 );
168
116 it.effect("keys remote cookies by environment identity instead of state directory", () =>169 it.effect("keys remote cookies by environment identity instead of state directory", () =>
117 Effect.gen(function* () {170 Effect.gen(function* () {
118 const cookieName = (stateDir: string, environmentId: EnvironmentId) =>171 const cookieName = (stateDir: string, environmentId: EnvironmentId) =>
apps/server/src/auth/SessionStore.ts+33
...@@ -407,6 +407,9 @@ export class SessionStore extends Context.Service<...@@ -407,6 +407,9 @@ export class SessionStore extends Context.Service<
407 SessionCredentialInternalError407 SessionCredentialInternalError
408 >;408 >;
409 readonly streamChanges: Stream.Stream<SessionCredentialChange>;409 readonly streamChanges: Stream.Stream<SessionCredentialChange>;
410 readonly awaitInvalidation: (
411 sessionId: AuthSessionId,
412 ) => Effect.Effect<void, SessionCredentialVerificationError>;
410 readonly revoke: (413 readonly revoke: (
411 sessionId: AuthSessionId,414 sessionId: AuthSessionId,
412 ) => Effect.Effect<boolean, SessionCredentialInternalError>;415 ) => Effect.Effect<boolean, SessionCredentialInternalError>;
...@@ -1047,6 +1050,35 @@ export const make = Effect.gen(function* () {...@@ -1047,6 +1050,35 @@ export const make = Effect.gen(function* () {
1047 return revokedSessionIds.length;1050 return revokedSessionIds.length;
1048 });1051 });
10491052
1053 const awaitInvalidation = Effect.fn(function* (sessionId: AuthSessionId) {
1054 return yield* Effect.scoped(
1055 Effect.gen(function* () {
1056 // Subscribe before reading: revocation can race with the WebSocket upgrade.
1057 const subscription = yield* PubSub.subscribe(changesPubSub);
1058 const row = yield* authSessions
1059 .getById({ sessionId })
1060 .pipe(
1061 Effect.mapError(
1062 (cause) => new SessionCredentialVerificationError({ sessionId, cause }),
1063 ),
1064 );
1065 if (Option.isNone(row) || row.value.revokedAt !== null) return;
1066 const now = yield* DateTime.now;
1067 const remaining = row.value.expiresAt.epochMilliseconds - now.epochMilliseconds;
1068 if (remaining <= 0) return;
1069 yield* Effect.raceFirst(
1070 Stream.fromSubscription(subscription).pipe(
1071 Stream.filter(
1072 (change) => change.type === "clientRemoved" && change.sessionId === sessionId,
1073 ),
1074 Stream.runHead,
1075 ),
1076 Effect.sleep(Duration.millis(remaining)),
1077 );
1078 }),
1079 );
1080 });
1081
1050 return SessionStore.of({1082 return SessionStore.of({
1051 cookieName,1083 cookieName,
1052 legacyCookieName,1084 legacyCookieName,
...@@ -1055,6 +1087,7 @@ export const make = Effect.gen(function* () {...@@ -1055,6 +1087,7 @@ export const make = Effect.gen(function* () {
1055 issueWebSocketToken,1087 issueWebSocketToken,
1056 verifyWebSocketToken,1088 verifyWebSocketToken,
1057 listActive,1089 listActive,
1090 awaitInvalidation,
1058 get streamChanges() {1091 get streamChanges() {
1059 return Stream.fromPubSub(changesPubSub);1092 return Stream.fromPubSub(changesPubSub);
1060 },1093 },
apps/server/src/ws.ts+11-1
...@@ -3224,7 +3224,17 @@ export const layer = Layer.unwrap(...@@ -3224,7 +3224,17 @@ export const layer = Layer.unwrap(
3224 );3224 );
3225 return yield* Effect.acquireUseRelease(3225 return yield* Effect.acquireUseRelease(
3226 sessions.markConnected(session.sessionId),3226 sessions.markConnected(session.sessionId),
3227 () => rpcWebSocketHttpEffect,3227 () =>
3228 Effect.raceFirst(
3229 rpcWebSocketHttpEffect,
3230 sessions.awaitInvalidation(session.sessionId).pipe(
3231 Effect.as(HttpServerResponse.empty()),
3232 Effect.catchTags({
3233 SessionCredentialVerificationError: (error) =>
3234 failEnvironmentInternal("internal_error", error),
3235 }),
3236 ),
3237 ),
3228 () => sessions.markDisconnected(session.sessionId),3238 () => sessions.markDisconnected(session.sessionId),
3229 );3239 );
3230 }).pipe(3240 }).pipe(
apps/web/src/components/settings/ConnectionsSettings.tsx+57-100
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { SessionPermissions } from "./SessionPermissions";
2import { AUTH_SCOPE_OPTIONS as PAIRING_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions";
1import {3import {
2 ChevronRightIcon,4 ChevronRightIcon,
3 ChevronsLeftRightEllipsisIcon,5 ChevronsLeftRightEllipsisIcon,
...@@ -23,20 +25,13 @@ import {...@@ -23,20 +25,13 @@ import {
23import {25import {
24 AuthAccessReadScope,26 AuthAccessReadScope,
25 AuthAccessWriteScope,27 AuthAccessWriteScope,
26 AuthSettingsWriteScope,
27 AuthProvidersManageScope,
28 AuthEnvironmentMaintainScope,28 AuthEnvironmentMaintainScope,
29 AuthDiagnosticsReadScope,29 AuthDiagnosticsReadScope,
30 AuthOrchestrationOperateScope,
31 AuthOrchestrationReadScope,30 AuthOrchestrationReadScope,
32 AuthPreviewOperateScope,
33 AuthRelayReadScope,31 AuthRelayReadScope,
34 AuthRelayWriteScope,32 AuthRelayWriteScope,
35 AuthSourceControlWriteScope,
36 AuthFilesystemReadScope,33 AuthFilesystemReadScope,
37 AuthFilesystemWriteScope,
38 AuthStandardClientScopes,34 AuthStandardClientScopes,
39 AuthTerminalOperateScope,
40 AuthTerminalReadScope,35 AuthTerminalReadScope,
41 type AuthClientSession,36 type AuthClientSession,
42 type AuthEnvironmentScope,37 type AuthEnvironmentScope,
...@@ -231,93 +226,6 @@ function formatAccessTimestamp(value: string): string {...@@ -231,93 +226,6 @@ function formatAccessTimestamp(value: string): string {
231 return accessTimestampFormatter.format(parsed);226 return accessTimestampFormatter.format(parsed);
232}227}
233228
234const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{
235 readonly scope: AuthGrantScope;
236 readonly title: string;
237 readonly description: string;
238}> = [
239 {
240 scope: AuthOrchestrationReadScope,
241 title: "View environment",
242 description: "Read threads, status, checkpoints, and configuration.",
243 },
244 {
245 scope: AuthOrchestrationOperateScope,
246 title: "Operate tasks",
247 description: "Start, update, and stop tasks.",
248 },
249 {
250 scope: AuthSettingsWriteScope,
251 title: "Change environment settings",
252 description: "Edit environment preferences and keybindings.",
253 },
254 {
255 scope: AuthProvidersManageScope,
256 title: "Manage providers",
257 description: "Configure, install, sign in to, and update providers and usage sources.",
258 },
259 {
260 scope: AuthEnvironmentMaintainScope,
261 title: "Maintain environment",
262 description: "Update the server and control environment processes.",
263 },
264 {
265 scope: AuthPreviewOperateScope,
266 title: "Control previews",
267 description: "Open browser previews and host browser automation.",
268 },
269 {
270 scope: AuthDiagnosticsReadScope,
271 title: "View diagnostics and usage",
272 description: "Read process diagnostics, resource history, and usage totals.",
273 },
274 {
275 scope: AuthTerminalReadScope,
276 title: "View terminals",
277 description: "Read existing terminal output and status.",
278 },
279 {
280 scope: AuthTerminalOperateScope,
281 title: "Use terminals",
282 description: "Create terminals and send input to running shells.",
283 },
284 {
285 scope: AuthSourceControlWriteScope,
286 title: "Change source control",
287 description: "Commit, push, manage branches and repositories, and change pull requests.",
288 },
289 {
290 scope: AuthFilesystemReadScope,
291 title: "Read files",
292 description: "Browse host files, search workspaces, and inspect local changes.",
293 },
294 {
295 scope: AuthFilesystemWriteScope,
296 title: "Write files",
297 description: "Edit workspace files and save plans to disk.",
298 },
299 {
300 scope: AuthAccessReadScope,
301 title: "View access",
302 description: "Inspect pairing links and authorized clients.",
303 },
304 {
305 scope: AuthAccessWriteScope,
306 title: "Manage access",
307 description: "Issue and revoke credentials for other clients.",
308 },
309 {
310 scope: AuthRelayReadScope,
311 title: "View relay",
312 description: "Inspect managed relay connectivity.",
313 },
314 {
315 scope: AuthRelayWriteScope,
316 title: "Manage relay",
317 description: "Change managed tunnel connectivity.",
318 },
319];
320
321function AccessScopeSummary({229function AccessScopeSummary({
322 scopes,230 scopes,
323 label,231 label,
...@@ -1586,6 +1494,7 @@ function SavedBackendListRow({...@@ -1586,6 +1494,7 @@ function SavedBackendListRow({
1586 onAddRoute,1494 onAddRoute,
1587}: SavedBackendListRowProps) {1495}: SavedBackendListRowProps) {
1588 const [routesOpen, setRoutesOpen] = useState(false);1496 const [routesOpen, setRoutesOpen] = useState(false);
1497 const [permissionsOpen, setPermissionsOpen] = useState(false);
1589 const environmentId = environment.environmentId;1498 const environmentId = environment.environmentId;
1590 const unsupported = environment.connection.phase === "unsupported";1499 const unsupported = environment.connection.phase === "unsupported";
1591 const enabled = environment.entry.enabled && !unsupported;1500 const enabled = environment.entry.enabled && !unsupported;
...@@ -1753,6 +1662,25 @@ function SavedBackendListRow({...@@ -1753,6 +1662,25 @@ function SavedBackendListRow({
1753 )}1662 )}
1754 />1663 />
1755 </button>1664 </button>
1665 <span aria-hidden className="shrink-0">
1666 ·
1667 </span>
1668 <button
1669 type="button"
1670 aria-expanded={permissionsOpen}
1671 aria-controls={`remote-permissions-${environmentId}`}
1672 onClick={() => setPermissionsOpen((open) => !open)}
1673 className="inline-flex shrink-0 items-center gap-0.5 rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring"
1674 >
1675 Permissions
1676 <ChevronRightIcon
1677 aria-hidden
1678 className={cn(
1679 "size-3 shrink-0 transition-transform duration-150 motion-reduce:transition-none",
1680 permissionsOpen && "rotate-90",
1681 )}
1682 />
1683 </button>
1756 </span>1684 </span>
1757 }1685 }
1758 below={1686 below={
...@@ -1763,12 +1691,26 @@ function SavedBackendListRow({...@@ -1763,12 +1691,26 @@ function SavedBackendListRow({
1763 ) : null1691 ) : null
1764 }1692 }
1765 detail={1693 detail={
1766 routesOpen ? (1694 <>
1767 <EnvironmentRoutesList1695 {routesOpen && (
1768 environment={environment}1696 <EnvironmentRoutesList
1769 onAddRoute={() => onAddRoute(environment)}1697 environment={environment}
1770 />1698 onAddRoute={() => onAddRoute(environment)}
1771 ) : null1699 />
1700 )}
1701 {permissionsOpen && (
1702 <div
1703 id={`remote-permissions-${environmentId}`}
1704 className="mt-2 border-t border-border/50"
1705 >
1706 <SessionPermissions
1707 environmentId={environmentId}
1708 connected={isConnected}
1709 routeContext
1710 />
1711 </div>
1712 )}
1713 </>
1772 }1714 }
1773 >1715 >
1774 {unsupported &&1716 {unsupported &&
...@@ -3774,6 +3716,21 @@ export function ConnectionsSettings() {...@@ -3774,6 +3716,21 @@ export function ConnectionsSettings() {
3774 ) : null}3716 ) : null}
3775 </>3717 </>
3776 ) : null}3718 ) : null}
3719 {primaryEnvironment ? (
3720 <details className="group px-3 sm:px-4">
3721 <summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 rounded-md text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-details-marker]:hidden">
3722 Permissions
3723 <ChevronRightIcon
3724 aria-hidden
3725 className="size-3 text-muted-foreground group-open:rotate-90"
3726 />
3727 </summary>
3728 <SessionPermissions
3729 environmentId={primaryEnvironment.environmentId}
3730 connected={primaryEnvironment.connection.phase === "connected"}
3731 />
3732 </details>
3733 ) : null}
3777 </SettingsSection>3734 </SettingsSection>
37783735
3779 {canReadAccess || canWriteAccess ? (3736 {canReadAccess || canWriteAccess ? (
apps/web/src/components/settings/EnvironmentRoutesList.tsx+13-13
...@@ -103,7 +103,7 @@ export function EnvironmentRoutesList({...@@ -103,7 +103,7 @@ export function EnvironmentRoutesList({
103 };103 };
104104
105 return (105 return (
106 <div className="mt-2 rounded-md border border-border/70">106 <div className="mt-2 border-t border-border/50 py-2">
107 <DndContext107 <DndContext
108 sensors={sensors}108 sensors={sensors}
109 collisionDetection={closestCenter}109 collisionDetection={closestCenter}
...@@ -128,7 +128,7 @@ export function EnvironmentRoutesList({...@@ -128,7 +128,7 @@ export function EnvironmentRoutesList({
128 </ol>128 </ol>
129 </SortableContext>129 </SortableContext>
130 </DndContext>130 </DndContext>
131 <div className="border-t border-border/70 px-1 py-1">131 <div className="-ml-1.5 pt-1">
132 <Button size="xs" variant="ghost-muted" onClick={onAddRoute}>132 <Button size="xs" variant="ghost-muted" onClick={onAddRoute}>
133 <PlusIcon className="size-3" />133 <PlusIcon className="size-3" />
134 Add route134 Add route
...@@ -169,20 +169,10 @@ function SortableRouteRow({...@@ -169,20 +169,10 @@ function SortableRouteRow({
169 ref={setNodeRef}169 ref={setNodeRef}
170 style={{ transform: CSS.Translate.toString(transform), transition }}170 style={{ transform: CSS.Translate.toString(transform), transition }}
171 className={cn(171 className={cn(
172 "flex items-center gap-2 border-b border-border/70 px-1 py-1.5 last:border-b-0",172 "flex items-center gap-2 py-2",
173 isDragging && "relative z-10 rounded-md bg-background shadow-md",173 isDragging && "relative z-10 rounded-md bg-background shadow-md",
174 )}174 )}
175 >175 >
176 <button
177 type="button"
178 ref={setActivatorNodeRef}
179 {...attributes}
180 {...listeners}
181 aria-label={`Reorder ${label}, position ${position}`}
182 className="flex size-6 shrink-0 cursor-grab touch-none items-center justify-center rounded text-muted-foreground/70 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing"
183 >
184 <GripVerticalIcon className="size-3.5" />
185 </button>
186 <div className="min-w-0 flex-1">176 <div className="min-w-0 flex-1">
187 <p className="flex items-center gap-1.5 text-xs font-medium text-foreground">177 <p className="flex items-center gap-1.5 text-xs font-medium text-foreground">
188 {label}178 {label}
...@@ -199,6 +189,16 @@ function SortableRouteRow({...@@ -199,6 +189,16 @@ function SortableRouteRow({
199 </p>189 </p>
200 ) : null}190 ) : null}
201 </div>191 </div>
192 <button
193 type="button"
194 ref={setActivatorNodeRef}
195 {...attributes}
196 {...listeners}
197 aria-label={`Reorder ${label}, position ${position}`}
198 className="flex size-6 shrink-0 cursor-grab touch-none items-center justify-center rounded text-muted-foreground/70 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing"
199 >
200 <GripVerticalIcon className="size-3.5" />
201 </button>
202 {removable ? (202 {removable ? (
203 <Tooltip>203 <Tooltip>
204 <TooltipTrigger204 <TooltipTrigger
apps/web/src/components/settings/SessionPermissions.tsx created+38
...@@ -0,0 +1,38 @@
1import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts";
2import { AUTH_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions";
3import { useEnvironmentSessionState } from "~/state/session";
4
5export function SessionPermissions({
6 environmentId,
7 connected,
8 routeContext = false,
9}: {
10 readonly environmentId: EnvironmentId;
11 readonly connected: boolean;
12 readonly routeContext?: boolean;
13}) {
14 const session = useEnvironmentSessionState(environmentId);
15 return connected && !session.hasError && !session.isPending && session.data?.authenticated ? (
16 <div className="space-y-3 py-3 text-xs">
17 <p className="text-muted-foreground">
18 {routeContext
19 ? "These permissions apply to your active route. Other routes may have different permissions."
20 : "These permissions apply to this client’s current connection."}
21 </p>
22 <ul className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
23 {AUTH_SCOPE_OPTIONS.map(({ scope, title }) => (
24 <li key={scope} className="flex items-start justify-between gap-3">
25 <span>{title}</span>
26 <span className="shrink-0 text-muted-foreground">
27 {sessionGrantsScope(session.data!, scope) ? "Allowed" : "Not granted"}
28 </span>
29 </li>
30 ))}
31 </ul>
32 </div>
33 ) : (
34 <p className="py-3 text-xs text-muted-foreground">
35 Permissions not checked. Connect to this environment to view this session’s permissions.
36 </p>
37 );
38}
apps/web/src/components/settings/scopedSettings.test.ts+19-1
...@@ -1,11 +1,13 @@...@@ -1,11 +1,13 @@
1import {1import {
2 DEFAULT_SERVER_SETTINGS,2 DEFAULT_SERVER_SETTINGS,
3 EnvironmentAuthorizationError,
3 EnvironmentId,4 EnvironmentId,
4 ProjectId,5 ProjectId,
5 type ServerSettings,6 type ServerSettings,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
7import { describe, expect, it, vi } from "vite-plus/test";8import { describe, expect, it, vi } from "vite-plus/test";
8import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";9import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
10import * as Cause from "effect/Cause";
9import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";11import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
1012
11import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";13import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
...@@ -410,7 +412,15 @@ describe("scoped settings writes", () => {...@@ -410,7 +412,15 @@ describe("scoped settings writes", () => {
410 const persistServer = vi412 const persistServer = vi
411 .fn()413 .fn()
412 .mockResolvedValueOnce({ _tag: "Success" })414 .mockResolvedValueOnce({ _tag: "Success" })
413 .mockResolvedValueOnce({ _tag: "Failure" })415 .mockResolvedValueOnce({
416 _tag: "Failure",
417 cause: Cause.fail(
418 new EnvironmentAuthorizationError({
419 requiredScope: "settings:write",
420 message: "This connection lacks permission to change settings.",
421 }),
422 ),
423 })
414 .mockRejectedValueOnce(new Error("Disconnected during save"))424 .mockRejectedValueOnce(new Error("Disconnected during save"))
415 .mockResolvedValueOnce({ _tag: "Success" });425 .mockResolvedValueOnce({ _tag: "Success" });
416 const result = await persistScopedSettingsPatch(426 const result = await persistScopedSettingsPatch(
...@@ -419,6 +429,14 @@ describe("scoped settings writes", () => {...@@ -419,6 +429,14 @@ describe("scoped settings writes", () => {
419 vi.fn(),429 vi.fn(),
420 );430 );
421 expect(result.savedEnvironmentCount).toBe(2);431 expect(result.savedEnvironmentCount).toBe(2);
432 expect(result.savedEnvironments.map(({ label }) => label)).toEqual([
433 laptop.label,
434 fourth.label,
435 ]);
436 expect(result.failedEnvironments.map(({ message }) => message)).toEqual([
437 "This connection lacks permission to change settings.",
438 "Disconnected during save",
439 ]);
422 expect(result.failedEnvironments.map(({ label }) => label)).toEqual([440 expect(result.failedEnvironments.map(({ label }) => label)).toEqual([
423 server.label,441 server.label,
424 third.label,442 third.label,
apps/web/src/components/settings/scopedSettings.ts+25-4
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 type ProjectSettingSource,19 type ProjectSettingSource,
20} from "@t3tools/shared/projectSettings";20} from "@t3tools/shared/projectSettings";
21import * as Equal from "effect/Equal";21import * as Equal from "effect/Equal";
22import * as Cause from "effect/Cause";
2223
23import type { ResolvedSettingsScope } from "./settingsScope";24import type { ResolvedSettingsScope } from "./settingsScope";
2425
...@@ -394,7 +395,7 @@ export async function persistScopedSettingsPatch(...@@ -394,7 +395,7 @@ export async function persistScopedSettingsPatch(
394 persistServer: (input: {395 persistServer: (input: {
395 environmentId: EnvironmentId;396 environmentId: EnvironmentId;
396 input: { patch: ServerSettingsPatch };397 input: { patch: ServerSettingsPatch };
397 }) => Promise<{ readonly _tag: "Success" | "Failure" }>,398 }) => Promise<{ readonly _tag: "Success" | "Failure"; readonly cause?: Cause.Cause<unknown> }>,
398 persistClient: (patch: ClientSettingsPatch) => void,399 persistClient: (patch: ClientSettingsPatch) => void,
399) {400) {
400 if (plan.hasClientWrite) persistClient(plan.clientPatch);401 if (plan.hasClientWrite) persistClient(plan.clientPatch);
...@@ -403,12 +404,32 @@ export async function persistScopedSettingsPatch(...@@ -403,12 +404,32 @@ export async function persistScopedSettingsPatch(
403 persistServer({ environmentId, input: { patch } }),404 persistServer({ environmentId, input: { patch } }),
404 ),405 ),
405 );406 );
406 const failedEnvironments = plan.serverWrites.filter((_, index) => {407 const failedEnvironments = plan.serverWrites.flatMap((environment, index) => {
407 const result = results[index];408 const result = results[index];
408 return result?.status !== "fulfilled" || result.value._tag === "Failure";409 if (result?.status === "fulfilled" && result.value._tag === "Success") return [];
410 const error: unknown =
411 result?.status === "rejected"
412 ? result.reason
413 : result?.status === "fulfilled" && result.value.cause
414 ? Cause.squash(result.value.cause)
415 : undefined;
416 return [
417 {
418 ...environment,
419 message:
420 error instanceof Error
421 ? error.message
422 : "The save failed. Try reconnecting and saving again.",
423 },
424 ];
425 });
426 const savedEnvironments = plan.serverWrites.filter((_, index) => {
427 const result = results[index];
428 return result?.status === "fulfilled" && result.value._tag === "Success";
409 });429 });
410 return {430 return {
411 failedEnvironments,431 failedEnvironments,
412 savedEnvironmentCount: plan.serverWrites.length - failedEnvironments.length,432 savedEnvironments,
433 savedEnvironmentCount: savedEnvironments.length,
413 };434 };
414}435}
apps/web/src/components/settings/settingsLayout.tsx+10-2
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { SettingsGroup } from "./SettingsGroup";1import { SettingsGroup } from "./SettingsGroup";
2import { AuthSettingsWriteScope } from "@t3tools/contracts";2import { AuthSettingsWriteScope } from "@t3tools/contracts";
3import { usePrimaryEnvironmentId } from "../../state/environments";3import { usePrimaryEnvironmentId, usePrimaryEnvironment } from "../../state/environments";
4import { useEnvironmentScope, useEnvironmentsWithScope } from "../../state/session";4import { useEnvironmentScope, useEnvironmentsWithScope } from "../../state/session";
5import { InfoIcon, Undo2Icon } from "lucide-react";5import { InfoIcon, Undo2Icon } from "lucide-react";
6import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts";6import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts";
...@@ -290,6 +290,7 @@ export function SettingsRow({...@@ -290,6 +290,7 @@ export function SettingsRow({
290 const primarySettingsAvailable = usePrimarySettingsAvailable();290 const primarySettingsAvailable = usePrimarySettingsAvailable();
291 const context = useOptionalSettingsScope();291 const context = useOptionalSettingsScope();
292 const primaryEnvironmentId = usePrimaryEnvironmentId();292 const primaryEnvironmentId = usePrimaryEnvironmentId();
293 const primaryEnvironment = usePrimaryEnvironment();
293 const primaryCanWrite = useEnvironmentScope(primaryEnvironmentId, AuthSettingsWriteScope);294 const primaryCanWrite = useEnvironmentScope(primaryEnvironmentId, AuthSettingsWriteScope);
294 const writableIds = useEnvironmentsWithScope(295 const writableIds = useEnvironmentsWithScope(
295 context?.connectedEnvironments ?? [],296 context?.connectedEnvironments ?? [],
...@@ -395,7 +396,14 @@ export function SettingsRow({...@@ -395,7 +396,14 @@ export function SettingsRow({
395 unavailable && control396 unavailable && control
396 ? inertControl(397 ? inertControl(
397 !canWriteSettings398 !canWriteSettings
398 ? "This connection does not have permission to change environment settings."399 ? `This connection lacks permission to change settings on ${
400 context
401 ? context.connectedEnvironments
402 .filter((target) => !writableIds.has(target.environmentId))
403 .map((target) => target.label)
404 .join(", ") || "the selected environment"
405 : (primaryEnvironment?.label ?? "the primary environment")
406 }.`
399 : context407 : context
400 ? "Reconnect the selected environment to change this setting."408 ? "Reconnect the selected environment to change this setting."
401 : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE,409 : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE,
apps/web/src/components/settings/useScopedSettings.ts+10-3
...@@ -83,20 +83,27 @@ function useRunScopedPlan() {...@@ -83,20 +83,27 @@ function useRunScopedPlan() {
83 Cause.fail(83 Cause.fail(
84 new EnvironmentAuthorizationError({84 new EnvironmentAuthorizationError({
85 requiredScope: missing,85 requiredScope: missing,
86 message: "This connection cannot change these settings.",86 message: "This connection lacks permission to change these settings.",
87 }),87 }),
88 ),88 ),
89 );89 );
90 return persistServer(request);90 return persistServer(request);
91 },91 },
92 persistClientSettingsPatch,92 persistClientSettingsPatch,
93 ).then(({ failedEnvironments, savedEnvironmentCount }) => {93 ).then(({ failedEnvironments, savedEnvironments, savedEnvironmentCount }) => {
94 if (failedEnvironments.length === 0) return;94 if (failedEnvironments.length === 0) return;
95 toastManager.add({95 toastManager.add({
96 type: "error",96 type: "error",
97 title:97 title:
98 savedEnvironmentCount > 0 ? "Setting saved on some environments" : "Setting not saved",98 savedEnvironmentCount > 0 ? "Setting saved on some environments" : "Setting not saved",
99 description: `Could not update ${failedEnvironments.map((environment) => environment.label).join(", ")}.${savedEnvironmentCount > 0 ? " The other selected environments saved the change." : ""}`,99 description: [
100 ...failedEnvironments.map(
101 ({ label, message }) => `Could not save on ${label}: ${message}`,
102 ),
103 ...(savedEnvironmentCount > 0
104 ? [`Saved on ${savedEnvironments.map(({ label }) => label).join(", ")}.`]
105 : []),
106 ].join("\n"),
100 });107 });
101 });108 });
102 },109 },
apps/web/src/hooks/useSettings.sync.test.tsx+49-1
...@@ -122,6 +122,54 @@ afterEach(async () => {...@@ -122,6 +122,54 @@ afterEach(async () => {
122});122});
123123
124describe("shared settings writes", () => {124describe("shared settings writes", () => {
125 it("waits for the remaining target before reporting a partial save", async () => {
126 let finishRemote = () => {};
127 const remote = new Promise((resolve) => {
128 finishRemote = () => resolve(AsyncResult.success(DEFAULT_SERVER_SETTINGS));
129 });
130 state.persist.mockResolvedValueOnce(
131 AsyncResult.failure(Cause.fail(new Error("Permission denied"))),
132 );
133 state.persist.mockReturnValueOnce(remote);
134 await mountEditor();
135 await act(async () => saveSharedSettings());
136 expect(state.toast).not.toHaveBeenCalled();
137 await act(async () => finishRemote());
138 expect(state.toast).toHaveBeenCalledExactlyOnceWith({
139 type: "error",
140 title: "Setting saved on some environments",
141 description: "Could not save on primary: Permission denied\nSaved on remote.",
142 });
143 });
144
145 it("keeps a single-target failure specific to that environment", async () => {
146 state.registry!.set(state.sessions.get(remoteId)!, AsyncResult.success(session([])));
147 state.persist.mockResolvedValueOnce(
148 AsyncResult.failure(Cause.fail(new Error("Permission denied"))),
149 );
150 await mountEditor();
151 await act(async () => saveSharedSettings());
152 expect(state.toast).toHaveBeenCalledExactlyOnceWith({
153 type: "error",
154 title: "Setting not saved",
155 description: "Could not save on primary: Permission denied",
156 });
157 });
158
159 it("names the failed environment when a server rejects a save after the grant check", async () => {
160 state.persist.mockResolvedValueOnce(AsyncResult.success(DEFAULT_SERVER_SETTINGS));
161 state.persist.mockResolvedValueOnce(
162 AsyncResult.failure(Cause.fail(new Error("Permission denied"))),
163 );
164 await mountEditor();
165 await act(async () => saveSharedSettings());
166 expect(state.toast).toHaveBeenCalledExactlyOnceWith({
167 type: "error",
168 title: "Setting saved on some environments",
169 description: "Could not save on remote: Permission denied\nSaved on primary.",
170 });
171 });
172
125 it("dispatches to the primary and connected remote before the remote grant finishes loading", async () => {173 it("dispatches to the primary and connected remote before the remote grant finishes loading", async () => {
126 await mountEditor();174 await mountEditor();
127 saveSharedSettings();175 saveSharedSettings();
...@@ -193,7 +241,7 @@ describe("shared settings writes", () => {...@@ -193,7 +241,7 @@ describe("shared settings writes", () => {
193 expect(state.toast).toHaveBeenCalledExactlyOnceWith({241 expect(state.toast).toHaveBeenCalledExactlyOnceWith({
194 type: "warning",242 type: "warning",
195 title: "Setting not saved",243 title: "Setting not saved",
196 description: "This connection does not have permission to change these settings.",244 description: "This connection lacks permission to change settings on remote.",
197 });245 });
198 }246 }
199 },247 },
apps/web/src/hooks/useSettings.ts+65-14
...@@ -29,6 +29,10 @@ import {...@@ -29,6 +29,10 @@ import {
29 type UnifiedSettings,29 type UnifiedSettings,
30} from "@t3tools/contracts/settings";30} from "@t3tools/contracts/settings";
31import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";31import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
32import {
33 isAtomCommandInterrupted,
34 squashAtomCommandFailure,
35} from "@t3tools/client-runtime/state/runtime";
32import {36import {
33 filterSharedServerPatch,37 filterSharedServerPatch,
34 splitSharedServerPatch,38 splitSharedServerPatch,
...@@ -469,11 +473,29 @@ function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray<E...@@ -469,11 +473,29 @@ function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray<E
469function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {473function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
470 // Mount this session even on pages without a visible permission-gated control.474 // Mount this session even on pages without a visible permission-gated control.
471 useEnvironmentScope(environmentId, AuthSettingsWriteScope);475 useEnvironmentScope(environmentId, AuthSettingsWriteScope);
472 const persistServerSettings = useAtomCommand(476 const persist = useAtomCommand(serverEnvironment.updateSettings, {
473 serverEnvironment.updateSettings,477 label: "server settings update",
474 "server settings update",478 reportFailure: false,
475 );479 });
476 const { environments } = useEnvironments();480 const { environments } = useEnvironments();
481 const persistServerSettings = useCallback(
482 async (request: Parameters<typeof persist>[0]) => {
483 const result = await persist(request);
484 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
485 const label =
486 environments.find((environment) => environment.environmentId === request.environmentId)
487 ?.label ?? request.environmentId;
488 const error = squashAtomCommandFailure(result);
489 toastManager.add({
490 type: "error",
491 title: "Setting not saved",
492 description: `Could not save on ${label}: ${error instanceof Error ? error.message : "The save failed. Try reconnecting and saving again."}`,
493 });
494 }
495 return result;
496 },
497 [environments, persist],
498 );
477 const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(true);499 const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(true);
478 const updateSettings = useCallback(500 const updateSettings = useCallback(
479 (patch: UnifiedSettingsPatch) => {501 (patch: UnifiedSettingsPatch) => {
...@@ -488,7 +510,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {...@@ -488,7 +510,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
488 toastManager.add({510 toastManager.add({
489 type: "warning",511 type: "warning",
490 title: "Setting not saved",512 title: "Setting not saved",
491 description: "This connection does not have permission to change these settings.",513 description: `This connection lacks permission to change settings on ${environments.find((target) => target.environmentId === environmentId)?.label ?? "the selected environment"}.`,
492 });514 });
493 }515 }
494 if (Object.keys(serverPatch).length > 0 && canWriteServerPatch) {516 if (Object.keys(serverPatch).length > 0 && canWriteServerPatch) {
...@@ -518,8 +540,8 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {...@@ -518,8 +540,8 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
518 if (environmentId) {540 if (environmentId) {
519 targets.add(environmentId);541 targets.add(environmentId);
520 }542 }
521 let wroteToTarget = false;543 const writes: Array<Parameters<typeof persist>[0]> = [];
522 let permissionDenied = false;544 const deniedLabels: string[] = [];
523 for (const targetId of targets) {545 for (const targetId of targets) {
524 const target = environments.find((candidate) => candidate.environmentId === targetId);546 const target = environments.find((candidate) => candidate.environmentId === targetId);
525 const targetPatch = filterSharedServerPatch(547 const targetPatch = filterSharedServerPatch(
...@@ -537,23 +559,52 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {...@@ -537,23 +559,52 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
537 readEnvironmentScope(targetId, scope),559 readEnvironmentScope(targetId, scope),
538 )560 )
539 ) {561 ) {
540 permissionDenied = true;562 deniedLabels.push(target?.label ?? targetId);
541 continue;563 continue;
542 }564 }
543 wroteToTarget = true;565 writes.push({
544 void persistServerSettings({
545 environmentId: targetId,566 environmentId: targetId,
546 input: { patch: targetPatch },567 input: { patch: targetPatch },
547 });568 });
548 }569 }
549 if (!wroteToTarget) {570 if (writes.length === 0) {
550 warnUnsaved(571 warnUnsaved(
551 permissionDenied572 deniedLabels.length > 0
552 ? "This connection does not have permission to change these settings."573 ? `This connection lacks permission to change settings on ${deniedLabels.join(", ")}.`
553 : targets.size > 0574 : targets.size > 0
554 ? "Update older servers to save this setting."575 ? "Update older servers to save this setting."
555 : undefined,576 : undefined,
556 );577 );
578 } else {
579 void Promise.all(
580 writes.map(async (request) => ({
581 label:
582 environments.find((target) => target.environmentId === request.environmentId)
583 ?.label ?? request.environmentId,
584 result: await persist(request),
585 })),
586 ).then((outcomes) => {
587 const failures = outcomes.flatMap(({ label, result }) => {
588 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return [];
589 const error = squashAtomCommandFailure(result);
590 return [
591 `Could not save on ${label}: ${error instanceof Error ? error.message : "The save failed. Try reconnecting and saving again."}`,
592 ];
593 });
594 if (failures.length === 0) return;
595 const saved = outcomes
596 .filter(({ result }) => result._tag === "Success")
597 .map(({ label }) => label);
598 toastManager.add({
599 type: "error",
600 title:
601 saved.length > 0 ? "Setting saved on some environments" : "Setting not saved",
602 description: [
603 ...failures,
604 ...(saved.length > 0 ? [`Saved on ${saved.join(", ")}.`] : []),
605 ].join("\n"),
606 });
607 });
557 }608 }
558 }609 }
559 }610 }
...@@ -561,7 +612,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {...@@ -561,7 +612,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
561 void persistClientSettingsPatch(clientPatch);612 void persistClientSettingsPatch(clientPatch);
562 }613 }
563 },614 },
564 [environmentId, environments, persistServerSettings, sharedSettingsSyncTargetIds],615 [environmentId, environments, persist, persistServerSettings, sharedSettingsSyncTargetIds],
565 );616 );
566617
567 return updateSettings;618 return updateSettings;
docs/user/remote-access.md+7
...@@ -91,6 +91,13 @@ or remove it. On mobile, open the machine under **Settings → Environments** an...@@ -91,6 +91,13 @@ or remove it. On mobile, open the machine under **Settings → Environments** an
91choose **Edit**. Signing out of T3 Connect removes only that route; a machine91choose **Edit**. Signing out of T3 Connect removes only that route; a machine
92you can still reach another way stays saved.92you can still reach another way stays saved.
9393
94Open **Permissions** next to **Routes** in web or desktop, or **Your permissions**
95in the mobile route details, to see what your current connection can do on that
96environment. For a remote environment, this is in its route details. Permissions
97shown there apply only to the route marked **In use**; other routes are not
98checked. Direct pairing and T3 Connect have separate sessions and may grant
99different permissions.
100
94### Balance new threads across machines101### Balance new threads across machines
95102
96Auto balance is off by default. On web and desktop, enable it in103Auto balance is off by default. On web and desktop, enable it in
packages/shared/package.json+4
...@@ -3,6 +3,10 @@...@@ -3,6 +3,10 @@
3 "private": true,3 "private": true,
4 "type": "module",4 "type": "module",
5 "exports": {5 "exports": {
6 "./authScopeOptions": {
7 "types": "./src/authScopeOptions.ts",
8 "import": "./src/authScopeOptions.ts"
9 },
6 "./orchestrationV2ThreadError": {10 "./orchestrationV2ThreadError": {
7 "types": "./src/orchestrationV2ThreadError.ts",11 "types": "./src/orchestrationV2ThreadError.ts",
8 "import": "./src/orchestrationV2ThreadError.ts"12 "import": "./src/orchestrationV2ThreadError.ts"
packages/shared/src/authScopeOptions.ts created+106
...@@ -0,0 +1,106 @@
1import {
2 AuthAccessReadScope,
3 AuthAccessWriteScope,
4 AuthDiagnosticsReadScope,
5 AuthEnvironmentMaintainScope,
6 AuthFilesystemReadScope,
7 AuthFilesystemWriteScope,
8 type AuthGrantScope,
9 AuthOrchestrationOperateScope,
10 AuthOrchestrationReadScope,
11 AuthPreviewOperateScope,
12 AuthProvidersManageScope,
13 AuthRelayReadScope,
14 AuthRelayWriteScope,
15 AuthSettingsWriteScope,
16 AuthSourceControlWriteScope,
17 AuthTerminalOperateScope,
18 AuthTerminalReadScope,
19} from "@t3tools/contracts";
20
21export const AUTH_SCOPE_OPTIONS: ReadonlyArray<{
22 readonly scope: AuthGrantScope;
23 readonly title: string;
24 readonly description: string;
25}> = [
26 {
27 scope: AuthOrchestrationReadScope,
28 title: "View environment",
29 description: "Read threads, status, checkpoints, and configuration.",
30 },
31 {
32 scope: AuthOrchestrationOperateScope,
33 title: "Operate tasks",
34 description: "Start, update, and stop tasks.",
35 },
36 {
37 scope: AuthSettingsWriteScope,
38 title: "Change environment settings",
39 description: "Edit environment preferences and keybindings.",
40 },
41 {
42 scope: AuthProvidersManageScope,
43 title: "Manage providers",
44 description: "Configure, install, sign in to, and update providers and usage sources.",
45 },
46 {
47 scope: AuthEnvironmentMaintainScope,
48 title: "Maintain environment",
49 description: "Update the server and control environment processes.",
50 },
51 {
52 scope: AuthPreviewOperateScope,
53 title: "Control previews",
54 description: "Open browser previews and host browser automation.",
55 },
56 {
57 scope: AuthDiagnosticsReadScope,
58 title: "View diagnostics and usage",
59 description: "Read process diagnostics, resource history, and usage totals.",
60 },
61 {
62 scope: AuthTerminalReadScope,
63 title: "View terminals",
64 description: "Read existing terminal output and status.",
65 },
66 {
67 scope: AuthTerminalOperateScope,
68 title: "Use terminals",
69 description: "Create terminals and send input to running shells.",
70 },
71 {
72 scope: AuthSourceControlWriteScope,
73 title: "Change source control",
74 description: "Commit, push, manage branches and repositories, and change pull requests.",
75 },
76 {
77 scope: AuthFilesystemReadScope,
78 title: "Read files",
79 description: "Browse host files, search workspaces, and inspect local changes.",
80 },
81 {
82 scope: AuthFilesystemWriteScope,
83 title: "Write files",
84 description: "Edit workspace files and save plans to disk.",
85 },
86 {
87 scope: AuthAccessReadScope,
88 title: "View access",
89 description: "Inspect pairing links and authorized clients.",
90 },
91 {
92 scope: AuthAccessWriteScope,
93 title: "Manage access",
94 description: "Issue and revoke credentials for other clients.",
95 },
96 {
97 scope: AuthRelayReadScope,
98 title: "View relay",
99 description: "Inspect managed relay connectivity.",
100 },
101 {
102 scope: AuthRelayWriteScope,
103 title: "Manage relay",
104 description: "Change managed tunnel connectivity.",
105 },
106];