authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 20:30:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 20:30:14-07:00
log365aa87982a4d81cc8e0c085e8d1a40ca7daecdc
tree08055d86149cd701686ab12cf845bb156c7a2ae1
parentcc41482df0232454af0cc5ac2f1afd984e342134
signature Signed by PGP key B5690EEEBB952194

fix(auth): keep old clients connected across scope changes (#10298)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

45 files changed, 870 insertions(+), 127 deletions(-)

apps/mobile/src/App.tsx+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { PermissionUpdateNotice } from "./components/PermissionUpdateNotice";
1import * as Linking from "expo-linking";2import * as Linking from "expo-linking";
2import * as SplashScreen from "expo-splash-screen";3import * as SplashScreen from "expo-splash-screen";
3import { useEffect } from "react";4import { useEffect } from "react";
...@@ -75,6 +76,7 @@ function AppContent() {...@@ -75,6 +76,7 @@ function AppContent() {
75 <>76 <>
76 <SplashScreenCoordinator />77 <SplashScreenCoordinator />
77 <SubscriptionUsageCoordinator />78 <SubscriptionUsageCoordinator />
79 <PermissionUpdateNotice />
78 <GestureHandlerRootView className="flex-1">80 <GestureHandlerRootView className="flex-1">
79 <KeyboardProvider statusBarTranslucent>81 <KeyboardProvider statusBarTranslucent>
80 <SafeAreaProvider>82 <SafeAreaProvider>
apps/mobile/src/components/PermissionUpdateNotice.tsx created+84
...@@ -0,0 +1,84 @@
1import { useAtomValue } from "@effect/atom-react";
2import { sessionHasLegacyPermissions } from "@t3tools/contracts";
3import { Atom } from "effect/reactivity";
4import * as SecureStore from "expo-secure-store";
5import { useEffect, useMemo, useRef, useState } from "react";
6import { Alert } from "react-native";
7
8import { useEnvironments } from "../state/environments";
9import { environmentSession } from "../state/session";
10
11const storageKey = "t3code.permission-update.v1";
12const dismissedThisLaunch = new Set<string>();
13const shownThisLaunch = new Set<string>();
14
15export function PermissionUpdateNotice() {
16 const { environments } = useEnvironments();
17 const [dismissed, setDismissed] = useState<ReadonlySet<string> | null>(null);
18 const showing = useRef(false);
19 const affected = useAtomValue(
20 useMemo(
21 () =>
22 Atom.make((get) =>
23 environments.filter(({ environmentId }) => {
24 const session = get(environmentSession.sessionStateAtom(environmentId));
25 return (
26 session._tag === "Success" &&
27 !session.waiting &&
28 sessionHasLegacyPermissions(session.value)
29 );
30 }),
31 ),
32 [environments],
33 ),
34 );
35
36 useEffect(() => {
37 let active = true;
38 void SecureStore.getItemAsync(storageKey)
39 .then((raw) => {
40 const value: unknown = raw === null ? [] : JSON.parse(raw);
41 if (active)
42 setDismissed(
43 new Set(
44 Array.isArray(value)
45 ? value.filter((id): id is string => typeof id === "string")
46 : [],
47 ),
48 );
49 })
50 .catch(() => {
51 if (active) setDismissed(new Set());
52 });
53 return () => {
54 active = false;
55 };
56 }, []);
57
58 useEffect(() => {
59 if (dismissed === null || showing.current) return;
60 const environment = affected.find(
61 ({ environmentId }) => !dismissed.has(environmentId) && !shownThisLaunch.has(environmentId),
62 );
63 if (!environment) return;
64 showing.current = true;
65 shownThisLaunch.add(environment.environmentId);
66 const dismiss = () => {
67 dismissedThisLaunch.add(environment.environmentId);
68 const next = new Set([...dismissed, ...dismissedThisLaunch]);
69 // Keep notices serial when several environments have old grants.
70 showing.current = false;
71 setDismissed(next);
72 void SecureStore.setItemAsync(storageKey, JSON.stringify([...next])).catch(() => {
73 // Remember for this launch even if persistent storage is unavailable.
74 });
75 };
76 Alert.alert(
77 `Permissions have changed for ${environment.label}`,
78 "This connection still uses the old permissions, so some actions may no longer be available. Pair again using a new link with the permissions you need.",
79 [{ text: "Got it", onPress: dismiss }],
80 { cancelable: false },
81 );
82 }, [affected, dismissed]);
83 return null;
84}
apps/mobile/src/features/settings/environment-maintenance.test.ts+27
...@@ -76,6 +76,33 @@ describe("environment maintenance access", () => {...@@ -76,6 +76,33 @@ describe("environment maintenance access", () => {
76 ).toBe(expected);76 ).toBe(expected);
77 });77 });
7878
79 it.each([
80 { permissions: ["environment:maintain"], expected: true },
81 { permissions: ["providers:manage"], expected: false },
82 { permissions: [], expected: false },
83 ] as const)(
84 "honors exact permissions over legacy scopes: $permissions",
85 ({ permissions, expected }) => {
86 expect(
87 canMaintainEnvironment(
88 {
89 authenticated: true,
90 auth: {
91 policy: "remote-reachable",
92 bootstrapMethods: [],
93 sessionMethods: [],
94 sessionCookieName: "session",
95 serverUpdateScope: "environment:maintain",
96 },
97 scopes: ["orchestration:operate"],
98 permissions,
99 },
100 true,
101 ),
102 ).toBe(expected);
103 },
104 );
105
79 it("requires remote desktop update support for desktop hosts", () => {106 it("requires remote desktop update support for desktop hosts", () => {
80 expect(supportsEnvironmentUpdate({})).toBe(false);107 expect(supportsEnvironmentUpdate({})).toBe(false);
81 expect(supportsEnvironmentUpdate({ serverSelfUpdate: "respawn" })).toBe(true);108 expect(supportsEnvironmentUpdate({ serverSelfUpdate: "respawn" })).toBe(true);
apps/mobile/src/features/settings/environment-maintenance.ts+3-3
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 AuthOrchestrationOperateScope,2 AuthEnvironmentMaintainScope,
3 sessionGrantsScope,
3 type AuthSessionState,4 type AuthSessionState,
4 type ExecutionEnvironmentCapabilities,5 type ExecutionEnvironmentCapabilities,
5 type ServerProvider,6 type ServerProvider,
...@@ -16,8 +17,7 @@ export function canMaintainEnvironment(session: AuthSessionState | null, connect...@@ -16,8 +17,7 @@ export function canMaintainEnvironment(session: AuthSessionState | null, connect
16 return (17 return (
17 connected &&18 connected &&
18 session?.authenticated === true &&19 session?.authenticated === true &&
19 session.scopes?.includes(session.auth.serverUpdateScope ?? AuthOrchestrationOperateScope) ===20 sessionGrantsScope(session, AuthEnvironmentMaintainScope)
20 true
21 );21 );
22}22}
2323
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+7-2
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 DEFAULT_TERMINAL_ID,4 DEFAULT_TERMINAL_ID,
5 EnvironmentId,5 EnvironmentId,
6 ThreadId,6 ThreadId,
7 sessionGrantsScope,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";9import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
9import { SymbolView } from "../../components/AppSymbol";10import { SymbolView } from "../../components/AppSymbol";
...@@ -279,9 +280,13 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -279,9 +280,13 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
279 const isAuthenticated =280 const isAuthenticated =
280 terminalSession.error === null && terminalSession.data?.authenticated === true;281 terminalSession.error === null && terminalSession.data?.authenticated === true;
281 const canOperateTerminal =282 const canOperateTerminal =
282 isAuthenticated && terminalSession.data?.scopes?.includes(AuthTerminalOperateScope) === true;283 isAuthenticated &&
284 terminalSession.data !== null &&
285 sessionGrantsScope(terminalSession.data, AuthTerminalOperateScope);
283 const canReadTerminal =286 const canReadTerminal =
284 isAuthenticated && terminalSession.data?.scopes?.includes(AuthTerminalReadScope) === true;287 isAuthenticated &&
288 terminalSession.data !== null &&
289 sessionGrantsScope(terminalSession.data, AuthTerminalReadScope);
285 const environment = useEnvironmentPresentation(routeEnvironmentId);290 const environment = useEnvironmentPresentation(routeEnvironmentId);
286 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";291 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";
287 const requestedTerminalId = firstRouteParam(params.terminalId);292 const requestedTerminalId = firstRouteParam(params.terminalId);
apps/mobile/src/state/mediaActions.ts+4-6
...@@ -3,8 +3,9 @@ import { useNavigation } from "@react-navigation/native";...@@ -3,8 +3,9 @@ import { useNavigation } from "@react-navigation/native";
3import type { MediaActionId } from "@t3tools/client-runtime/media-actions";3import type { MediaActionId } from "@t3tools/client-runtime/media-actions";
4import {4import {
5 AuthFilesystemReadScope,5 AuthFilesystemReadScope,
6 type AuthSessionState,
7 type EnvironmentId,6 type EnvironmentId,
7 sessionGrantsScope,
8 type SessionGrantInput,
8} from "@t3tools/contracts";9} from "@t3tools/contracts";
9import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links";10import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links";
10import * as Option from "effect/Option";11import * as Option from "effect/Option";
...@@ -21,11 +22,8 @@ import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";...@@ -21,11 +22,8 @@ import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
21import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";22import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
2223
23/** An explicit action may ask the server while its grant is still unresolved. */24/** An explicit action may ask the server while its grant is still unresolved. */
24function allowsHostMedia(session: Pick<AuthSessionState, "authenticated" | "scopes"> | null) {25function allowsHostMedia(session: SessionGrantInput | null) {
25 return (26 return session === null || sessionGrantsScope(session, AuthFilesystemReadScope);
26 session === null ||
27 (session.authenticated && session.scopes?.includes(AuthFilesystemReadScope) === true)
28 );
29}27}
3028
31function canReadHostMedia(environmentId: EnvironmentId | null): boolean {29function canReadHostMedia(environmentId: EnvironmentId | null): boolean {
apps/mobile/src/state/session.test.ts+15
...@@ -98,3 +98,18 @@ it("reacts to grant revocation, failure, and regrant without a connection list c...@@ -98,3 +98,18 @@ it("reacts to grant revocation, failure, and regrant without a connection list c
98 expect(appAtomRegistry.get(observed)).toEqual(new Set([secondary]));98 expect(appAtomRegistry.get(observed)).toEqual(new Set([secondary]));
99 expect(changes).not.toHaveLength(0);99 expect(changes).not.toHaveLength(0);
100});100});
101
102it("uses exact new-server permissions and keeps old-server grants usable", () => {
103 // A legacy representation must never override an explicitly narrowed grant.
104 appAtomRegistry.set(source(primary), AsyncResult.success({ ...session(true), permissions: [] }));
105 expect(useEnvironmentScope(primary, AuthOrchestrationOperateScope)).toBe(false);
106 expect(readEnvironmentScope(primary, AuthOrchestrationOperateScope)).toBe(false);
107 appAtomRegistry.set(
108 source(primary),
109 AsyncResult.success({ ...session(false), permissions: [AuthOrchestrationOperateScope] }),
110 );
111 expect(useEnvironmentScope(primary, AuthOrchestrationOperateScope)).toBe(true);
112 expect(readEnvironmentScope(primary, AuthOrchestrationOperateScope)).toBe(true);
113 appAtomRegistry.set(source(primary), AsyncResult.success(session(true)));
114 expect(useEnvironmentScope(primary, AuthOrchestrationOperateScope)).toBe(true);
115});
apps/mobile/src/state/session.ts+7-6
...@@ -1,6 +1,11 @@...@@ -1,6 +1,11 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";2import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";
3import type { AuthEnvironmentScope, AuthSessionState, EnvironmentId } from "@t3tools/contracts";3import {
4 type AuthEnvironmentScope,
5 type AuthSessionState,
6 type EnvironmentId,
7 sessionGrantsScope,
8} from "@t3tools/contracts";
4import * as Option from "effect/Option";9import * as Option from "effect/Option";
5import { AsyncResult, Atom } from "effect/reactivity";10import { AsyncResult, Atom } from "effect/reactivity";
6import { useMemo } from "react";11import { useMemo } from "react";
...@@ -17,11 +22,7 @@ function sessionHasScope(...@@ -17,11 +22,7 @@ function sessionHasScope(
17 scope: AuthEnvironmentScope,22 scope: AuthEnvironmentScope,
18): boolean {23): boolean {
19 const session = Option.getOrNull(AsyncResult.value(result));24 const session = Option.getOrNull(AsyncResult.value(result));
20 return (25 return result._tag !== "Failure" && session !== null && sessionGrantsScope(session, scope);
21 result._tag !== "Failure" &&
22 session?.authenticated === true &&
23 session.scopes?.includes(scope) === true
24 );
25}26}
2627
27/** Uses the selected environment's grant, including cached scopes during a refresh. */28/** Uses the selected environment's grant, including cached scopes during a refresh. */
apps/server/src/auth/EnvironmentAuth.test.ts+22-4
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts";2import {
3 authScopeResponse,
4 AuthAdministrativeScopes,
5 AuthStandardClientScopes,
6} from "@t3tools/contracts";
3import { expect, it } from "@effect/vitest";7import { expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
...@@ -102,6 +106,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {...@@ -102,6 +106,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
102 const authenticated = yield* serverAuth.authenticateHttpRequest(request);106 const authenticated = yield* serverAuth.authenticateHttpRequest(request);
103 expect(devExchange.cookieName).toMatch(/^t3_dev_session_/);107 expect(devExchange.cookieName).toMatch(/^t3_dev_session_/);
104 expect(devExchange.expireNormalCookie).toBe(true);108 expect(devExchange.expireNormalCookie).toBe(true);
109 expect(devExchange.response).toMatchObject(authScopeResponse(AuthAdministrativeScopes));
105 expect(authenticated.scopes).toEqual(["orchestration:read"]);110 expect(authenticated.scopes).toEqual(["orchestration:read"]);
106 }).pipe(111 }).pipe(
107 Effect.provide(112 Effect.provide(
...@@ -214,6 +219,16 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {...@@ -214,6 +219,16 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
214 expect(firstSession.subject).toBe("reusable-dev-token-child");219 expect(firstSession.subject).toBe("reusable-dev-token-child");
215 expect(secondSession.subject).toBe("reusable-dev-token-child");220 expect(secondSession.subject).toBe("reusable-dev-token-child");
216 expect((yield* sessions.verify(token)).subject).toBe("reusable-dev-token");221 expect((yield* sessions.verify(token)).subject).toBe("reusable-dev-token");
222 const before = yield* serverAuth.listClientSessions(firstSession.sessionId);
223 const denied = yield* serverAuth
224 .exchangeBootstrapCredentialForAccessToken(token, ["review:write"], requestMetadata)
225 .pipe(Effect.flip);
226 expect(denied._tag).toBe("ServerAuthScopeNotGrantedError");
227 const empty = yield* serverAuth
228 .exchangeBootstrapCredentialForAccessToken(token, [], requestMetadata)
229 .pipe(Effect.flip);
230 expect(empty._tag).toBe("ServerAuthScopeNotGrantedError");
231 expect(yield* serverAuth.listClientSessions(firstSession.sessionId)).toEqual(before);
217 }).pipe(232 }).pipe(
218 Effect.provide(233 Effect.provide(
219 layerEnvironmentAuth({234 layerEnvironmentAuth({
...@@ -241,7 +256,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {...@@ -241,7 +256,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
241 expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError");256 expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError");
242 expect(257 expect(
243 (yield* serverAuth.createBrowserSession(recovery.credential, requestMetadata)).response,258 (yield* serverAuth.createBrowserSession(recovery.credential, requestMetadata)).response,
244 ).toMatchObject({ authenticated: true, scopes: AuthAdministrativeScopes });259 ).toMatchObject({ authenticated: true, ...authScopeResponse(AuthAdministrativeScopes) });
245 }).pipe(260 }).pipe(
246 Effect.provide(261 Effect.provide(
247 layerEnvironmentAuth({262 layerEnvironmentAuth({
...@@ -355,7 +370,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {...@@ -355,7 +370,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
355 const error = yield* serverAuth370 const error = yield* serverAuth
356 .exchangeBootstrapCredentialForAccessToken(371 .exchangeBootstrapCredentialForAccessToken(
357 pairingCredential.credential,372 pairingCredential.credential,
358 ["orchestration:read", "access:write"],373 ["access:write"],
359 requestMetadata,374 requestMetadata,
360 )375 )
361 .pipe(Effect.flip);376 .pipe(Effect.flip);
...@@ -393,7 +408,10 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {...@@ -393,7 +408,10 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
393408
394 it.effect.each([409 it.effect.each([
395 { label: "omits scope", requestedScopes: undefined },410 { label: "omits scope", requestedScopes: undefined },
396 { label: "requests no scopes", requestedScopes: [] },411 {
412 label: "requests unsupported permissions alongside a granted one",
413 requestedScopes: ["orchestration:read", "access:write"] as const,
414 },
397 ])("inherits a constrained pairing grant when token exchange $label", ({ requestedScopes }) =>415 ])("inherits a constrained pairing grant when token exchange $label", ({ requestedScopes }) =>
398 Effect.gen(function* () {416 Effect.gen(function* () {
399 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;417 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
apps/server/src/auth/EnvironmentAuth.ts+14-6
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 type AuthPairingCredentialResult,16 type AuthPairingCredentialResult,
17 type AuthSessionId,17 type AuthSessionId,
18 type AuthSessionState,18 type AuthSessionState,
19 authScopeResponse,
19 type ServerAuthDescriptor,20 type ServerAuthDescriptor,
20 type ServerAuthSessionMethod,21 type ServerAuthSessionMethod,
21 type AuthWebSocketTicketResult,22 type AuthWebSocketTicketResult,
...@@ -773,7 +774,7 @@ export const make = Effect.gen(function* () {...@@ -773,7 +774,7 @@ export const make = Effect.gen(function* () {
773 ({774 ({
774 authenticated: true,775 authenticated: true,
775 auth: descriptor,776 auth: descriptor,
776 scopes: session.scopes,777 ...authScopeResponse(session.scopes),
777 sessionMethod: session.method,778 sessionMethod: session.method,
778 ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}),779 ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}),
779 }) satisfies AuthSessionState,780 }) satisfies AuthSessionState,
...@@ -800,7 +801,7 @@ export const make = Effect.gen(function* () {...@@ -800,7 +801,7 @@ export const make = Effect.gen(function* () {
800 ({801 ({
801 response: {802 response: {
802 authenticated: true,803 authenticated: true,
803 scopes: session.scopes,804 ...authScopeResponse(session.scopes),
804 sessionMethod: session.method,805 sessionMethod: session.method,
805 expiresAt: DateTime.toUtc(DateTime.add(now, { days: 30 })),806 expiresAt: DateTime.toUtc(DateTime.add(now, { days: 30 })),
806 } satisfies AuthBrowserSessionResult,807 } satisfies AuthBrowserSessionResult,
...@@ -841,7 +842,7 @@ export const make = Effect.gen(function* () {...@@ -841,7 +842,7 @@ export const make = Effect.gen(function* () {
841 return {842 return {
842 response: {843 response: {
843 authenticated: true,844 authenticated: true,
844 scopes: session.scopes,845 ...authScopeResponse(session.scopes),
845 sessionMethod: session.method,846 sessionMethod: session.method,
846 expiresAt: DateTime.toUtc(session.expiresAt),847 expiresAt: DateTime.toUtc(session.expiresAt),
847 } satisfies AuthBrowserSessionResult,848 } satisfies AuthBrowserSessionResult,
...@@ -890,15 +891,20 @@ export const make = Effect.gen(function* () {...@@ -890,15 +891,20 @@ export const make = Effect.gen(function* () {
890 };891 };
891892
892 const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] =893 const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] =
893 (credential, requestedScopesInput, requestMetadata, input) => {894 (credential, requestedScopes, requestMetadata, input) => {
894 const requestedScopes = requestedScopesInput?.length ? requestedScopesInput : undefined;
895 return resolveBootstrapGrant(credential, {895 return resolveBootstrapGrant(credential, {
896 ...input,896 ...input,
897 ...(requestedScopes !== undefined ? { requestedScopes } : {}),897 ...(requestedScopes !== undefined ? { requestedScopes } : {}),
898 }).pipe(898 }).pipe(
899 Effect.flatMap((grant) =>899 Effect.flatMap((grant) =>
900 Effect.gen(function* () {900 Effect.gen(function* () {
901 const grantedScopes = requestedScopes ?? grant.scopes;901 const grantedScopes =
902 requestedScopes === undefined
903 ? grant.scopes
904 : [...new Set(requestedScopes)].filter((scope) => grant.scopes.includes(scope));
905 if (grantedScopes.length === 0) {
906 return yield* new ServerAuthScopeNotGrantedError({});
907 }
902 return yield* sessions908 return yield* sessions
903 .issue({909 .issue({
904 method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token",910 method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token",
...@@ -1005,6 +1011,7 @@ export const make = Effect.gen(function* () {...@@ -1005,6 +1011,7 @@ export const make = Effect.gen(function* () {
1005 ];1011 ];
1006 return pairingLinks1012 return pairingLinks
1007 .filter((pairingLink) => !excludedSubjects.includes(pairingLink.subject))1013 .filter((pairingLink) => !excludedSubjects.includes(pairingLink.subject))
1014 .map((link) => ({ ...link, ...authScopeResponse(link.scopes) }))
1008 .toSorted(1015 .toSorted(
1009 (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds,1016 (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds,
1010 );1017 );
...@@ -1110,6 +1117,7 @@ export const make = Effect.gen(function* () {...@@ -1110,6 +1117,7 @@ export const make = Effect.gen(function* () {
1110 Effect.map((clientSessions) =>1117 Effect.map((clientSessions) =>
1111 clientSessions.map((clientSession): AuthClientSession => ({1118 clientSessions.map((clientSession): AuthClientSession => ({
1112 ...clientSession,1119 ...clientSession,
1120 ...authScopeResponse(clientSession.scopes),
1113 current: clientSession.sessionId === currentSessionId,1121 current: clientSession.sessionId === currentSessionId,
1114 })),1122 })),
1115 ),1123 ),
apps/server/src/auth/PairingGrantStore.ts+8-2
...@@ -504,7 +504,10 @@ export const make = Effect.gen(function* () {...@@ -504,7 +504,10 @@ export const make = Effect.gen(function* () {
504 ];504 ];
505 }505 }
506506
507 if (input?.requestedScopes?.some((scope) => !grant.scopes.includes(scope))) {507 if (
508 input?.requestedScopes !== undefined &&
509 !input.requestedScopes.some((scope) => grant.scopes.includes(scope))
510 ) {
508 return [511 return [
509 {512 {
510 _tag: "error",513 _tag: "error",
...@@ -608,7 +611,10 @@ export const make = Effect.gen(function* () {...@@ -608,7 +611,10 @@ export const make = Effect.gen(function* () {
608 return yield* new BootstrapCredentialProofKeyMismatchError({});611 return yield* new BootstrapCredentialProofKeyMismatchError({});
609 }612 }
610613
611 if (input?.requestedScopes?.some((scope) => !matching.value.scopes.includes(scope))) {614 if (
615 input?.requestedScopes !== undefined &&
616 !input.requestedScopes.some((scope) => matching.value.scopes.includes(scope))
617 ) {
612 return yield* new BootstrapCredentialScopeNotGrantedError({});618 return yield* new BootstrapCredentialScopeNotGrantedError({});
613 }619 }
614620
apps/server/src/auth/RpcAuthorization.test.ts+8-5
...@@ -247,7 +247,7 @@ describe("RPC scope middleware", () => {...@@ -247,7 +247,7 @@ describe("RPC scope middleware", () => {
247 yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip),247 yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip),
248 ).toMatchObject({248 ).toMatchObject({
249 _tag: "EnvironmentAuthorizationError",249 _tag: "EnvironmentAuthorizationError",
250 requiredScope: missing,250 requiredPermission: missing,
251 });251 });
252 expect(handled).toEqual([]);252 expect(handled).toEqual([]);
253 }).pipe(Effect.scoped),253 }).pipe(Effect.scoped),
...@@ -291,7 +291,7 @@ describe("settings mutation authorization", () => {...@@ -291,7 +291,7 @@ describe("settings mutation authorization", () => {
291 patch: { defaultRuntimeMode: "full-access" },291 patch: { defaultRuntimeMode: "full-access" },
292 providerInstanceMutation,292 providerInstanceMutation,
293 }).pipe(Effect.flip),293 }).pipe(Effect.flip),
294 ).toMatchObject({ requiredScope: AuthSettingsWriteScope });294 ).toMatchObject({ requiredPermission: AuthSettingsWriteScope });
295 expect(handled).toBe(1);295 expect(handled).toBe(1);
296 }).pipe(Effect.scoped),296 }).pipe(Effect.scoped),
297 );297 );
...@@ -317,7 +317,7 @@ describe("settings mutation authorization", () => {...@@ -317,7 +317,7 @@ describe("settings mutation authorization", () => {
317 patch: {},317 patch: {},
318 providerInstanceMutation,318 providerInstanceMutation,
319 }).pipe(Effect.flip),319 }).pipe(Effect.flip),
320 ).toMatchObject({ requiredScope: AuthProvidersManageScope });320 ).toMatchObject({ requiredPermission: AuthProvidersManageScope });
321 expect(handled).toBe(false);321 expect(handled).toBe(false);
322 }).pipe(Effect.scoped),322 }).pipe(Effect.scoped),
323 );323 );
...@@ -355,7 +355,7 @@ it.effect("requires task permission before attaching a prepared worktree to a th...@@ -355,7 +355,7 @@ it.effect("requires task permission before attaching a prepared worktree to a th
355 mode: "worktree",355 mode: "worktree",
356 threadId: ThreadId.make("thread"),356 threadId: ThreadId.make("thread"),
357 }).pipe(Effect.flip),357 }).pipe(Effect.flip),
358 ).toMatchObject({ requiredScope: AuthOrchestrationOperateScope });358 ).toMatchObject({ requiredPermission: AuthOrchestrationOperateScope });
359 expect(handled).toBe(false);359 expect(handled).toBe(false);
360 }).pipe(Effect.scoped),360 }).pipe(Effect.scoped),
361);361);
...@@ -394,7 +394,10 @@ it.effect("separates host file URLs from readable attachment URLs", () =>...@@ -394,7 +394,10 @@ it.effect("separates host file URLs from readable attachment URLs", () =>
394 ] as const) {394 ] as const) {
395 expect(395 expect(
396 yield* client[WS_METHODS.assetsCreateUrl]({ resource }).pipe(Effect.flip),396 yield* client[WS_METHODS.assetsCreateUrl]({ resource }).pipe(Effect.flip),
397 ).toMatchObject({ requiredScope: AuthFilesystemReadScope });397 ).toMatchObject({
398 requiredScope: AuthOrchestrationReadScope,
399 requiredPermission: AuthFilesystemReadScope,
400 });
398 }401 }
399 expect(handled).toBe(1);402 expect(handled).toBe(1);
400 }).pipe(Effect.scoped),403 }).pipe(Effect.scoped),
apps/server/src/auth/RpcAuthorization.ts+2-1
...@@ -2,6 +2,7 @@ import {...@@ -2,6 +2,7 @@ import {
2 CLIENT_GUARDED_RPC_SCOPES,2 CLIENT_GUARDED_RPC_SCOPES,
3 type DeviceListInput,3 type DeviceListInput,
4 clientRpcRequiredScopes,4 clientRpcRequiredScopes,
5 authScopeRequiredResponse,
5 AssetCreateUrlInput,6 AssetCreateUrlInput,
6 AuthAccessReadScope,7 AuthAccessReadScope,
7 ServerSettingsPatch,8 ServerSettingsPatch,
...@@ -213,7 +214,7 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope...@@ -213,7 +214,7 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope
213export const rpcAuthorizationError = (requiredScope: AuthEnvironmentScope) =>214export const rpcAuthorizationError = (requiredScope: AuthEnvironmentScope) =>
214 new EnvironmentAuthorizationError({215 new EnvironmentAuthorizationError({
215 message: `The authenticated token is missing required scope: ${requiredScope}.`,216 message: `The authenticated token is missing required scope: ${requiredScope}.`,
216 requiredScope,217 ...authScopeRequiredResponse(requiredScope),
217 });218 });
218219
219const SettingsUpdate = Schema.Struct({220const SettingsUpdate = Schema.Struct({
apps/server/src/auth/SessionStore.test.ts+29
...@@ -20,6 +20,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts";...@@ -20,6 +20,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts";
20import * as AuthSessions from "../persistence/AuthSessions.ts";20import * as AuthSessions from "../persistence/AuthSessions.ts";
21import * as SessionStore from "./SessionStore.ts";21import * as SessionStore from "./SessionStore.ts";
22import * as ServerSecretStore from "./ServerSecretStore.ts";22import * as ServerSecretStore from "./ServerSecretStore.ts";
23import { base64UrlDecodeUtf8, base64UrlEncode, signPayload } from "./utils.ts";
2324
24const layerServerConfig = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>25const layerServerConfig = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>
25 Layer.effect(26 Layer.effect(
...@@ -290,6 +291,34 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {...@@ -290,6 +291,34 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
290 expect((yield* sessions.verify(uncapped.token)).runtimeModeCeiling).toBeUndefined();291 expect((yield* sessions.verify(uncapped.token)).runtimeModeCeiling).toBeUndefined();
291 }).pipe(Effect.provide(layerSessionStore())),292 }).pipe(Effect.provide(layerSessionStore())),
292 );293 );
294 it.effect("keeps recorded scopes unchanged for both token versions", () =>
295 Effect.gen(function* () {
296 const sessions = yield* SessionStore.SessionStore;
297 const secrets = yield* ServerSecretStore.ServerSecretStore;
298 const legacyScopes = ["orchestration:read", "terminal:operate", "review:write"] as const;
299 const issued = yield* sessions.issue({ subject: "one-time-token", scopes: legacyScopes });
300 // Accept prerelease v2 credentials without widening their recorded grant.
301 const [encodedPayload] = issued.token.split(".");
302 const currentClaims = base64UrlDecodeUtf8(encodedPayload!);
303 expect(currentClaims).toContain('"v":1');
304 const legacyPayload = base64UrlEncode(currentClaims.replace('"v":1', '"v":2'));
305 const secret = yield* secrets.getOrCreateRandom("server-signing-key", 32);
306 const legacyToken = `${legacyPayload}.${signPayload(legacyPayload, secret)}`;
307
308 expect((yield* sessions.verify(issued.token)).scopes).toEqual(legacyScopes);
309 expect((yield* sessions.verify(legacyToken)).scopes).toEqual(legacyScopes);
310 }).pipe(
311 Effect.provide(
312 SessionStore.layer.pipe(
313 Layer.provideMerge(ServerSecretStore.layer),
314 Layer.provide(SqlitePersistence.layerMemory),
315 Layer.provide(layerServerEnvironment(EnvironmentId.make("test-environment"))),
316 Layer.provide(layerServerConfig()),
317 ),
318 ),
319 ),
320 );
321
293 it.effect("rejects malformed session tokens", () =>322 it.effect("rejects malformed session tokens", () =>
294 Effect.gen(function* () {323 Effect.gen(function* () {
295 const sessions = yield* SessionStore.SessionStore;324 const sessions = yield* SessionStore.SessionStore;
apps/server/src/auth/SessionStore.ts+2-1
...@@ -428,8 +428,9 @@ export class SessionStore extends Context.Service<...@@ -428,8 +428,9 @@ export class SessionStore extends Context.Service<
428const SIGNING_SECRET_NAME = "server-signing-key";428const SIGNING_SECRET_NAME = "server-signing-key";
429const DEFAULT_SESSION_TTL = Duration.days(30);429const DEFAULT_SESSION_TTL = Duration.days(30);
430const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5);430const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5);
431
431const SessionClaims = Schema.Struct({432const SessionClaims = Schema.Struct({
432 v: Schema.Literal(1),433 v: Schema.Literals([1, 2]),
433 kind: Schema.Literal("session"),434 kind: Schema.Literal("session"),
434 sid: AuthSessionId,435 sid: AuthSessionId,
435 sub: Schema.String,436 sub: Schema.String,
apps/server/src/auth/http.test.ts+19
...@@ -1,6 +1,9 @@...@@ -1,6 +1,9 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import {2import {
3 AuthSessionId,3 AuthSessionId,
4 AuthTokenExchangeGrantType,
5 AuthEnvironmentBootstrapTokenType,
6 AuthAccessTokenType,
4 EnvironmentAuthenticatedAuth,7 EnvironmentAuthenticatedAuth,
5 EnvironmentHttpApi,8 EnvironmentHttpApi,
6} from "@t3tools/contracts";9} from "@t3tools/contracts";
...@@ -100,6 +103,22 @@ it.effect("sets the selected browser session cookies through the HTTP route", ()...@@ -100,6 +103,22 @@ it.effect("sets the selected browser session cookies through the HTTP route", ()
100 requestContext,103 requestContext,
101 );104 );
102 expect(devResponse.status).toBe(200);105 expect(devResponse.status).toBe(200);
106 const retiredScopeResponse = await environmentA.handler(
107 new Request("http://127.0.0.1/oauth/token", {
108 method: "POST",
109 body: new URLSearchParams({
110 grant_type: AuthTokenExchangeGrantType,
111 subject_token: DEV_TOKEN,
112 subject_token_type: AuthEnvironmentBootstrapTokenType,
113 requested_token_type: AuthAccessTokenType,
114 scope: "review:write",
115 }),
116 }),
117 requestContext,
118 );
119 expect(retiredScopeResponse.status).toBe(400);
120 expect(await retiredScopeResponse.json()).toMatchObject({ reason: "invalid_scope" });
121
103 const devCookies = devResponse.headers.getSetCookie();122 const devCookies = devResponse.headers.getSetCookie();
104 const devCookie = devCookies.find((cookie) => cookie.startsWith("t3_dev_session_"));123 const devCookie = devCookies.find((cookie) => cookie.startsWith("t3_dev_session_"));
105 expect(devCookie).toContain("HttpOnly");124 expect(devCookie).toContain("HttpOnly");
apps/server/src/auth/http.ts+7-7
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 authScopeRequiredResponse,
2 AuthAccessReadScope,3 AuthAccessReadScope,
3 AuthAccessWriteScope,4 AuthAccessWriteScope,
4 AuthStandardClientScopes,5 AuthStandardClientScopes,
...@@ -18,11 +19,13 @@ import {...@@ -18,11 +19,13 @@ import {
18 EnvironmentAuthenticatedPrincipal,19 EnvironmentAuthenticatedPrincipal,
19} from "@t3tools/contracts";20} from "@t3tools/contracts";
20import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts";21import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts";
21import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope";22import { parseOAuthScope } from "@t3tools/shared/oauthScope";
22import { causeErrorTag } from "@t3tools/shared/observability";23import { causeErrorTag } from "@t3tools/shared/observability";
23import * as Clock from "effect/Clock";24import * as Clock from "effect/Clock";
24import * as DateTime from "effect/DateTime";25import * as DateTime from "effect/DateTime";
25import * as Effect from "effect/Effect";26import * as Effect from "effect/Effect";
27import * as Schema from "effect/Schema";
28import { identity } from "effect/Function";
26import * as Layer from "effect/Layer";29import * as Layer from "effect/Layer";
27import * as Cookies from "effect/http/Cookies";30import * as Cookies from "effect/http/Cookies";
28import * as HttpEffect from "effect/http/HttpEffect";31import * as HttpEffect from "effect/http/HttpEffect";
...@@ -122,7 +125,7 @@ export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope...@@ -122,7 +125,7 @@ export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope
122 Effect.fail(125 Effect.fail(
123 new EnvironmentScopeRequiredError({126 new EnvironmentScopeRequiredError({
124 code: "insufficient_scope",127 code: "insufficient_scope",
125 requiredScope,128 ...authScopeRequiredResponse(requiredScope),
126 traceId,129 traceId,
127 }),130 }),
128 ),131 ),
...@@ -356,11 +359,8 @@ export const layer = HttpApiBuilder.group(...@@ -356,11 +359,8 @@ export const layer = HttpApiBuilder.group(
356 const requestedScopes =359 const requestedScopes =
357 args.payload.scope === undefined360 args.payload.scope === undefined
358 ? undefined361 ? undefined
359 : parseAllowedOAuthScope({362 : (parseOAuthScope(args.payload.scope)?.filter(Schema.is(AuthGrantScope)) ?? null);
360 value: args.payload.scope,363 if (requestedScopes === null || requestedScopes?.length === 0) {
361 allowedScopes: new Set(AuthGrantScope.literals),
362 });
363 if (requestedScopes === null) {
364 return yield* failEnvironmentInvalidRequest("invalid_scope");364 return yield* failEnvironmentInvalidRequest("invalid_scope");
365 }365 }
366 const proofKeyThumbprint = args.headers.dpop366 const proofKeyThumbprint = args.headers.dpop
apps/server/src/cliAuthFormat.ts+2-2
...@@ -77,7 +77,7 @@ export function formatPairingCredentialList(...@@ -77,7 +77,7 @@ export function formatPairingCredentialList(
77 credentials.map((credential) => ({77 credentials.map((credential) => ({
78 id: credential.id,78 id: credential.id,
79 ...(credential.label ? { label: credential.label } : {}),79 ...(credential.label ? { label: credential.label } : {}),
80 scopes: credential.scopes,80 scopes: credential.permissions ?? credential.scopes,
81 createdAt: toIsoString(credential.createdAt),81 createdAt: toIsoString(credential.createdAt),
82 expiresAt: toIsoString(credential.expiresAt),82 expiresAt: toIsoString(credential.expiresAt),
83 })),83 })),
...@@ -95,7 +95,7 @@ export function formatPairingCredentialList(...@@ -95,7 +95,7 @@ export function formatPairingCredentialList(
95 .map((credential) =>95 .map((credential) =>
96 [96 [
97 `${credential.id}${credential.label ? ` (${credential.label})` : ""}`,97 `${credential.id}${credential.label ? ` (${credential.label})` : ""}`,
98 ` scopes: ${credential.scopes.join(" ")}`,98 ` scopes: ${(credential.permissions ?? credential.scopes).join(" ")}`,
99 ` created: ${toIsoString(credential.createdAt)}`,99 ` created: ${toIsoString(credential.createdAt)}`,
100 ` expires: ${toIsoString(credential.expiresAt)}`,100 ` expires: ${toIsoString(credential.expiresAt)}`,
101 ].join(newline),101 ].join(newline),
apps/server/src/persistence/AuthPairingLinks.ts+3-3
...@@ -172,13 +172,13 @@ export const make = Effect.gen(function* () {...@@ -172,13 +172,13 @@ export const make = Effect.gen(function* () {
172 proof_key_thumbprint IS NULL172 proof_key_thumbprint IS NULL
173 OR proof_key_thumbprint = ${proofKeyThumbprint}173 OR proof_key_thumbprint = ${proofKeyThumbprint}
174 )174 )
175 AND NOT EXISTS (175 AND (${requestedScopes === undefined} OR EXISTS (
176 SELECT 1176 SELECT 1
177 FROM json_each(${JSON.stringify(requestedScopes ?? [])}) AS requested177 FROM json_each(${JSON.stringify(requestedScopes ?? [])}) AS requested
178 WHERE requested.value NOT IN (178 WHERE requested.value IN (
179 SELECT value FROM json_each(auth_pairing_links.scopes)179 SELECT value FROM json_each(auth_pairing_links.scopes)
180 )180 )
181 )181 ))
182 RETURNING182 RETURNING
183 id AS "id",183 id AS "id",
184 credential AS "credential",184 credential AS "credential",
apps/server/src/ws.ts+3-1
...@@ -24,6 +24,7 @@ import {...@@ -24,6 +24,7 @@ import {
24 DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL,24 DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL,
25 AcpRegistryOperationError,25 AcpRegistryOperationError,
26 CommandId,26 CommandId,
27 authScopeResponse,
27 AuthAccessStreamError,28 AuthAccessStreamError,
28 type AuthAccessStreamEvent,29 type AuthAccessStreamEvent,
29 AuthOrchestrationOperateScope,30 AuthOrchestrationOperateScope,
...@@ -561,7 +562,7 @@ function toAuthAccessStreamEvent(...@@ -561,7 +562,7 @@ function toAuthAccessStreamEvent(
561 version: 1,562 version: 1,
562 revision,563 revision,
563 type: "pairingLinkUpserted",564 type: "pairingLinkUpserted",
564 payload: change.pairingLink,565 payload: { ...change.pairingLink, ...authScopeResponse(change.pairingLink.scopes) },
565 };566 };
566 case "pairingLinkRemoved":567 case "pairingLinkRemoved":
567 return {568 return {
...@@ -577,6 +578,7 @@ function toAuthAccessStreamEvent(...@@ -577,6 +578,7 @@ function toAuthAccessStreamEvent(
577 type: "clientUpserted",578 type: "clientUpserted",
578 payload: {579 payload: {
579 ...change.clientSession,580 ...change.clientSession,
581 ...authScopeResponse(change.clientSession.scopes),
580 current: change.clientSession.sessionId === currentSessionId,582 current: change.clientSession.sessionId === currentSessionId,
581 },583 },
582 };584 };
apps/web/src/components/PermissionUpdateNotice.test.tsx created+111
...@@ -0,0 +1,111 @@
1import { act, create, type ReactTestRenderer } from "react-test-renderer";
2import { beforeEach, afterEach, expect, it, vi } from "vite-plus/test";
3
4const state = vi.hoisted(() => ({
5 id: 0,
6 saved: false,
7 session: {
8 _tag: "Success",
9 waiting: false,
10 value: { authenticated: true, permissions: ["orchestration:operate"] },
11 },
12 add: vi.fn((_notice: unknown) => "notice"),
13 close: vi.fn(),
14 save: vi.fn(),
15 navigate: vi.fn(),
16}));
17vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.session }));
18vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate }));
19vi.mock("../state/environments", () => ({
20 useEnvironments: () => ({
21 environments: [{ environmentId: `env-${state.id}`, label: "Work laptop" }],
22 }),
23}));
24vi.mock("../state/session", () => ({ environmentSession: { sessionStateAtom: () => null } }));
25vi.mock("../hooks/useLocalStorage", () => ({
26 getLocalStorageItem: () => state.saved,
27 setLocalStorageItem: (...args: unknown[]) => state.save(...args),
28}));
29vi.mock("./ui/toast", () => ({ toastManager: { add: state.add, close: state.close } }));
30import { PermissionUpdateNotice } from "./PermissionUpdateNotice";
31
32let renderer: ReactTestRenderer;
33beforeEach(() => {
34 state.id++;
35 state.saved = false;
36 state.session = {
37 _tag: "Success",
38 waiting: false,
39 value: { authenticated: true, permissions: ["orchestration:operate"] },
40 };
41 vi.clearAllMocks();
42});
43afterEach(async () => {
44 if (renderer) await act(async () => renderer.unmount());
45});
46async function render() {
47 await act(async () => {
48 renderer = create(<PermissionUpdateNotice />);
49 });
50}
51
52it("keeps the notice visible and persists dismissal, with a route to pairing settings", async () => {
53 await render();
54 const notice = state.add.mock.calls[0]![0] as unknown as {
55 timeout: number;
56 onClose: () => void;
57 actionProps: { onClick: () => void };
58 };
59 expect(notice.timeout).toBe(0);
60 expect(state.save).not.toHaveBeenCalled();
61 notice.actionProps.onClick();
62 expect(state.navigate).toHaveBeenCalledWith({ to: "/settings/connections" });
63 expect(state.close).toHaveBeenCalledWith("notice");
64 expect(state.save).toHaveBeenCalledWith(
65 `t3code:permission-update:v1:env-${state.id}`,
66 true,
67 expect.anything(),
68 );
69});
70it("does not repeat on session refresh or remount", async () => {
71 await render();
72 await act(async () => renderer.unmount());
73 state.session = { ...state.session };
74 await render();
75 expect(state.add).toHaveBeenCalledTimes(1);
76});
77it("skips a notice dismissed on an earlier launch", async () => {
78 state.saved = true;
79 await render();
80 expect(state.add).not.toHaveBeenCalled();
81});
82it("waits for a successful, settled session check", async () => {
83 state.session._tag = "Failure";
84 await render();
85 expect(state.add).not.toHaveBeenCalled();
86 state.session = { ...state.session, _tag: "Success", waiting: true };
87 await act(async () => renderer.update(<PermissionUpdateNotice />));
88 expect(state.add).not.toHaveBeenCalled();
89 state.session = { ...state.session, waiting: false };
90 await act(async () => renderer.update(<PermissionUpdateNotice />));
91 expect(state.add).toHaveBeenCalledTimes(1);
92});
93it("does not warn for a granular grant", async () => {
94 state.session.value.permissions.push("filesystem:read");
95 await render();
96 expect(state.add).not.toHaveBeenCalled();
97});
98
99it("persists the secondary dismissal without relying on the toast close callback", async () => {
100 await render();
101 const notice = state.add.mock.calls[0]![0] as {
102 data: { secondaryActionProps: { onClick: () => void } };
103 };
104 notice.data.secondaryActionProps.onClick();
105 expect(state.save).toHaveBeenCalledWith(
106 `t3code:permission-update:v1:env-${state.id}`,
107 true,
108 expect.anything(),
109 );
110 expect(state.close).toHaveBeenCalledWith("notice");
111});
apps/web/src/components/PermissionUpdateNotice.tsx created+78
...@@ -0,0 +1,78 @@
1import { useAtomValue } from "@effect/atom-react";
2import { sessionHasLegacyPermissions, type EnvironmentId } from "@t3tools/contracts";
3import { useNavigate } from "@tanstack/react-router";
4import * as Schema from "effect/Schema";
5import { useEffect } from "react";
6
7import { getLocalStorageItem, setLocalStorageItem } from "../hooks/useLocalStorage";
8import { useEnvironments } from "../state/environments";
9import { environmentSession } from "../state/session";
10import { toastManager } from "./ui/toast";
11
12// Keep an active toast across remounts, including Strict Mode effect replay.
13const shown = new Set<EnvironmentId>();
14
15function EnvironmentPermissionNotice({
16 environmentId,
17 label,
18}: {
19 environmentId: EnvironmentId;
20 label: string;
21}) {
22 const session = useAtomValue(environmentSession.sessionStateAtom(environmentId));
23 const navigate = useNavigate();
24 useEffect(() => {
25 if (
26 session._tag !== "Success" ||
27 session.waiting ||
28 !sessionHasLegacyPermissions(session.value)
29 )
30 return;
31 if (shown.has(environmentId)) return;
32 const key = `t3code:permission-update:v1:${environmentId}`;
33 try {
34 if (getLocalStorageItem(key, Schema.Boolean)) return;
35 } catch {
36 // An unavailable store must not prevent the notice.
37 }
38 shown.add(environmentId);
39 const persistDismissal = () => {
40 try {
41 setLocalStorageItem(key, true, Schema.Boolean);
42 } catch {
43 // The in-memory marker still prevents repeats during this launch.
44 }
45 };
46 const dismiss = () => {
47 persistDismissal();
48 toastManager.close(id);
49 };
50 const id = toastManager.add({
51 title: `Permissions have changed for ${label}`,
52 description:
53 "This connection still uses the old permissions, so some actions may no longer be available. Pair again using a new link with the permissions you need.",
54 timeout: 0,
55 onClose: persistDismissal,
56 actionProps: {
57 children: "Open Connections",
58 onClick: () => {
59 dismiss();
60 void navigate({ to: "/settings/connections" });
61 },
62 },
63 data: {
64 actionLayout: "stacked-end",
65 secondaryActionProps: { children: "Dismiss", onClick: dismiss },
66 secondaryActionVariant: "ghost",
67 },
68 });
69 }, [environmentId, label, navigate, session]);
70 return null;
71}
72
73export function PermissionUpdateNotice() {
74 const { environments } = useEnvironments();
75 return environments.map(({ environmentId, label }) => (
76 <EnvironmentPermissionNotice key={environmentId} environmentId={environmentId} label={label} />
77 ));
78}
apps/web/src/components/ServerUpdateAction.tsx+6-8
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { AuthOrchestrationOperateScope, type AuthSessionState } from "@t3tools/contracts";2import {
3 AuthEnvironmentMaintainScope,
4 type AuthSessionState,
5 sessionGrantsScope,
6} from "@t3tools/contracts";
3import type { AsyncResult } from "effect/reactivity";7import type { AsyncResult } from "effect/reactivity";
4import { environmentSession } from "~/state/session";8import { environmentSession } from "~/state/session";
5import type {9import type {
...@@ -162,13 +166,7 @@ export function ServerUpdatesAction({...@@ -162,13 +166,7 @@ export function ServerUpdatesAction({
162166
163function canUpdateServer(result: AsyncResult.AsyncResult<AuthSessionState, unknown>): boolean {167function canUpdateServer(result: AsyncResult.AsyncResult<AuthSessionState, unknown>): boolean {
164 if (result._tag !== "Success" || !result.value.authenticated) return false;168 if (result._tag !== "Success" || !result.value.authenticated) return false;
165 const session = result.value;169 return sessionGrantsScope(result.value, AuthEnvironmentMaintainScope);
166 // Only self-update bridges the old authorization protocol. Upgraded servers
167 // advertise the new scope even when this client's grant predates it.
168 return (
169 session.scopes?.includes(session.auth.serverUpdateScope ?? AuthOrchestrationOperateScope) ===
170 true
171 );
172}170}
173171
174/**172/**
apps/web/src/components/media/MediaActions.tsx+4-5
...@@ -11,6 +11,8 @@ import {...@@ -11,6 +11,8 @@ import {
11 type AuthSessionState,11 type AuthSessionState,
12 type ContextMenuItem,12 type ContextMenuItem,
13 type EnvironmentId,13 type EnvironmentId,
14 sessionGrantsScope,
15 type SessionGrantInput,
14} from "@t3tools/contracts";16} from "@t3tools/contracts";
15import * as Option from "effect/Option";17import * as Option from "effect/Option";
16import { AsyncResult } from "effect/reactivity";18import { AsyncResult } from "effect/reactivity";
...@@ -43,11 +45,8 @@ function mediaFileName(source: MediaActionSource): string {...@@ -43,11 +45,8 @@ function mediaFileName(source: MediaActionSource): string {
43}45}
4446
45/** An explicit action may ask the server while its grant is still unresolved. */47/** An explicit action may ask the server while its grant is still unresolved. */
46function allowsHostMedia(session: Pick<AuthSessionState, "authenticated" | "scopes"> | null) {48function allowsHostMedia(session: SessionGrantInput | null) {
47 return (49 return session === null || sessionGrantsScope(session, AuthFilesystemReadScope);
48 session === null ||
49 (session.authenticated && session.scopes?.includes(AuthFilesystemReadScope) === true)
50 );
51}50}
5251
53function canReadHostMedia(environmentId: EnvironmentId | null): boolean {52function canReadHostMedia(environmentId: EnvironmentId | null): boolean {
apps/web/src/components/settings/ConnectionsSettings.tsx+3-1
...@@ -534,6 +534,7 @@ function sortDesktopClientSessions(sessions: ReadonlyArray<ServerClientSessionRe...@@ -534,6 +534,7 @@ function sortDesktopClientSessions(sessions: ReadonlyArray<ServerClientSessionRe
534function toDesktopPairingLinkRecord(pairingLink: AuthPairingLink): ServerPairingLinkRecord {534function toDesktopPairingLinkRecord(pairingLink: AuthPairingLink): ServerPairingLinkRecord {
535 return {535 return {
536 ...pairingLink,536 ...pairingLink,
537 scopes: pairingLink.permissions ?? pairingLink.scopes,
537 createdAt: DateTime.formatIso(pairingLink.createdAt),538 createdAt: DateTime.formatIso(pairingLink.createdAt),
538 expiresAt: DateTime.formatIso(pairingLink.expiresAt),539 expiresAt: DateTime.formatIso(pairingLink.expiresAt),
539 };540 };
...@@ -542,6 +543,7 @@ function toDesktopPairingLinkRecord(pairingLink: AuthPairingLink): ServerPairing...@@ -542,6 +543,7 @@ function toDesktopPairingLinkRecord(pairingLink: AuthPairingLink): ServerPairing
542function toDesktopClientSessionRecord(clientSession: AuthClientSession): ServerClientSessionRecord {543function toDesktopClientSessionRecord(clientSession: AuthClientSession): ServerClientSessionRecord {
543 return {544 return {
544 ...clientSession,545 ...clientSession,
546 scopes: clientSession.permissions ?? clientSession.scopes,
545 issuedAt: DateTime.formatIso(clientSession.issuedAt),547 issuedAt: DateTime.formatIso(clientSession.issuedAt),
546 expiresAt: DateTime.formatIso(clientSession.expiresAt),548 expiresAt: DateTime.formatIso(clientSession.expiresAt),
547 lastConnectedAt:549 lastConnectedAt:
...@@ -2108,7 +2110,7 @@ export function ConnectionsSettings() {...@@ -2108,7 +2110,7 @@ export function ConnectionsSettings() {
2108 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;2110 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
2109 const primarySessionState = usePrimarySessionState();2111 const primarySessionState = usePrimarySessionState();
2110 const currentSessionScopes = primarySessionState.data?.authenticated2112 const currentSessionScopes = primarySessionState.data?.authenticated
2111 ? (primarySessionState.data.scopes ?? null)2113 ? (primarySessionState.data.permissions ?? primarySessionState.data.scopes ?? null)
2112 : null;2114 : null;
2113 const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null);2115 const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null);
2114 // Catalog order is the order the machines were added; rows never jump when2116 // Catalog order is the order the machines were added; rows never jump when
apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts+15-1
...@@ -231,12 +231,26 @@ describe("remote operate access", () => {...@@ -231,12 +231,26 @@ describe("remote operate access", () => {
231 it("does not treat the old orchestration grant as provider management", () => {231 it("does not treat the old orchestration grant as provider management", () => {
232 expect(232 expect(
233 resolveRemoteOperateAccess({233 resolveRemoteOperateAccess({
234 session: { authenticated: true, scopes: ["orchestration:operate"] },234 session: {
235 authenticated: true,
236 scopes: ["orchestration:operate"],
237 auth: { serverUpdateScope: "environment:maintain" },
238 },
235 isPending: false,239 isPending: false,
236 hasError: false,240 hasError: false,
237 }),241 }),
238 ).toBe("denied");242 ).toBe("denied");
239 });243 });
244
245 it("accepts the orchestration grant from a server that predates providers:manage", () => {
246 expect(
247 resolveRemoteOperateAccess({
248 session: { authenticated: true, scopes: ["orchestration:operate"], auth: {} },
249 isPending: false,
250 hasError: false,
251 }),
252 ).toBe("granted");
253 });
240 it("derives access from the environment session's granted scopes", () => {254 it("derives access from the environment session's granted scopes", () => {
241 expect(255 expect(
242 resolveRemoteOperateAccess({256 resolveRemoteOperateAccess({
apps/web/src/components/settings/ProviderSettingsPanel.logic.ts+6-6
...@@ -3,6 +3,8 @@ import {...@@ -3,6 +3,8 @@ import {
3 AuthProvidersManageScope,3 AuthProvidersManageScope,
4 type AuthSessionState,4 type AuthSessionState,
5 type EnvironmentId,5 type EnvironmentId,
6 sessionGrantsScope,
7 type SessionGrantInput,
6} from "@t3tools/contracts";8} from "@t3tools/contracts";
79
8export interface ProviderEnvironmentOptionLike {10export interface ProviderEnvironmentOptionLike {
...@@ -76,21 +78,19 @@ export type ProviderOperateAccess = "granted" | "denied" | "pending";...@@ -76,21 +78,19 @@ export type ProviderOperateAccess = "granted" | "denied" | "pending";
7678
77/** Cached grants remain usable during revalidation; unknown or failed lookups grant nothing. */79/** Cached grants remain usable during revalidation; unknown or failed lookups grant nothing. */
78function resolveSessionOperateAccess(input: {80function resolveSessionOperateAccess(input: {
79 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;81 readonly session: SessionGrantInput | null;
80 readonly isPending: boolean;82 readonly isPending: boolean;
81 readonly hasError: boolean;83 readonly hasError: boolean;
82}): ProviderOperateAccess {84}): ProviderOperateAccess {
83 if (input.hasError) return "denied";85 if (input.hasError) return "denied";
84 if (input.session === null) return input.isPending ? "pending" : "denied";86 if (input.session === null) return input.isPending ? "pending" : "denied";
85 return input.session.authenticated && input.session.scopes?.includes(AuthProvidersManageScope)87 return sessionGrantsScope(input.session, AuthProvidersManageScope) ? "granted" : "denied";
86 ? "granted"
87 : "denied";
88}88}
8989
90export function resolvePrimaryOperateAccess(input: {90export function resolvePrimaryOperateAccess(input: {
91 readonly isPrimary: boolean;91 readonly isPrimary: boolean;
92 readonly hasDesktopBridge: boolean;92 readonly hasDesktopBridge: boolean;
93 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;93 readonly session: SessionGrantInput | null;
94 readonly isPending: boolean;94 readonly isPending: boolean;
95 readonly hasError: boolean;95 readonly hasError: boolean;
96}): ProviderOperateAccess {96}): ProviderOperateAccess {
...@@ -98,7 +98,7 @@ export function resolvePrimaryOperateAccess(input: {...@@ -98,7 +98,7 @@ export function resolvePrimaryOperateAccess(input: {
98}98}
9999
100export function resolveRemoteOperateAccess(input: {100export function resolveRemoteOperateAccess(input: {
101 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;101 readonly session: SessionGrantInput | null;
102 readonly isPending: boolean;102 readonly isPending: boolean;
103 readonly hasError: boolean;103 readonly hasError: boolean;
104}): ProviderOperateAccess {104}): ProviderOperateAccess {
apps/web/src/components/usage/UsagePriceOverrides.tsx+2-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { AuthSettingsWriteScope, type EnvironmentId } from "@t3tools/contracts";2import { AuthSettingsWriteScope, type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts";
3import { ChevronDownIcon, PlusIcon, RotateCcwIcon, XIcon } from "lucide-react";3import { ChevronDownIcon, PlusIcon, RotateCcwIcon, XIcon } from "lucide-react";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import { AsyncResult, Atom } from "effect/reactivity";5import { AsyncResult, Atom } from "effect/reactivity";
...@@ -54,7 +54,7 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] =>...@@ -54,7 +54,7 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] =>
54 ? session.waiting54 ? session.waiting
55 ? "pending"55 ? "pending"
56 : "denied"56 : "denied"
57 : sessionData.authenticated && sessionData.scopes?.includes(AuthSettingsWriteScope)57 : sessionGrantsScope(sessionData, AuthSettingsWriteScope)
58 ? "granted"58 ? "granted"
59 : "denied";59 : "denied";
60 return {60 return {
apps/web/src/hooks/useSettings.ts+2-1
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 type ProviderInstanceMutation,19 type ProviderInstanceMutation,
20 ServerSettings,20 ServerSettings,
21 type ServerSettingsPatch,21 type ServerSettingsPatch,
22 sessionGrantsScope,
22} from "@t3tools/contracts";23} from "@t3tools/contracts";
23import {24import {
24 type ClientSettingsPatch,25 type ClientSettingsPatch,
...@@ -446,7 +447,7 @@ function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray<E...@@ -446,7 +447,7 @@ function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray<E
446 }447 }
447 const session =448 const session =
448 result._tag === "Failure" ? null : Option.getOrNull(AsyncResult.value(result));449 result._tag === "Failure" ? null : Option.getOrNull(AsyncResult.value(result));
449 return session?.authenticated && session.scopes?.includes(AuthSettingsWriteScope)450 return session !== null && sessionGrantsScope(session, AuthSettingsWriteScope)
450 ? [environment.environmentId]451 ? [environment.environmentId]
451 : [];452 : [];
452 }),453 }),
apps/web/src/hooks/useThreadActions.permissions.test.ts+1
...@@ -72,6 +72,7 @@ vi.mock("../state/use-atom-query-runner", () => ({...@@ -72,6 +72,7 @@ vi.mock("../state/use-atom-query-runner", () => ({
72 : AsyncResult.success({72 : AsyncResult.success({
73 authenticated: true,73 authenticated: true,
74 scopes: [...(state.scopes.get(environmentId) ?? [])],74 scopes: [...(state.scopes.get(environmentId) ?? [])],
75 auth: { serverUpdateScope: "environment:maintain" },
75 }),76 }),
76}));77}));
77vi.mock("../state/threads", () => ({78vi.mock("../state/threads", () => ({
apps/web/src/hooks/useThreadActions.ts+2-2
...@@ -14,6 +14,7 @@ import {...@@ -14,6 +14,7 @@ import {
14 EnvironmentId,14 EnvironmentId,
15 type ScopedThreadRef,15 type ScopedThreadRef,
16 ThreadId,16 ThreadId,
17 sessionGrantsScope,
17} from "@t3tools/contracts";18} from "@t3tools/contracts";
18import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";19import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
19import * as Cause from "effect/Cause";20import * as Cause from "effect/Cause";
...@@ -496,8 +497,7 @@ export function useThreadActions() {...@@ -496,8 +497,7 @@ export function useThreadActions() {
496 if (permissionFailure) return permissionFailure;497 if (permissionFailure) return permissionFailure;
497 canDeleteWorktree =498 canDeleteWorktree =
498 sessionResult._tag === "Success" &&499 sessionResult._tag === "Success" &&
499 sessionResult.value.authenticated &&500 sessionGrantsScope(sessionResult.value, AuthSourceControlWriteScope);
500 sessionResult.value.scopes?.includes(AuthSourceControlWriteScope) === true;
501 }501 }
502 let shouldDeleteWorktree = false;502 let shouldDeleteWorktree = false;
503 const environmentSettings = environmentConfig?.settings;503 const environmentSettings = environmentConfig?.settings;
apps/web/src/routes/__root.tsx+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { PermissionUpdateNotice } from "../components/PermissionUpdateNotice";
1import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts";2import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts";
2import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";3import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
3import {4import {
...@@ -237,6 +238,7 @@ function RootRouteView() {...@@ -237,6 +238,7 @@ function RootRouteView() {
237 <ConfirmDialogHost />238 <ConfirmDialogHost />
238 <CustomSnoozeDialogHost />239 <CustomSnoozeDialogHost />
239 <SlowRpcRequestToastCoordinator />240 <SlowRpcRequestToastCoordinator />
241 <PermissionUpdateNotice />
240 {primaryEnvironmentAuthenticated ? <LegacyThreadMigrationToast /> : null}242 {primaryEnvironmentAuthenticated ? <LegacyThreadMigrationToast /> : null}
241 <ProjectCloneToastCoordinator />243 <ProjectCloneToastCoordinator />
242 <HostedStaticEnvironmentBootstrap />244 <HostedStaticEnvironmentBootstrap />
apps/web/src/state/session.ts+18-18
...@@ -1,6 +1,11 @@...@@ -1,6 +1,11 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";2import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";
3import type { AuthEnvironmentScope, AuthSessionState, EnvironmentId } from "@t3tools/contracts";3import {
4 type AuthEnvironmentScope,
5 type AuthSessionState,
6 type EnvironmentId,
7 sessionGrantsScope,
8} from "@t3tools/contracts";
4import { useMemo } from "react";9import { useMemo } from "react";
5import * as Option from "effect/Option";10import * as Option from "effect/Option";
6import { AsyncResult, Atom } from "effect/reactivity";11import { AsyncResult, Atom } from "effect/reactivity";
...@@ -22,12 +27,15 @@ export function useEnvironmentScope(...@@ -22,12 +27,15 @@ export function useEnvironmentScope(
22 ? EMPTY_SESSION_STATE_ATOM27 ? EMPTY_SESSION_STATE_ATOM
23 : environmentSession.sessionStateAtom(environmentId),28 : environmentSession.sessionStateAtom(environmentId),
24 );29 );
30 return sessionHasScope(result, scope);
31}
32
33function sessionHasScope(
34 result: AsyncResult.AsyncResult<AuthSessionState, unknown>,
35 scope: AuthEnvironmentScope,
36): boolean {
25 const session = Option.getOrNull(AsyncResult.value(result));37 const session = Option.getOrNull(AsyncResult.value(result));
26 return (38 return result._tag !== "Failure" && session !== null && sessionGrantsScope(session, scope);
27 result._tag !== "Failure" &&
28 session?.authenticated === true &&
29 session.scopes?.includes(scope) === true
30 );
31}39}
3240
33/** Subscribe to the grants of every selected environment. */41/** Subscribe to the grants of every selected environment. */
...@@ -41,12 +49,7 @@ export function useEnvironmentsWithScope(...@@ -41,12 +49,7 @@ export function useEnvironmentsWithScope(
41 const ids = new Set<EnvironmentId>();49 const ids = new Set<EnvironmentId>();
42 for (const { environmentId } of environments) {50 for (const { environmentId } of environments) {
43 const result = get(environmentSession.sessionStateAtom(environmentId));51 const result = get(environmentSession.sessionStateAtom(environmentId));
44 const session = Option.getOrNull(AsyncResult.value(result));52 if (sessionHasScope(result, scope)) {
45 if (
46 result._tag !== "Failure" &&
47 session?.authenticated === true &&
48 session.scopes?.includes(scope)
49 ) {
50 ids.add(environmentId);53 ids.add(environmentId);
51 }54 }
52 }55 }
...@@ -61,12 +64,9 @@ export function readEnvironmentScope(...@@ -61,12 +64,9 @@ export function readEnvironmentScope(
61 environmentId: EnvironmentId,64 environmentId: EnvironmentId,
62 scope: AuthEnvironmentScope,65 scope: AuthEnvironmentScope,
63): boolean {66): boolean {
64 const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId));67 return sessionHasScope(
65 const session = Option.getOrNull(AsyncResult.value(result));68 appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)),
66 return (69 scope,
67 result._tag !== "Failure" &&
68 session?.authenticated === true &&
69 session.scopes?.includes(scope) === true
70 );70 );
71}71}
7272
docs/internals/environment-auth.md+12-4
...@@ -58,10 +58,18 @@ extra authority: [every RPC declares a required...@@ -58,10 +58,18 @@ extra authority: [every RPC declares a required
58scope](../../apps/server/src/auth/RpcAuthorization.ts), and the WebSocket RPC58scope](../../apps/server/src/auth/RpcAuthorization.ts), and the WebSocket RPC
59group's `RpcScopeAuthorization` middleware checks it before any handler runs.59group's `RpcScopeAuthorization` middleware checks it before any handler runs.
6060
61Self-update must work across authorization protocol changes. New servers advertise61Scope changes must not prevent older clients from connecting. Token exchange
62`auth.serverUpdateScope`; only an older server that omits it uses62intersects recognized requests with the pairing grant; retired and unknown names
63`orchestration:operate` for updates. An unchanged grant on an upgraded server must63are dropped. A request with no granted scopes fails before consuming the link.
64still include `environment:maintain`.64Stored credentials are never expanded when scopes split.
65
66Auth responses keep `scopes` within the original wire vocabulary and include
67`permissions` for the exact grant. New clients use `permissions` when present,
68even if empty. Older servers omit it, so clients use legacy parent checks for
69features those servers already support. These client checks never change server
70authorization. Permission errors likewise retain a legacy `requiredScope` and
71add the exact `requiredPermission`, so a denied RPC stays decodable by old clients.
72Unknown response permissions are ignored; grant inputs stay strict.
6573
66Desktop restarts forget the previous local bearer token, so its reusable74Desktop restarts forget the previous local bearer token, so its reusable
67bootstrap grant replaces earlier sessions for the same subject and method.75bootstrap grant replaces earlier sessions for the same subject and method.
docs/user/remote-access.md+4-3
...@@ -289,9 +289,10 @@ and the agent it runs can use Git however the environment allows....@@ -289,9 +289,10 @@ and the agent it runs can use Git however the environment allows.
289289
290Settings changes, provider management, and environment maintenance can be granted290Settings changes, provider management, and environment maintenance can be granted
291separately from access administration. New standard pairings include these291separately from access administration. New standard pairings include these
292permissions. Existing clients keep their original grants after an update; to292permissions. Existing clients can stay connected after an update, but newly separated
293receive newly separated permissions, pair the client again with the scopes it293features may require pairing again with the permissions they need. Older clients
294needs. Reconnecting or refreshing a session does not expand its grant.294may show controls that the server denies. Create a fresh pairing link to change
295a client's permissions.
295296
296`filesystem:read` allows browsing host files, opening workspace files, and viewing297`filesystem:read` allows browsing host files, opening workspace files, and viewing
297local changes. Add `filesystem:write` to allow editing files or saving plans to298local changes. Add `filesystem:write` to allow editing files or saving plans to
packages/client-runtime/src/rpc/client.ts+2-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import {1import {
2 EnvironmentAuthorizationError,2 EnvironmentAuthorizationError,
3 clientRpcRequiredScopes,3 clientRpcRequiredScopes,
4 authScopeRequiredResponse,
4 type EnvironmentId,5 type EnvironmentId,
5 type ClientGuardedRpcTag,6 type ClientGuardedRpcTag,
6 ORCHESTRATION_V2_WS_METHODS,7 ORCHESTRATION_V2_WS_METHODS,
...@@ -165,7 +166,7 @@ export class RpcPermissionGuard extends Context.Reference<{...@@ -165,7 +166,7 @@ export class RpcPermissionGuard extends Context.Reference<{
165 ? Effect.void166 ? Effect.void
166 : Effect.fail(167 : Effect.fail(
167 new EnvironmentAuthorizationError({168 new EnvironmentAuthorizationError({
168 requiredScope: scope,169 ...authScopeRequiredResponse(scope),
169 message: `This connection requires ${scope}.`,170 message: `This connection requires ${scope}.`,
170 }),171 }),
171 );172 );
packages/client-runtime/src/state/commandPermissions.test.ts+70-2
...@@ -1,3 +1,9 @@...@@ -1,3 +1,9 @@
1import { requestGuarded, runStreamGuarded } from "../rpc/client.ts";
2import { EnvironmentSupervisor } from "../connection/supervisor.ts";
3import * as SubscriptionRef from "effect/SubscriptionRef";
4import * as Option from "effect/Option";
5import * as Stream from "effect/Stream";
6import type { RpcSession } from "../rpc/session.ts";
1import { describe, expect, it } from "@effect/vitest";7import { describe, expect, it } from "@effect/vitest";
2import { vi } from "vite-plus/test";8import { vi } from "vite-plus/test";
3import {9import {
...@@ -36,6 +42,7 @@ const grant = (allowed: boolean): AuthSessionState => ({...@@ -36,6 +42,7 @@ const grant = (allowed: boolean): AuthSessionState => ({
36 sessionCookieName: "test",42 sessionCookieName: "test",
37 },43 },
38 scopes: allowed ? [AuthOrchestrationOperateScope] : [],44 scopes: allowed ? [AuthOrchestrationOperateScope] : [],
45 permissions: allowed ? [AuthOrchestrationOperateScope] : [],
39});46});
40const runtime = Atom.runtime(47const runtime = Atom.runtime(
41 Layer.succeed(EnvironmentRegistry, {48 Layer.succeed(EnvironmentRegistry, {
...@@ -161,12 +168,16 @@ it.effect(...@@ -161,12 +168,16 @@ it.effect(
161 const registry = yield* setup;168 const registry = yield* setup;
162 registry.set(sessions(env), AsyncResult.success(grant(true)));169 registry.set(sessions(env), AsyncResult.success(grant(true)));
163 const git = createCommandPermissions(runtime, WS_METHODS.vcsInit);170 const git = createCommandPermissions(runtime, WS_METHODS.vcsInit);
164 expect((yield* git.authorize(registry, env).pipe(Effect.flip)).requiredScope).toBe(171 expect((yield* git.authorize(registry, env).pipe(Effect.flip)).requiredPermission).toBe(
165 AuthSourceControlWriteScope,172 AuthSourceControlWriteScope,
166 );173 );
167 registry.set(174 registry.set(
168 sessions(env),175 sessions(env),
169 AsyncResult.success({ ...grant(false), scopes: [AuthSourceControlWriteScope] }),176 AsyncResult.success({
177 ...grant(false),
178 scopes: [AuthSourceControlWriteScope],
179 permissions: [AuthSourceControlWriteScope],
180 }),
170 );181 );
171 yield* git.authorize(registry, env);182 yield* git.authorize(registry, env);
172 const prepare = createCommandPermissions(runtime, WS_METHODS.gitPreparePullRequestThread);183 const prepare = createCommandPermissions(runtime, WS_METHODS.gitPreparePullRequestThread);
...@@ -186,6 +197,7 @@ it.effect(...@@ -186,6 +197,7 @@ it.effect(
186 AsyncResult.success({197 AsyncResult.success({
187 ...grant(true),198 ...grant(true),
188 scopes: [AuthSourceControlWriteScope, AuthOrchestrationOperateScope],199 scopes: [AuthSourceControlWriteScope, AuthOrchestrationOperateScope],
200 permissions: [AuthSourceControlWriteScope, AuthOrchestrationOperateScope],
189 }),201 }),
190 );202 );
191 expect(registry.get(prepare.permissionAtom(env, input))).toBe(true);203 expect(registry.get(prepare.permissionAtom(env, input))).toBe(true);
...@@ -193,3 +205,59 @@ it.effect(...@@ -193,3 +205,59 @@ it.effect(
193 }),205 }),
194 ),206 ),
195);207);
208
209it.effect("honors exact empty permissions and preserves legacy parent grants", () =>
210 Effect.scoped(
211 Effect.gen(function* () {
212 const registry = yield* setup;
213 const git = createCommandPermissions(runtime, WS_METHODS.vcsInit);
214 registry.set(sessions(env), AsyncResult.success({ ...grant(true), permissions: [] }));
215 expect(registry.get(git.permissionAtom(env))).toBe(false);
216 const denied = yield* git.authorize(registry, env).pipe(Effect.flip);
217 expect(denied).toMatchObject({
218 requiredPermission: AuthSourceControlWriteScope,
219 requiredScope: AuthOrchestrationOperateScope,
220 });
221 const { permissions: _exact, ...legacy } = grant(true);
222 registry.set(sessions(env), AsyncResult.success(legacy));
223 expect(registry.get(git.permissionAtom(env))).toBe(true);
224 yield* git.authorize(registry, env);
225 }),
226 ),
227);
228
229it.effect("rejects protected unary and streamed RPCs outside a guarded command", () =>
230 Effect.gen(function* () {
231 let writes = 0;
232 const session = {
233 client: {
234 [WS_METHODS.scheduledTasksDelete]: () =>
235 Effect.sync(() => {
236 writes++;
237 return { id: ScheduledTaskId.make("task") };
238 }),
239 [WS_METHODS.gitRunStackedAction]: () =>
240 Stream.fromEffect(
241 Effect.sync(() => {
242 writes++;
243 }),
244 ),
245 },
246 } as unknown as RpcSession;
247 const supervisor = {
248 target: { environmentId: env, label: "target" },
249 session: yield* SubscriptionRef.make(Option.some(session)),
250 } as unknown as EnvironmentSupervisor["Service"];
251 const unary = yield* requestGuarded(WS_METHODS.scheduledTasksDelete, {
252 id: ScheduledTaskId.make("task"),
253 }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor), Effect.flip);
254 expect(unary._tag).toBe("EnvironmentAuthorizationError");
255 const streamed = yield* runStreamGuarded(WS_METHODS.gitRunStackedAction, {
256 actionId: "test-action",
257 cwd: "/repo",
258 action: "commit",
259 }).pipe(Stream.runDrain, Effect.provideService(EnvironmentSupervisor, supervisor), Effect.flip);
260 expect(streamed._tag).toBe("EnvironmentAuthorizationError");
261 expect(writes).toBe(0);
262 }),
263);
packages/client-runtime/src/state/commandPermissions.ts+5-7
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1import {1import {
2 clientRpcRequiredScopes,2 clientRpcRequiredScopes,
3 sessionGrantsScope,
4 authScopeRequiredResponse,
3 EnvironmentAuthorizationError,5 EnvironmentAuthorizationError,
4 type EnvironmentId,6 type EnvironmentId,
5 type AuthSessionState,7 type AuthSessionState,
...@@ -11,10 +13,6 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";...@@ -11,10 +13,6 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
11import type { EnvironmentRegistry } from "../connection/registry.ts";13import type { EnvironmentRegistry } from "../connection/registry.ts";
12import { createEnvironmentSessionAtoms } from "./session.ts";14import { createEnvironmentSessionAtoms } from "./session.ts";
1315
14function grants(session: AuthSessionState, scope: AuthEnvironmentScope) {
15 return session.authenticated && session.scopes?.includes(scope) === true;
16}
17
18/** UI availability and dispatch use the same target session and method policy. */16/** UI availability and dispatch use the same target session and method policy. */
19function makeCommandPermissions<R, E>(17function makeCommandPermissions<R, E>(
20 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,18 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
...@@ -33,7 +31,7 @@ function makeCommandPermissions<R, E>(...@@ -33,7 +31,7 @@ function makeCommandPermissions<R, E>(
33 return (31 return (
34 result._tag !== "Failure" &&32 result._tag !== "Failure" &&
35 session !== null &&33 session !== null &&
36 scopes.every((scope) => grants(session, scope))34 scopes.every((scope) => sessionGrantsScope(session, scope))
37 );35 );
38 }),36 }),
39 ),37 ),
...@@ -59,12 +57,12 @@ function makeCommandPermissions<R, E>(...@@ -59,12 +57,12 @@ function makeCommandPermissions<R, E>(
59 Effect.catch(() => Effect.succeed(Option.none<AuthSessionState>())),57 Effect.catch(() => Effect.succeed(Option.none<AuthSessionState>())),
60 );58 );
61 const missing = scopes.find(59 const missing = scopes.find(
62 (scope) => Option.isNone(session) || !grants(session.value, scope),60 (scope) => Option.isNone(session) || !sessionGrantsScope(session.value, scope),
63 );61 );
64 if (missing !== undefined)62 if (missing !== undefined)
65 return yield* Effect.fail(63 return yield* Effect.fail(
66 new EnvironmentAuthorizationError({64 new EnvironmentAuthorizationError({
67 requiredScope: missing,65 ...authScopeRequiredResponse(missing),
68 message: `This connection requires ${missing}.`,66 message: `This connection requires ${missing}.`,
69 }),67 }),
70 );68 );
packages/client-runtime/src/state/filesystem.test.ts+16-2
...@@ -9,6 +9,9 @@ import {...@@ -9,6 +9,9 @@ import {
9 resolveFilesystemReadAccess,9 resolveFilesystemReadAccess,
10} from "./filesystem.ts";10} from "./filesystem.ts";
1111
12/** A server that already splits scopes; it never falls back to a parent grant. */
13const SPLIT_SCOPES_SERVER = { serverUpdateScope: "environment:maintain" } as const;
14
12describe("filesystem read access", () => {15describe("filesystem read access", () => {
13 it("waits for the initial catalog before declaring a missing environment disconnected", () => {16 it("waits for the initial catalog before declaring a missing environment disconnected", () => {
14 expect(17 expect(
...@@ -100,8 +103,8 @@ describe("filesystem read access", () => {...@@ -100,8 +103,8 @@ describe("filesystem read access", () => {
100 );103 );
101104
102 it.each([105 it.each([
103 { authenticated: true, scopes: [AuthOrchestrationReadScope] },106 { authenticated: true, scopes: [AuthOrchestrationReadScope], auth: SPLIT_SCOPES_SERVER },
104 { authenticated: false, scopes: [AuthFilesystemReadScope] },107 { authenticated: false, scopes: [AuthFilesystemReadScope], auth: SPLIT_SCOPES_SERVER },
105 ] as const)("does not infer file access from an ungranted session", (session) => {108 ] as const)("does not infer file access from an ungranted session", (session) => {
106 expect(109 expect(
107 resolveFilesystemReadAccess({110 resolveFilesystemReadAccess({
...@@ -112,6 +115,17 @@ describe("filesystem read access", () => {...@@ -112,6 +115,17 @@ describe("filesystem read access", () => {
112 }),115 }),
113 ).toEqual({ canReadFiles: false, isPending: false, error: null });116 ).toEqual({ canReadFiles: false, isPending: false, error: null });
114 });117 });
118
119 it("falls back to the orchestration grant on a server that predates filesystem:read", () => {
120 expect(
121 resolveFilesystemReadAccess({
122 isCatalogReady: true,
123 connection: { phase: "connected", error: null },
124 session: { authenticated: true, scopes: [AuthOrchestrationReadScope], auth: {} },
125 sessionError: null,
126 }),
127 ).toEqual({ canReadFiles: true, isPending: false, error: null });
128 });
115});129});
116130
117describe("filesystem browse model", () => {131describe("filesystem browse model", () => {
packages/client-runtime/src/state/filesystem.ts+4-4
...@@ -3,6 +3,8 @@ import {...@@ -3,6 +3,8 @@ import {
3 type AuthSessionState,3 type AuthSessionState,
4 type FilesystemBrowseEntry,4 type FilesystemBrowseEntry,
5 WS_METHODS,5 WS_METHODS,
6 sessionGrantsScope,
7 type SessionGrantInput,
6} from "@t3tools/contracts";8} from "@t3tools/contracts";
7import { Atom } from "effect/reactivity";9import { Atom } from "effect/reactivity";
810
...@@ -24,7 +26,7 @@ import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";...@@ -24,7 +26,7 @@ import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
24export function resolveFilesystemReadAccess(input: {26export function resolveFilesystemReadAccess(input: {
25 readonly isCatalogReady: boolean;27 readonly isCatalogReady: boolean;
26 readonly connection: Pick<EnvironmentConnectionPresentation, "phase" | "error"> | null;28 readonly connection: Pick<EnvironmentConnectionPresentation, "phase" | "error"> | null;
27 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;29 readonly session: SessionGrantInput | null;
28 readonly sessionError: string | null;30 readonly sessionError: string | null;
29}) {31}) {
30 if (input.sessionError !== null) {32 if (input.sessionError !== null) {
...@@ -45,9 +47,7 @@ export function resolveFilesystemReadAccess(input: {...@@ -45,9 +47,7 @@ export function resolveFilesystemReadAccess(input: {
45 };47 };
46 }48 }
47 return {49 return {
48 canReadFiles:50 canReadFiles: sessionGrantsScope(input.session, AuthFilesystemReadScope),
49 input.session.authenticated &&
50 input.session.scopes?.includes(AuthFilesystemReadScope) === true,
51 isPending: false,51 isPending: false,
52 error: null,52 error: null,
53 };53 };
packages/client-runtime/src/state/usageAccess.test.ts+5-1
...@@ -39,7 +39,11 @@ describe("resolveUsageAccess", () => {...@@ -39,7 +39,11 @@ describe("resolveUsageAccess", () => {
39 it("distinguishes a failed check from a resolved grant without diagnostics access", () => {39 it("distinguishes a failed check from a resolved grant without diagnostics access", () => {
40 const denied = resolveUsageAccess({40 const denied = resolveUsageAccess({
41 connectionPhase: "connected",41 connectionPhase: "connected",
42 session: { authenticated: true, scopes: [AuthOrchestrationReadScope] },42 session: {
43 authenticated: true,
44 scopes: [AuthOrchestrationReadScope],
45 auth: { serverUpdateScope: "environment:maintain" },
46 },
43 hasSessionError: false,47 hasSessionError: false,
44 });48 });
45 const failed = resolveUsageAccess({49 const failed = resolveUsageAccess({
packages/client-runtime/src/state/usageAccess.ts+8-5
...@@ -1,10 +1,15 @@...@@ -1,10 +1,15 @@
1import { AuthDiagnosticsReadScope, type AuthSessionState } from "@t3tools/contracts";1import {
2 AuthDiagnosticsReadScope,
3 type AuthSessionState,
4 sessionGrantsScope,
5 type SessionGrantInput,
6} from "@t3tools/contracts";
27
3import type { EnvironmentConnectionPhase } from "../connection/presentation.ts";8import type { EnvironmentConnectionPhase } from "../connection/presentation.ts";
49
5export function resolveUsageAccess(input: {10export function resolveUsageAccess(input: {
6 readonly connectionPhase: EnvironmentConnectionPhase;11 readonly connectionPhase: EnvironmentConnectionPhase;
7 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;12 readonly session: SessionGrantInput | null;
8 readonly hasSessionError: boolean;13 readonly hasSessionError: boolean;
9}) {14}) {
10 if (input.hasSessionError) {15 if (input.hasSessionError) {
...@@ -27,9 +32,7 @@ export function resolveUsageAccess(input: {...@@ -27,9 +32,7 @@ export function resolveUsageAccess(input: {
27 error: isPending ? null : "This environment is not connected.",32 error: isPending ? null : "This environment is not connected.",
28 };33 };
29 }34 }
30 const canReadDiagnostics =35 const canReadDiagnostics = sessionGrantsScope(input.session, AuthDiagnosticsReadScope);
31 input.session.authenticated &&
32 input.session.scopes?.includes(AuthDiagnosticsReadScope) === true;
33 return {36 return {
34 canReadDiagnostics,37 canReadDiagnostics,
35 isPending: false,38 isPending: false,
packages/contracts/src/auth.test.ts+143-1
...@@ -1,7 +1,17 @@...@@ -1,7 +1,17 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
33
4import { AuthEnvironmentScopes, AuthGrantScopes, AuthStandardClientScopes } from "./auth.ts";4import {
5 AuthEnvironmentScopes,
6 AuthEnvironmentScope,
7 authScopeRequiredResponse,
8 AuthGrantScopes,
9 AuthStandardClientScopes,
10 authScopeResponse,
11 AuthSessionState,
12 sessionGrantsScope,
13 sessionHasLegacyPermissions,
14} from "./auth.ts";
515
6describe("authorization grants", () => {16describe("authorization grants", () => {
7 it("decodes legacy review credentials without offering them in new grants", () => {17 it("decodes legacy review credentials without offering them in new grants", () => {
...@@ -11,4 +21,136 @@ describe("authorization grants", () => {...@@ -11,4 +21,136 @@ describe("authorization grants", () => {
11 expect(() => Schema.decodeUnknownSync(AuthGrantScopes)(["review:write"])).toThrow();21 expect(() => Schema.decodeUnknownSync(AuthGrantScopes)(["review:write"])).toThrow();
12 expect(AuthStandardClientScopes).not.toContain("review:write");22 expect(AuthStandardClientScopes).not.toContain("review:write");
13 });23 });
24
25 // Frozen vocabulary from the client before granular scopes shipped. Do not
26 // derive it from the current enum: that would hide compatibility regressions.
27 const oldScopes = Schema.Array(
28 Schema.Literals([
29 "orchestration:read",
30 "orchestration:operate",
31 "terminal:operate",
32 "review:write",
33 "access:read",
34 "access:write",
35 "relay:read",
36 "relay:write",
37 ]),
38 );
39
40 const decodeOldScopes = Schema.decodeUnknownSync(oldScopes);
41
42 it.each(AuthEnvironmentScope.literals)(
43 "keeps %s permission errors decodable by old clients",
44 (scope) => {
45 const response = authScopeRequiredResponse(scope);
46 expect(decodeOldScopes([response.requiredScope])).toEqual([response.requiredScope]);
47 expect(response.requiredPermission).toBe(scope);
48 },
49 );
50
51 it("keeps old clients able to decode grants with new permissions", () => {
52 const response = authScopeResponse(AuthStandardClientScopes);
53 expect(decodeOldScopes(response.scopes)).toEqual(response.scopes);
54 expect(response.permissions).toEqual(AuthStandardClientScopes);
55 expect(response.scopes).not.toContain("filesystem:read");
56 });
57
58 it("ignores unknown response permissions without falling back to broader scopes", () => {
59 const session = Schema.decodeUnknownSync(AuthSessionState)({
60 authenticated: true,
61 auth: {
62 policy: "loopback-browser",
63 bootstrapMethods: [],
64 sessionMethods: [],
65 sessionCookieName: "session",
66 },
67 scopes: ["orchestration:operate"],
68 permissions: ["future:permission"],
69 });
70 expect(session.permissions).toEqual([]);
71 expect(sessionGrantsScope(session, "orchestration:operate")).toBe(false);
72 expect(sessionGrantsScope(session, "settings:write")).toBe(false);
73 });
74
75 it.each([
76 {
77 label: "exact permissions over the legacy presentation",
78 session: {
79 authenticated: true,
80 scopes: ["orchestration:operate"],
81 permissions: ["filesystem:read"],
82 },
83 scope: "settings:write",
84 expected: false,
85 },
86 {
87 label: "a permission absent from the legacy presentation",
88 session: { authenticated: true, scopes: [], permissions: ["filesystem:read"] },
89 scope: "filesystem:read",
90 expected: true,
91 },
92
93 {
94 label: "the parent on a server that predates the split",
95 session: { authenticated: true, scopes: ["orchestration:operate"], auth: {} },
96 scope: "settings:write",
97 expected: true,
98 },
99 {
100 label: "only the exact scope on a server that knows the split",
101 session: {
102 authenticated: true,
103 scopes: ["orchestration:operate"],
104 auth: { serverUpdateScope: "environment:maintain" },
105 },
106 scope: "settings:write",
107 expected: false,
108 },
109 {
110 label: "the exact scope regardless of server version",
111 session: { authenticated: true, scopes: ["settings:write"], auth: {} },
112 scope: "settings:write",
113 expected: true,
114 },
115 {
116 label: "nothing for an unauthenticated session",
117 session: { authenticated: false, scopes: ["orchestration:operate"], auth: {} },
118 scope: "settings:write",
119 expected: false,
120 },
121 {
122 label: "no parent for scopes that were never split",
123 session: { authenticated: true, scopes: ["orchestration:operate"], auth: {} },
124 scope: "access:write",
125 expected: false,
126 },
127 ] as const)("sessionGrantsScope accepts $label", ({ session, scope, expected }) => {
128 expect(sessionGrantsScope(session, scope)).toBe(expected);
129 });
130});
131
132describe("legacy permission notice", () => {
133 it.each(["orchestration:read", "orchestration:operate", "terminal:operate"] as const)(
134 "recognizes an old %s grant on an upgraded server",
135 (scope) =>
136 expect(sessionHasLegacyPermissions({ authenticated: true, permissions: [scope] })).toBe(true),
137 );
138 it("waits for a new server and an authenticated session", () => {
139 expect(
140 sessionHasLegacyPermissions({ authenticated: true, scopes: ["orchestration:operate"] }),
141 ).toBe(false);
142 expect(
143 sessionHasLegacyPermissions({ authenticated: false, permissions: ["orchestration:operate"] }),
144 ).toBe(false);
145 });
146 it("skips new grants and old grants that lost no implied permissions", () => {
147 for (const permissions of [
148 AuthStandardClientScopes,
149 ["orchestration:read", "filesystem:read"] as const,
150 ["access:read"] as const,
151 [],
152 ]) {
153 expect(sessionHasLegacyPermissions({ authenticated: true, permissions })).toBe(false);
154 }
155 });
14});156});
packages/contracts/src/auth.ts+81-3
...@@ -3,6 +3,7 @@ import * as HttpApiSchema from "effect/http-api/HttpApiSchema";...@@ -3,6 +3,7 @@ import * as HttpApiSchema from "effect/http-api/HttpApiSchema";
33
4import {4import {
5 AuthSessionId,5 AuthSessionId,
6 ForwardCompatibleArray,
6 ClientSurface,7 ClientSurface,
7 ClientWebDeployment,8 ClientWebDeployment,
8 TrimmedNonEmptyString,9 TrimmedNonEmptyString,
...@@ -126,6 +127,81 @@ export type AuthGrantScope = typeof AuthGrantScope.Type;...@@ -126,6 +127,81 @@ export type AuthGrantScope = typeof AuthGrantScope.Type;
126export const AuthGrantScopes = Schema.Array(AuthGrantScope);127export const AuthGrantScopes = Schema.Array(AuthGrantScope);
127export type AuthGrantScopes = typeof AuthGrantScopes.Type;128export type AuthGrantScopes = typeof AuthGrantScopes.Type;
128129
130// Frozen wire vocabulary for clients released before granular permissions.
131const legacyScopes = new Set<AuthEnvironmentScope>([
132 AuthOrchestrationReadScope,
133 AuthOrchestrationOperateScope,
134 AuthTerminalOperateScope,
135 AuthReviewWriteScope,
136 AuthAccessReadScope,
137 AuthAccessWriteScope,
138 AuthRelayReadScope,
139 AuthRelayWriteScope,
140]);
141
142/** Format public auth metadata without changing the server's authorization grant. */
143export function authScopeResponse(scopes: ReadonlyArray<AuthEnvironmentScope>) {
144 return { scopes: scopes.filter((scope) => legacyScopes.has(scope)), permissions: scopes };
145}
146
147const authScopeResponseFields = {
148 scopes: AuthEnvironmentScopes,
149 permissions: Schema.optionalKey(ForwardCompatibleArray(AuthEnvironmentScope)),
150};
151
152// Only clients talking to an old server use these parent checks. Servers never
153// expand stored grants, and an explicitly empty permissions array grants nothing.
154const legacyParents: Partial<Record<AuthEnvironmentScope, AuthEnvironmentScope>> = {
155 [AuthFilesystemReadScope]: AuthOrchestrationReadScope,
156 [AuthDiagnosticsReadScope]: AuthOrchestrationReadScope,
157 [AuthSettingsWriteScope]: AuthOrchestrationOperateScope,
158 [AuthProvidersManageScope]: AuthOrchestrationOperateScope,
159 [AuthEnvironmentMaintainScope]: AuthOrchestrationOperateScope,
160 [AuthPreviewOperateScope]: AuthOrchestrationOperateScope,
161 [AuthSourceControlWriteScope]: AuthOrchestrationOperateScope,
162 [AuthFilesystemWriteScope]: AuthOrchestrationOperateScope,
163 [AuthTerminalReadScope]: AuthTerminalOperateScope,
164};
165
166/** Keep permission denials decodable by clients with the original scope enum. */
167export function authScopeRequiredResponse(requiredPermission: AuthEnvironmentScope) {
168 return {
169 requiredScope: legacyParents[requiredPermission] ?? requiredPermission,
170 requiredPermission,
171 };
172}
173
174export interface SessionGrantInput {
175 readonly authenticated: boolean;
176 readonly scopes?: ReadonlyArray<AuthEnvironmentScope> | undefined;
177 readonly permissions?: ReadonlyArray<AuthEnvironmentScope> | undefined;
178 readonly auth?: { readonly serverUpdateScope?: string | undefined } | undefined;
179}
180
181export function sessionGrantsScope(
182 session: SessionGrantInput,
183 scope: AuthEnvironmentScope,
184): boolean {
185 if (!session.authenticated) return false;
186 if (session.permissions !== undefined) return session.permissions.includes(scope);
187 if (session.scopes?.includes(scope)) return true;
188 // Also recognize servers from the first granular-scope release.
189 if (session.auth?.serverUpdateScope !== undefined) return false;
190 const parent = legacyParents[scope];
191 return parent !== undefined && session.scopes?.includes(parent) === true;
192}
193
194/** Old-only grants lost the child permissions formerly implied by their broad scopes. */
195export function sessionHasLegacyPermissions(session: SessionGrantInput): boolean {
196 const permissions = session.permissions;
197 return (
198 session.authenticated &&
199 permissions !== undefined &&
200 permissions.every((scope) => legacyScopes.has(scope)) &&
201 Object.values(legacyParents).some((parent) => permissions.includes(parent))
202 );
203}
204
129export const AuthStandardClientScopes = [205export const AuthStandardClientScopes = [
130 AuthOrchestrationReadScope,206 AuthOrchestrationReadScope,
131 AuthOrchestrationOperateScope,207 AuthOrchestrationOperateScope,
...@@ -191,7 +267,7 @@ export type AuthBrowserSessionRequest = typeof AuthBrowserSessionRequest.Type;...@@ -191,7 +267,7 @@ export type AuthBrowserSessionRequest = typeof AuthBrowserSessionRequest.Type;
191267
192export const AuthBrowserSessionResult = Schema.Struct({268export const AuthBrowserSessionResult = Schema.Struct({
193 authenticated: Schema.Literal(true),269 authenticated: Schema.Literal(true),
194 scopes: AuthEnvironmentScopes,270 ...authScopeResponseFields,
195 sessionMethod: ServerAuthSessionMethod,271 sessionMethod: ServerAuthSessionMethod,
196 expiresAt: Schema.DateTimeUtc,272 expiresAt: Schema.DateTimeUtc,
197});273});
...@@ -257,7 +333,7 @@ export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Typ...@@ -257,7 +333,7 @@ export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Typ
257// Read models contain metadata only. Credentials are returned by creation alone.333// Read models contain metadata only. Credentials are returned by creation alone.
258export const AuthPairingLink = Schema.Struct({334export const AuthPairingLink = Schema.Struct({
259 id: TrimmedNonEmptyString,335 id: TrimmedNonEmptyString,
260 scopes: AuthEnvironmentScopes,336 ...authScopeResponseFields,
261 subject: TrimmedNonEmptyString,337 subject: TrimmedNonEmptyString,
262 label: Schema.optionalKey(TrimmedNonEmptyString),338 label: Schema.optionalKey(TrimmedNonEmptyString),
263 createdAt: Schema.DateTimeUtc,339 createdAt: Schema.DateTimeUtc,
...@@ -278,7 +354,7 @@ export type AuthClientMetadata = typeof AuthClientMetadata.Type;...@@ -278,7 +354,7 @@ export type AuthClientMetadata = typeof AuthClientMetadata.Type;
278export const AuthClientSession = Schema.Struct({354export const AuthClientSession = Schema.Struct({
279 sessionId: AuthSessionId,355 sessionId: AuthSessionId,
280 subject: TrimmedNonEmptyString,356 subject: TrimmedNonEmptyString,
281 scopes: AuthEnvironmentScopes,357 ...authScopeResponseFields,
282 method: ServerAuthSessionMethod,358 method: ServerAuthSessionMethod,
283 client: AuthClientMetadata,359 client: AuthClientMetadata,
284 issuedAt: Schema.DateTimeUtc,360 issuedAt: Schema.DateTimeUtc,
...@@ -335,6 +411,7 @@ export class EnvironmentAuthorizationError extends Schema.TaggedError<Environmen...@@ -335,6 +411,7 @@ export class EnvironmentAuthorizationError extends Schema.TaggedError<Environmen
335 {411 {
336 message: Schema.String,412 message: Schema.String,
337 requiredScope: AuthEnvironmentScope,413 requiredScope: AuthEnvironmentScope,
414 requiredPermission: Schema.optionalKey(Schema.String),
338 },415 },
339) {}416) {}
340417
...@@ -385,6 +462,7 @@ export const AuthSessionState = Schema.Struct({...@@ -385,6 +462,7 @@ export const AuthSessionState = Schema.Struct({
385 authenticated: Schema.Boolean,462 authenticated: Schema.Boolean,
386 auth: ServerAuthDescriptor,463 auth: ServerAuthDescriptor,
387 scopes: Schema.optionalKey(AuthEnvironmentScopes),464 scopes: Schema.optionalKey(AuthEnvironmentScopes),
465 permissions: authScopeResponseFields.permissions,
388 sessionMethod: Schema.optionalKey(ServerAuthSessionMethod),466 sessionMethod: Schema.optionalKey(ServerAuthSessionMethod),
389 expiresAt: Schema.optionalKey(Schema.DateTimeUtc),467 expiresAt: Schema.optionalKey(Schema.DateTimeUtc),
390});468});
packages/contracts/src/environmentHttp.ts+2-1
...@@ -170,6 +170,7 @@ export class EnvironmentScopeRequiredError extends Schema.TaggedError<Environmen...@@ -170,6 +170,7 @@ export class EnvironmentScopeRequiredError extends Schema.TaggedError<Environmen
170 {170 {
171 code: Schema.Literal("insufficient_scope"),171 code: Schema.Literal("insufficient_scope"),
172 requiredScope: AuthEnvironmentScope,172 requiredScope: AuthEnvironmentScope,
173 requiredPermission: Schema.optionalKey(Schema.String),
173 traceId: TrimmedNonEmptyString,174 traceId: TrimmedNonEmptyString,
174 },175 },
175 { httpApiStatus: 403 },176 { httpApiStatus: 403 },
...@@ -179,7 +180,7 @@ export class EnvironmentScopeRequiredError extends Schema.TaggedError<Environmen...@@ -179,7 +180,7 @@ export class EnvironmentScopeRequiredError extends Schema.TaggedError<Environmen
179 }180 }
180181
181 override get message(): string {182 override get message(): string {
182 return `This request needs the ${this.requiredScope} scope, which this client does not have.`;183 return `This request needs the ${this.requiredPermission ?? this.requiredScope} scope, which this client does not have.`;
183 }184 }
184}185}
185186