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 {
99 connectionRoutes,
1010 isLearned,
1111} 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";
1315import * as Option from "effect/Option";
1416import { useEffect, useMemo, useRef, useState } from "react";
1517import { Alert, Platform, Pressable, View } from "react-native";
......@@ -56,6 +58,9 @@ export function EnvironmentRoutesSection({
5658}) {
5759 const entry = useAtomValue(environmentCatalog.catalogValueAtom).entries.get(environmentId);
5860 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);
5964 const reorder = useAtomCommand(environmentCatalog.reorderRoutes, "route reorder");
6065 const removeRoute = useAtomCommand(environmentCatalog.removeRoute, "route removal");
6166 const [editing, setEditing] = useState(false);
......@@ -146,6 +151,36 @@ export function EnvironmentRoutesSection({
146151 ) : undefined
147152 }
148153 >
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}
149184 {routes.map((route, index) => {
150185 const id = connectionRouteId(route.target);
151186 // 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";
33import { expect, it } from "@effect/vitest";
44import * as Duration from "effect/Duration";
55import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as Deferred from "effect/Deferred";
68import * as FileSystem from "effect/FileSystem";
79import * as Layer from "effect/Layer";
810import * as Option from "effect/Option";
......@@ -113,6 +115,57 @@ const layerFailingSessionLookupCredential = Layer.effect(
113115);
114116
115117it.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
116169 it.effect("keys remote cookies by environment identity instead of state directory", () =>
117170 Effect.gen(function* () {
118171 const cookieName = (stateDir: string, environmentId: EnvironmentId) =>
apps/server/src/auth/SessionStore.ts+33
......@@ -407,6 +407,9 @@ export class SessionStore extends Context.Service<
407407 SessionCredentialInternalError
408408 >;
409409 readonly streamChanges: Stream.Stream<SessionCredentialChange>;
410 readonly awaitInvalidation: (
411 sessionId: AuthSessionId,
412 ) => Effect.Effect<void, SessionCredentialVerificationError>;
410413 readonly revoke: (
411414 sessionId: AuthSessionId,
412415 ) => Effect.Effect<boolean, SessionCredentialInternalError>;
......@@ -1047,6 +1050,35 @@ export const make = Effect.gen(function* () {
10471050 return revokedSessionIds.length;
10481051 });
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
10501082 return SessionStore.of({
10511083 cookieName,
10521084 legacyCookieName,
......@@ -1055,6 +1087,7 @@ export const make = Effect.gen(function* () {
10551087 issueWebSocketToken,
10561088 verifyWebSocketToken,
10571089 listActive,
1090 awaitInvalidation,
10581091 get streamChanges() {
10591092 return Stream.fromPubSub(changesPubSub);
10601093 },
apps/server/src/ws.ts+11-1
......@@ -3224,7 +3224,17 @@ export const layer = Layer.unwrap(
32243224 );
32253225 return yield* Effect.acquireUseRelease(
32263226 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 ),
32283238 () => sessions.markDisconnected(session.sessionId),
32293239 );
32303240 }).pipe(
apps/web/src/components/settings/ConnectionsSettings.tsx+57-100
......@@ -1,3 +1,5 @@
1import { SessionPermissions } from "./SessionPermissions";
2import { AUTH_SCOPE_OPTIONS as PAIRING_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions";
13import {
24 ChevronRightIcon,
35 ChevronsLeftRightEllipsisIcon,
......@@ -23,20 +25,13 @@ import {
2325import {
2426 AuthAccessReadScope,
2527 AuthAccessWriteScope,
26 AuthSettingsWriteScope,
27 AuthProvidersManageScope,
2828 AuthEnvironmentMaintainScope,
2929 AuthDiagnosticsReadScope,
30 AuthOrchestrationOperateScope,
3130 AuthOrchestrationReadScope,
32 AuthPreviewOperateScope,
3331 AuthRelayReadScope,
3432 AuthRelayWriteScope,
35 AuthSourceControlWriteScope,
3633 AuthFilesystemReadScope,
37 AuthFilesystemWriteScope,
3834 AuthStandardClientScopes,
39 AuthTerminalOperateScope,
4035 AuthTerminalReadScope,
4136 type AuthClientSession,
4237 type AuthEnvironmentScope,
......@@ -231,93 +226,6 @@ function formatAccessTimestamp(value: string): string {
231226 return accessTimestampFormatter.format(parsed);
232227}
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
321229function AccessScopeSummary({
322230 scopes,
323231 label,
......@@ -1586,6 +1494,7 @@ function SavedBackendListRow({
15861494 onAddRoute,
15871495}: SavedBackendListRowProps) {
15881496 const [routesOpen, setRoutesOpen] = useState(false);
1497 const [permissionsOpen, setPermissionsOpen] = useState(false);
15891498 const environmentId = environment.environmentId;
15901499 const unsupported = environment.connection.phase === "unsupported";
15911500 const enabled = environment.entry.enabled && !unsupported;
......@@ -1753,6 +1662,25 @@ function SavedBackendListRow({
17531662 )}
17541663 />
17551664 </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>
17561684 </span>
17571685 }
17581686 below={
......@@ -1763,12 +1691,26 @@ function SavedBackendListRow({
17631691 ) : null
17641692 }
17651693 detail={
1766 routesOpen ? (
1767 <EnvironmentRoutesList
1768 environment={environment}
1769 onAddRoute={() => onAddRoute(environment)}
1770 />
1771 ) : null
1694 <>
1695 {routesOpen && (
1696 <EnvironmentRoutesList
1697 environment={environment}
1698 onAddRoute={() => onAddRoute(environment)}
1699 />
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 </>
17721714 }
17731715 >
17741716 {unsupported &&
......@@ -3774,6 +3716,21 @@ export function ConnectionsSettings() {
37743716 ) : null}
37753717 </>
37763718 ) : 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}
37773734 </SettingsSection>
37783735
37793736 {canReadAccess || canWriteAccess ? (
apps/web/src/components/settings/EnvironmentRoutesList.tsx+13-13
......@@ -103,7 +103,7 @@ export function EnvironmentRoutesList({
103103 };
104104
105105 return (
106 <div className="mt-2 rounded-md border border-border/70">
106 <div className="mt-2 border-t border-border/50 py-2">
107107 <DndContext
108108 sensors={sensors}
109109 collisionDetection={closestCenter}
......@@ -128,7 +128,7 @@ export function EnvironmentRoutesList({
128128 </ol>
129129 </SortableContext>
130130 </DndContext>
131 <div className="border-t border-border/70 px-1 py-1">
131 <div className="-ml-1.5 pt-1">
132132 <Button size="xs" variant="ghost-muted" onClick={onAddRoute}>
133133 <PlusIcon className="size-3" />
134134 Add route
......@@ -169,20 +169,10 @@ function SortableRouteRow({
169169 ref={setNodeRef}
170170 style={{ transform: CSS.Translate.toString(transform), transition }}
171171 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",
173173 isDragging && "relative z-10 rounded-md bg-background shadow-md",
174174 )}
175175 >
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>
186176 <div className="min-w-0 flex-1">
187177 <p className="flex items-center gap-1.5 text-xs font-medium text-foreground">
188178 {label}
......@@ -199,6 +189,16 @@ function SortableRouteRow({
199189 </p>
200190 ) : null}
201191 </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>
202202 {removable ? (
203203 <Tooltip>
204204 <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 @@
11import {
22 DEFAULT_SERVER_SETTINGS,
3 EnvironmentAuthorizationError,
34 EnvironmentId,
45 ProjectId,
56 type ServerSettings,
67} from "@t3tools/contracts";
78import { describe, expect, it, vi } from "vite-plus/test";
89import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
10import * as Cause from "effect/Cause";
911import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
1012
1113import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
......@@ -410,7 +412,15 @@ describe("scoped settings writes", () => {
410412 const persistServer = vi
411413 .fn()
412414 .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 })
414424 .mockRejectedValueOnce(new Error("Disconnected during save"))
415425 .mockResolvedValueOnce({ _tag: "Success" });
416426 const result = await persistScopedSettingsPatch(
......@@ -419,6 +429,14 @@ describe("scoped settings writes", () => {
419429 vi.fn(),
420430 );
421431 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 ]);
422440 expect(result.failedEnvironments.map(({ label }) => label)).toEqual([
423441 server.label,
424442 third.label,
apps/web/src/components/settings/scopedSettings.ts+25-4
......@@ -19,6 +19,7 @@ import {
1919 type ProjectSettingSource,
2020} from "@t3tools/shared/projectSettings";
2121import * as Equal from "effect/Equal";
22import * as Cause from "effect/Cause";
2223
2324import type { ResolvedSettingsScope } from "./settingsScope";
2425
......@@ -394,7 +395,7 @@ export async function persistScopedSettingsPatch(
394395 persistServer: (input: {
395396 environmentId: EnvironmentId;
396397 input: { patch: ServerSettingsPatch };
397 }) => Promise<{ readonly _tag: "Success" | "Failure" }>,
398 }) => Promise<{ readonly _tag: "Success" | "Failure"; readonly cause?: Cause.Cause<unknown> }>,
398399 persistClient: (patch: ClientSettingsPatch) => void,
399400) {
400401 if (plan.hasClientWrite) persistClient(plan.clientPatch);
......@@ -403,12 +404,32 @@ export async function persistScopedSettingsPatch(
403404 persistServer({ environmentId, input: { patch } }),
404405 ),
405406 );
406 const failedEnvironments = plan.serverWrites.filter((_, index) => {
407 const failedEnvironments = plan.serverWrites.flatMap((environment, index) => {
407408 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";
409429 });
410430 return {
411431 failedEnvironments,
412 savedEnvironmentCount: plan.serverWrites.length - failedEnvironments.length,
432 savedEnvironments,
433 savedEnvironmentCount: savedEnvironments.length,
413434 };
414435}
apps/web/src/components/settings/settingsLayout.tsx+10-2
......@@ -1,6 +1,6 @@
11import { SettingsGroup } from "./SettingsGroup";
22import { AuthSettingsWriteScope } from "@t3tools/contracts";
3import { usePrimaryEnvironmentId } from "../../state/environments";
3import { usePrimaryEnvironmentId, usePrimaryEnvironment } from "../../state/environments";
44import { useEnvironmentScope, useEnvironmentsWithScope } from "../../state/session";
55import { InfoIcon, Undo2Icon } from "lucide-react";
66import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts";
......@@ -290,6 +290,7 @@ export function SettingsRow({
290290 const primarySettingsAvailable = usePrimarySettingsAvailable();
291291 const context = useOptionalSettingsScope();
292292 const primaryEnvironmentId = usePrimaryEnvironmentId();
293 const primaryEnvironment = usePrimaryEnvironment();
293294 const primaryCanWrite = useEnvironmentScope(primaryEnvironmentId, AuthSettingsWriteScope);
294295 const writableIds = useEnvironmentsWithScope(
295296 context?.connectedEnvironments ?? [],
......@@ -395,7 +396,14 @@ export function SettingsRow({
395396 unavailable && control
396397 ? inertControl(
397398 !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 }.`
399407 : context
400408 ? "Reconnect the selected environment to change this setting."
401409 : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE,
apps/web/src/components/settings/useScopedSettings.ts+10-3
......@@ -83,20 +83,27 @@ function useRunScopedPlan() {
8383 Cause.fail(
8484 new EnvironmentAuthorizationError({
8585 requiredScope: missing,
86 message: "This connection cannot change these settings.",
86 message: "This connection lacks permission to change these settings.",
8787 }),
8888 ),
8989 );
9090 return persistServer(request);
9191 },
9292 persistClientSettingsPatch,
93 ).then(({ failedEnvironments, savedEnvironmentCount }) => {
93 ).then(({ failedEnvironments, savedEnvironments, savedEnvironmentCount }) => {
9494 if (failedEnvironments.length === 0) return;
9595 toastManager.add({
9696 type: "error",
9797 title:
9898 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"),
100107 });
101108 });
102109 },
apps/web/src/hooks/useSettings.sync.test.tsx+49-1
......@@ -122,6 +122,54 @@ afterEach(async () => {
122122});
123123
124124describe("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
125173 it("dispatches to the primary and connected remote before the remote grant finishes loading", async () => {
126174 await mountEditor();
127175 saveSharedSettings();
......@@ -193,7 +241,7 @@ describe("shared settings writes", () => {
193241 expect(state.toast).toHaveBeenCalledExactlyOnceWith({
194242 type: "warning",
195243 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.",
197245 });
198246 }
199247 },
apps/web/src/hooks/useSettings.ts+65-14
......@@ -29,6 +29,10 @@ import {
2929 type UnifiedSettings,
3030} from "@t3tools/contracts/settings";
3131import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
32import {
33 isAtomCommandInterrupted,
34 squashAtomCommandFailure,
35} from "@t3tools/client-runtime/state/runtime";
3236import {
3337 filterSharedServerPatch,
3438 splitSharedServerPatch,
......@@ -469,11 +473,29 @@ function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray<E
469473function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
470474 // Mount this session even on pages without a visible permission-gated control.
471475 useEnvironmentScope(environmentId, AuthSettingsWriteScope);
472 const persistServerSettings = useAtomCommand(
473 serverEnvironment.updateSettings,
474 "server settings update",
475 );
476 const persist = useAtomCommand(serverEnvironment.updateSettings, {
477 label: "server settings update",
478 reportFailure: false,
479 });
476480 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 );
477499 const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(true);
478500 const updateSettings = useCallback(
479501 (patch: UnifiedSettingsPatch) => {
......@@ -488,7 +510,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
488510 toastManager.add({
489511 type: "warning",
490512 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"}.`,
492514 });
493515 }
494516 if (Object.keys(serverPatch).length > 0 && canWriteServerPatch) {
......@@ -518,8 +540,8 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
518540 if (environmentId) {
519541 targets.add(environmentId);
520542 }
521 let wroteToTarget = false;
522 let permissionDenied = false;
543 const writes: Array<Parameters<typeof persist>[0]> = [];
544 const deniedLabels: string[] = [];
523545 for (const targetId of targets) {
524546 const target = environments.find((candidate) => candidate.environmentId === targetId);
525547 const targetPatch = filterSharedServerPatch(
......@@ -537,23 +559,52 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
537559 readEnvironmentScope(targetId, scope),
538560 )
539561 ) {
540 permissionDenied = true;
562 deniedLabels.push(target?.label ?? targetId);
541563 continue;
542564 }
543 wroteToTarget = true;
544 void persistServerSettings({
565 writes.push({
545566 environmentId: targetId,
546567 input: { patch: targetPatch },
547568 });
548569 }
549 if (!wroteToTarget) {
570 if (writes.length === 0) {
550571 warnUnsaved(
551 permissionDenied
552 ? "This connection does not have permission to change these settings."
572 deniedLabels.length > 0
573 ? `This connection lacks permission to change settings on ${deniedLabels.join(", ")}.`
553574 : targets.size > 0
554575 ? "Update older servers to save this setting."
555576 : undefined,
556577 );
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 });
557608 }
558609 }
559610 }
......@@ -561,7 +612,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
561612 void persistClientSettingsPatch(clientPatch);
562613 }
563614 },
564 [environmentId, environments, persistServerSettings, sharedSettingsSyncTargetIds],
615 [environmentId, environments, persist, persistServerSettings, sharedSettingsSyncTargetIds],
565616 );
566617
567618 return updateSettings;
docs/user/remote-access.md+7
......@@ -91,6 +91,13 @@ or remove it. On mobile, open the machine under **Settings → Environments** an
9191choose **Edit**. Signing out of T3 Connect removes only that route; a machine
9292you 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
94101### Balance new threads across machines
95102
96103Auto balance is off by default. On web and desktop, enable it in
packages/shared/package.json+4
......@@ -3,6 +3,10 @@
33 "private": true,
44 "type": "module",
55 "exports": {
6 "./authScopeOptions": {
7 "types": "./src/authScopeOptions.ts",
8 "import": "./src/authScopeOptions.ts"
9 },
610 "./orchestrationV2ThreadError": {
711 "types": "./src/orchestrationV2ThreadError.ts",
812 "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];