| author | |
| committer | |
| log | 365aa87982a4d81cc8e0c085e8d1a40ca7daecdc |
| tree | 08055d86149cd701686ab12cf845bb156c7a2ae1 |
| parent | cc41482df0232454af0cc5ac2f1afd984e342134 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | import { PermissionUpdateNotice } from "./components/PermissionUpdateNotice"; | |
| 1 | 2 | import * as Linking from "expo-linking"; |
| 2 | 3 | import * as SplashScreen from "expo-splash-screen"; |
| 3 | 4 | import { useEffect } from "react"; |
| ... | ... | @@ -75,6 +76,7 @@ function AppContent() { |
| 75 | 76 | <> |
| 76 | 77 | <SplashScreenCoordinator /> |
| 77 | 78 | <SubscriptionUsageCoordinator /> |
| 79 | <PermissionUpdateNotice /> | |
| 78 | 80 | <GestureHandlerRootView className="flex-1"> |
| 79 | 81 | <KeyboardProvider statusBarTranslucent> |
| 80 | 82 | <SafeAreaProvider> |
apps/mobile/src/components/PermissionUpdateNotice.tsx created+84| ... | ... | @@ -0,0 +1,84 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { sessionHasLegacyPermissions } from "@t3tools/contracts"; | |
| 3 | import { Atom } from "effect/reactivity"; | |
| 4 | import * as SecureStore from "expo-secure-store"; | |
| 5 | import { useEffect, useMemo, useRef, useState } from "react"; | |
| 6 | import { Alert } from "react-native"; | |
| 7 | ||
| 8 | import { useEnvironments } from "../state/environments"; | |
| 9 | import { environmentSession } from "../state/session"; | |
| 10 | ||
| 11 | const storageKey = "t3code.permission-update.v1"; | |
| 12 | const dismissedThisLaunch = new Set<string>(); | |
| 13 | const shownThisLaunch = new Set<string>(); | |
| 14 | ||
| 15 | export 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 | 76 | ).toBe(expected); |
| 77 | 77 | }); |
| 78 | 78 | |
| 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 | 106 | it("requires remote desktop update support for desktop hosts", () => { |
| 80 | 107 | expect(supportsEnvironmentUpdate({})).toBe(false); |
| 81 | 108 | expect(supportsEnvironmentUpdate({ serverSelfUpdate: "respawn" })).toBe(true); |
apps/mobile/src/features/settings/environment-maintenance.ts+3-3| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { |
| 2 | AuthOrchestrationOperateScope, | |
| 2 | AuthEnvironmentMaintainScope, | |
| 3 | sessionGrantsScope, | |
| 3 | 4 | type AuthSessionState, |
| 4 | 5 | type ExecutionEnvironmentCapabilities, |
| 5 | 6 | type ServerProvider, |
| ... | ... | @@ -16,8 +17,7 @@ export function canMaintainEnvironment(session: AuthSessionState | null, connect |
| 16 | 17 | return ( |
| 17 | 18 | connected && |
| 18 | 19 | session?.authenticated === true && |
| 19 | session.scopes?.includes(session.auth.serverUpdateScope ?? AuthOrchestrationOperateScope) === | |
| 20 | true | |
| 20 | sessionGrantsScope(session, AuthEnvironmentMaintainScope) | |
| 21 | 21 | ); |
| 22 | 22 | } |
| 23 | 23 |
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+7-2| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | DEFAULT_TERMINAL_ID, |
| 5 | 5 | EnvironmentId, |
| 6 | 6 | ThreadId, |
| 7 | sessionGrantsScope, | |
| 7 | 8 | } from "@t3tools/contracts"; |
| 8 | 9 | import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; |
| 9 | 10 | import { SymbolView } from "../../components/AppSymbol"; |
| ... | ... | @@ -279,9 +280,13 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 279 | 280 | const isAuthenticated = |
| 280 | 281 | terminalSession.error === null && terminalSession.data?.authenticated === true; |
| 281 | 282 | const canOperateTerminal = |
| 282 | isAuthenticated && terminalSession.data?.scopes?.includes(AuthTerminalOperateScope) === true; | |
| 283 | isAuthenticated && | |
| 284 | terminalSession.data !== null && | |
| 285 | sessionGrantsScope(terminalSession.data, AuthTerminalOperateScope); | |
| 283 | 286 | const canReadTerminal = |
| 284 | isAuthenticated && terminalSession.data?.scopes?.includes(AuthTerminalReadScope) === true; | |
| 287 | isAuthenticated && | |
| 288 | terminalSession.data !== null && | |
| 289 | sessionGrantsScope(terminalSession.data, AuthTerminalReadScope); | |
| 285 | 290 | const environment = useEnvironmentPresentation(routeEnvironmentId); |
| 286 | 291 | const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; |
| 287 | 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 | 3 | import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; |
| 4 | 4 | import { |
| 5 | 5 | AuthFilesystemReadScope, |
| 6 | type AuthSessionState, | |
| 7 | 6 | type EnvironmentId, |
| 7 | sessionGrantsScope, | |
| 8 | type SessionGrantInput, | |
| 8 | 9 | } from "@t3tools/contracts"; |
| 9 | 10 | import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; |
| 10 | 11 | import * as Option from "effect/Option"; |
| ... | ... | @@ -21,11 +22,8 @@ import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; |
| 21 | 22 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 22 | 23 | |
| 23 | 24 | /** An explicit action may ask the server while its grant is still unresolved. */ |
| 24 | function allowsHostMedia(session: Pick<AuthSessionState, "authenticated" | "scopes"> | null) { | |
| 25 | return ( | |
| 26 | session === null || | |
| 27 | (session.authenticated && session.scopes?.includes(AuthFilesystemReadScope) === true) | |
| 28 | ); | |
| 25 | function allowsHostMedia(session: SessionGrantInput | null) { | |
| 26 | return session === null || sessionGrantsScope(session, AuthFilesystemReadScope); | |
| 29 | 27 | } |
| 30 | 28 | |
| 31 | 29 | function 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 | 98 | expect(appAtomRegistry.get(observed)).toEqual(new Set([secondary])); |
| 99 | 99 | expect(changes).not.toHaveLength(0); |
| 100 | 100 | }); |
| 101 | ||
| 102 | it("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 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; |
| 3 | import type { AuthEnvironmentScope, AuthSessionState, EnvironmentId } from "@t3tools/contracts"; | |
| 3 | import { | |
| 4 | type AuthEnvironmentScope, | |
| 5 | type AuthSessionState, | |
| 6 | type EnvironmentId, | |
| 7 | sessionGrantsScope, | |
| 8 | } from "@t3tools/contracts"; | |
| 4 | 9 | import * as Option from "effect/Option"; |
| 5 | 10 | import { AsyncResult, Atom } from "effect/reactivity"; |
| 6 | 11 | import { useMemo } from "react"; |
| ... | ... | @@ -17,11 +22,7 @@ function sessionHasScope( |
| 17 | 22 | scope: AuthEnvironmentScope, |
| 18 | 23 | ): boolean { |
| 19 | 24 | const session = Option.getOrNull(AsyncResult.value(result)); |
| 20 | return ( | |
| 21 | result._tag !== "Failure" && | |
| 22 | session?.authenticated === true && | |
| 23 | session.scopes?.includes(scope) === true | |
| 24 | ); | |
| 25 | return result._tag !== "Failure" && session !== null && sessionGrantsScope(session, scope); | |
| 25 | 26 | } |
| 26 | 27 | |
| 27 | 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 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | authScopeResponse, | |
| 4 | AuthAdministrativeScopes, | |
| 5 | AuthStandardClientScopes, | |
| 6 | } from "@t3tools/contracts"; | |
| 3 | 7 | import { expect, it } from "@effect/vitest"; |
| 4 | 8 | import * as Effect from "effect/Effect"; |
| 5 | 9 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -102,6 +106,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 102 | 106 | const authenticated = yield* serverAuth.authenticateHttpRequest(request); |
| 103 | 107 | expect(devExchange.cookieName).toMatch(/^t3_dev_session_/); |
| 104 | 108 | expect(devExchange.expireNormalCookie).toBe(true); |
| 109 | expect(devExchange.response).toMatchObject(authScopeResponse(AuthAdministrativeScopes)); | |
| 105 | 110 | expect(authenticated.scopes).toEqual(["orchestration:read"]); |
| 106 | 111 | }).pipe( |
| 107 | 112 | Effect.provide( |
| ... | ... | @@ -214,6 +219,16 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 214 | 219 | expect(firstSession.subject).toBe("reusable-dev-token-child"); |
| 215 | 220 | expect(secondSession.subject).toBe("reusable-dev-token-child"); |
| 216 | 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 | 232 | }).pipe( |
| 218 | 233 | Effect.provide( |
| 219 | 234 | layerEnvironmentAuth({ |
| ... | ... | @@ -241,7 +256,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 241 | 256 | expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError"); |
| 242 | 257 | expect( |
| 243 | 258 | (yield* serverAuth.createBrowserSession(recovery.credential, requestMetadata)).response, |
| 244 | ).toMatchObject({ authenticated: true, scopes: AuthAdministrativeScopes }); | |
| 259 | ).toMatchObject({ authenticated: true, ...authScopeResponse(AuthAdministrativeScopes) }); | |
| 245 | 260 | }).pipe( |
| 246 | 261 | Effect.provide( |
| 247 | 262 | layerEnvironmentAuth({ |
| ... | ... | @@ -355,7 +370,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 355 | 370 | const error = yield* serverAuth |
| 356 | 371 | .exchangeBootstrapCredentialForAccessToken( |
| 357 | 372 | pairingCredential.credential, |
| 358 | ["orchestration:read", "access:write"], | |
| 373 | ["access:write"], | |
| 359 | 374 | requestMetadata, |
| 360 | 375 | ) |
| 361 | 376 | .pipe(Effect.flip); |
| ... | ... | @@ -393,7 +408,10 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 393 | 408 | |
| 394 | 409 | it.effect.each([ |
| 395 | 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 | 415 | ])("inherits a constrained pairing grant when token exchange $label", ({ requestedScopes }) => |
| 398 | 416 | Effect.gen(function* () { |
| 399 | 417 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
apps/server/src/auth/EnvironmentAuth.ts+14-6| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | type AuthPairingCredentialResult, |
| 17 | 17 | type AuthSessionId, |
| 18 | 18 | type AuthSessionState, |
| 19 | authScopeResponse, | |
| 19 | 20 | type ServerAuthDescriptor, |
| 20 | 21 | type ServerAuthSessionMethod, |
| 21 | 22 | type AuthWebSocketTicketResult, |
| ... | ... | @@ -773,7 +774,7 @@ export const make = Effect.gen(function* () { |
| 773 | 774 | ({ |
| 774 | 775 | authenticated: true, |
| 775 | 776 | auth: descriptor, |
| 776 | scopes: session.scopes, | |
| 777 | ...authScopeResponse(session.scopes), | |
| 777 | 778 | sessionMethod: session.method, |
| 778 | 779 | ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), |
| 779 | 780 | }) satisfies AuthSessionState, |
| ... | ... | @@ -800,7 +801,7 @@ export const make = Effect.gen(function* () { |
| 800 | 801 | ({ |
| 801 | 802 | response: { |
| 802 | 803 | authenticated: true, |
| 803 | scopes: session.scopes, | |
| 804 | ...authScopeResponse(session.scopes), | |
| 804 | 805 | sessionMethod: session.method, |
| 805 | 806 | expiresAt: DateTime.toUtc(DateTime.add(now, { days: 30 })), |
| 806 | 807 | } satisfies AuthBrowserSessionResult, |
| ... | ... | @@ -841,7 +842,7 @@ export const make = Effect.gen(function* () { |
| 841 | 842 | return { |
| 842 | 843 | response: { |
| 843 | 844 | authenticated: true, |
| 844 | scopes: session.scopes, | |
| 845 | ...authScopeResponse(session.scopes), | |
| 845 | 846 | sessionMethod: session.method, |
| 846 | 847 | expiresAt: DateTime.toUtc(session.expiresAt), |
| 847 | 848 | } satisfies AuthBrowserSessionResult, |
| ... | ... | @@ -890,15 +891,20 @@ export const make = Effect.gen(function* () { |
| 890 | 891 | }; |
| 891 | 892 | |
| 892 | 893 | const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = |
| 893 | (credential, requestedScopesInput, requestMetadata, input) => { | |
| 894 | const requestedScopes = requestedScopesInput?.length ? requestedScopesInput : undefined; | |
| 894 | (credential, requestedScopes, requestMetadata, input) => { | |
| 895 | 895 | return resolveBootstrapGrant(credential, { |
| 896 | 896 | ...input, |
| 897 | 897 | ...(requestedScopes !== undefined ? { requestedScopes } : {}), |
| 898 | 898 | }).pipe( |
| 899 | 899 | Effect.flatMap((grant) => |
| 900 | 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 | 908 | return yield* sessions |
| 903 | 909 | .issue({ |
| 904 | 910 | method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", |
| ... | ... | @@ -1005,6 +1011,7 @@ export const make = Effect.gen(function* () { |
| 1005 | 1011 | ]; |
| 1006 | 1012 | return pairingLinks |
| 1007 | 1013 | .filter((pairingLink) => !excludedSubjects.includes(pairingLink.subject)) |
| 1014 | .map((link) => ({ ...link, ...authScopeResponse(link.scopes) })) | |
| 1008 | 1015 | .toSorted( |
| 1009 | 1016 | (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds, |
| 1010 | 1017 | ); |
| ... | ... | @@ -1110,6 +1117,7 @@ export const make = Effect.gen(function* () { |
| 1110 | 1117 | Effect.map((clientSessions) => |
| 1111 | 1118 | clientSessions.map((clientSession): AuthClientSession => ({ |
| 1112 | 1119 | ...clientSession, |
| 1120 | ...authScopeResponse(clientSession.scopes), | |
| 1113 | 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 | 504 | ]; |
| 505 | 505 | } |
| 506 | 506 | |
| 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 | 511 | return [ |
| 509 | 512 | { |
| 510 | 513 | _tag: "error", |
| ... | ... | @@ -608,7 +611,10 @@ export const make = Effect.gen(function* () { |
| 608 | 611 | return yield* new BootstrapCredentialProofKeyMismatchError({}); |
| 609 | 612 | } |
| 610 | 613 | |
| 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 | 618 | return yield* new BootstrapCredentialScopeNotGrantedError({}); |
| 613 | 619 | } |
| 614 | 620 |
apps/server/src/auth/RpcAuthorization.test.ts+8-5| ... | ... | @@ -247,7 +247,7 @@ describe("RPC scope middleware", () => { |
| 247 | 247 | yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip), |
| 248 | 248 | ).toMatchObject({ |
| 249 | 249 | _tag: "EnvironmentAuthorizationError", |
| 250 | requiredScope: missing, | |
| 250 | requiredPermission: missing, | |
| 251 | 251 | }); |
| 252 | 252 | expect(handled).toEqual([]); |
| 253 | 253 | }).pipe(Effect.scoped), |
| ... | ... | @@ -291,7 +291,7 @@ describe("settings mutation authorization", () => { |
| 291 | 291 | patch: { defaultRuntimeMode: "full-access" }, |
| 292 | 292 | providerInstanceMutation, |
| 293 | 293 | }).pipe(Effect.flip), |
| 294 | ).toMatchObject({ requiredScope: AuthSettingsWriteScope }); | |
| 294 | ).toMatchObject({ requiredPermission: AuthSettingsWriteScope }); | |
| 295 | 295 | expect(handled).toBe(1); |
| 296 | 296 | }).pipe(Effect.scoped), |
| 297 | 297 | ); |
| ... | ... | @@ -317,7 +317,7 @@ describe("settings mutation authorization", () => { |
| 317 | 317 | patch: {}, |
| 318 | 318 | providerInstanceMutation, |
| 319 | 319 | }).pipe(Effect.flip), |
| 320 | ).toMatchObject({ requiredScope: AuthProvidersManageScope }); | |
| 320 | ).toMatchObject({ requiredPermission: AuthProvidersManageScope }); | |
| 321 | 321 | expect(handled).toBe(false); |
| 322 | 322 | }).pipe(Effect.scoped), |
| 323 | 323 | ); |
| ... | ... | @@ -355,7 +355,7 @@ it.effect("requires task permission before attaching a prepared worktree to a th |
| 355 | 355 | mode: "worktree", |
| 356 | 356 | threadId: ThreadId.make("thread"), |
| 357 | 357 | }).pipe(Effect.flip), |
| 358 | ).toMatchObject({ requiredScope: AuthOrchestrationOperateScope }); | |
| 358 | ).toMatchObject({ requiredPermission: AuthOrchestrationOperateScope }); | |
| 359 | 359 | expect(handled).toBe(false); |
| 360 | 360 | }).pipe(Effect.scoped), |
| 361 | 361 | ); |
| ... | ... | @@ -394,7 +394,10 @@ it.effect("separates host file URLs from readable attachment URLs", () => |
| 394 | 394 | ] as const) { |
| 395 | 395 | expect( |
| 396 | 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 | 402 | expect(handled).toBe(1); |
| 400 | 403 | }).pipe(Effect.scoped), |
apps/server/src/auth/RpcAuthorization.ts+2-1| ... | ... | @@ -2,6 +2,7 @@ import { |
| 2 | 2 | CLIENT_GUARDED_RPC_SCOPES, |
| 3 | 3 | type DeviceListInput, |
| 4 | 4 | clientRpcRequiredScopes, |
| 5 | authScopeRequiredResponse, | |
| 5 | 6 | AssetCreateUrlInput, |
| 6 | 7 | AuthAccessReadScope, |
| 7 | 8 | ServerSettingsPatch, |
| ... | ... | @@ -213,7 +214,7 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope |
| 213 | 214 | export const rpcAuthorizationError = (requiredScope: AuthEnvironmentScope) => |
| 214 | 215 | new EnvironmentAuthorizationError({ |
| 215 | 216 | message: `The authenticated token is missing required scope: ${requiredScope}.`, |
| 216 | requiredScope, | |
| 217 | ...authScopeRequiredResponse(requiredScope), | |
| 217 | 218 | }); |
| 218 | 219 | |
| 219 | 220 | const SettingsUpdate = Schema.Struct({ |
apps/server/src/auth/SessionStore.test.ts+29| ... | ... | @@ -20,6 +20,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 20 | 20 | import * as AuthSessions from "../persistence/AuthSessions.ts"; |
| 21 | 21 | import * as SessionStore from "./SessionStore.ts"; |
| 22 | 22 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 23 | import { base64UrlDecodeUtf8, base64UrlEncode, signPayload } from "./utils.ts"; | |
| 23 | 24 | |
| 24 | 25 | const layerServerConfig = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) => |
| 25 | 26 | Layer.effect( |
| ... | ... | @@ -290,6 +291,34 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { |
| 290 | 291 | expect((yield* sessions.verify(uncapped.token)).runtimeModeCeiling).toBeUndefined(); |
| 291 | 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 | 322 | it.effect("rejects malformed session tokens", () => |
| 294 | 323 | Effect.gen(function* () { |
| 295 | 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 | 428 | const SIGNING_SECRET_NAME = "server-signing-key"; |
| 429 | 429 | const DEFAULT_SESSION_TTL = Duration.days(30); |
| 430 | 430 | const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5); |
| 431 | ||
| 431 | 432 | const SessionClaims = Schema.Struct({ |
| 432 | v: Schema.Literal(1), | |
| 433 | v: Schema.Literals([1, 2]), | |
| 433 | 434 | kind: Schema.Literal("session"), |
| 434 | 435 | sid: AuthSessionId, |
| 435 | 436 | sub: Schema.String, |
apps/server/src/auth/http.test.ts+19| ... | ... | @@ -1,6 +1,9 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { |
| 3 | 3 | AuthSessionId, |
| 4 | AuthTokenExchangeGrantType, | |
| 5 | AuthEnvironmentBootstrapTokenType, | |
| 6 | AuthAccessTokenType, | |
| 4 | 7 | EnvironmentAuthenticatedAuth, |
| 5 | 8 | EnvironmentHttpApi, |
| 6 | 9 | } from "@t3tools/contracts"; |
| ... | ... | @@ -100,6 +103,22 @@ it.effect("sets the selected browser session cookies through the HTTP route", () |
| 100 | 103 | requestContext, |
| 101 | 104 | ); |
| 102 | 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 | 122 | const devCookies = devResponse.headers.getSetCookie(); |
| 104 | 123 | const devCookie = devCookies.find((cookie) => cookie.startsWith("t3_dev_session_")); |
| 105 | 124 | expect(devCookie).toContain("HttpOnly"); |
apps/server/src/auth/http.ts+7-7| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { |
| 2 | authScopeRequiredResponse, | |
| 2 | 3 | AuthAccessReadScope, |
| 3 | 4 | AuthAccessWriteScope, |
| 4 | 5 | AuthStandardClientScopes, |
| ... | ... | @@ -18,11 +19,13 @@ import { |
| 18 | 19 | EnvironmentAuthenticatedPrincipal, |
| 19 | 20 | } from "@t3tools/contracts"; |
| 20 | 21 | import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts"; |
| 21 | import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; | |
| 22 | import { parseOAuthScope } from "@t3tools/shared/oauthScope"; | |
| 22 | 23 | import { causeErrorTag } from "@t3tools/shared/observability"; |
| 23 | 24 | import * as Clock from "effect/Clock"; |
| 24 | 25 | import * as DateTime from "effect/DateTime"; |
| 25 | 26 | import * as Effect from "effect/Effect"; |
| 27 | import * as Schema from "effect/Schema"; | |
| 28 | import { identity } from "effect/Function"; | |
| 26 | 29 | import * as Layer from "effect/Layer"; |
| 27 | 30 | import * as Cookies from "effect/http/Cookies"; |
| 28 | 31 | import * as HttpEffect from "effect/http/HttpEffect"; |
| ... | ... | @@ -122,7 +125,7 @@ export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope |
| 122 | 125 | Effect.fail( |
| 123 | 126 | new EnvironmentScopeRequiredError({ |
| 124 | 127 | code: "insufficient_scope", |
| 125 | requiredScope, | |
| 128 | ...authScopeRequiredResponse(requiredScope), | |
| 126 | 129 | traceId, |
| 127 | 130 | }), |
| 128 | 131 | ), |
| ... | ... | @@ -356,11 +359,8 @@ export const layer = HttpApiBuilder.group( |
| 356 | 359 | const requestedScopes = |
| 357 | 360 | args.payload.scope === undefined |
| 358 | 361 | ? undefined |
| 359 | : parseAllowedOAuthScope({ | |
| 360 | value: args.payload.scope, | |
| 361 | allowedScopes: new Set(AuthGrantScope.literals), | |
| 362 | }); | |
| 363 | if (requestedScopes === null) { | |
| 362 | : (parseOAuthScope(args.payload.scope)?.filter(Schema.is(AuthGrantScope)) ?? null); | |
| 363 | if (requestedScopes === null || requestedScopes?.length === 0) { | |
| 364 | 364 | return yield* failEnvironmentInvalidRequest("invalid_scope"); |
| 365 | 365 | } |
| 366 | 366 | const proofKeyThumbprint = args.headers.dpop |
apps/server/src/cliAuthFormat.ts+2-2| ... | ... | @@ -77,7 +77,7 @@ export function formatPairingCredentialList( |
| 77 | 77 | credentials.map((credential) => ({ |
| 78 | 78 | id: credential.id, |
| 79 | 79 | ...(credential.label ? { label: credential.label } : {}), |
| 80 | scopes: credential.scopes, | |
| 80 | scopes: credential.permissions ?? credential.scopes, | |
| 81 | 81 | createdAt: toIsoString(credential.createdAt), |
| 82 | 82 | expiresAt: toIsoString(credential.expiresAt), |
| 83 | 83 | })), |
| ... | ... | @@ -95,7 +95,7 @@ export function formatPairingCredentialList( |
| 95 | 95 | .map((credential) => |
| 96 | 96 | [ |
| 97 | 97 | `${credential.id}${credential.label ? ` (${credential.label})` : ""}`, |
| 98 | ` scopes: ${credential.scopes.join(" ")}`, | |
| 98 | ` scopes: ${(credential.permissions ?? credential.scopes).join(" ")}`, | |
| 99 | 99 | ` created: ${toIsoString(credential.createdAt)}`, |
| 100 | 100 | ` expires: ${toIsoString(credential.expiresAt)}`, |
| 101 | 101 | ].join(newline), |
apps/server/src/persistence/AuthPairingLinks.ts+3-3| ... | ... | @@ -172,13 +172,13 @@ export const make = Effect.gen(function* () { |
| 172 | 172 | proof_key_thumbprint IS NULL |
| 173 | 173 | OR proof_key_thumbprint = ${proofKeyThumbprint} |
| 174 | 174 | ) |
| 175 | AND NOT EXISTS ( | |
| 175 | AND (${requestedScopes === undefined} OR EXISTS ( | |
| 176 | 176 | SELECT 1 |
| 177 | 177 | FROM json_each(${JSON.stringify(requestedScopes ?? [])}) AS requested |
| 178 | WHERE requested.value NOT IN ( | |
| 178 | WHERE requested.value IN ( | |
| 179 | 179 | SELECT value FROM json_each(auth_pairing_links.scopes) |
| 180 | 180 | ) |
| 181 | ) | |
| 181 | )) | |
| 182 | 182 | RETURNING |
| 183 | 183 | id AS "id", |
| 184 | 184 | credential AS "credential", |
apps/server/src/ws.ts+3-1| ... | ... | @@ -24,6 +24,7 @@ import { |
| 24 | 24 | DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, |
| 25 | 25 | AcpRegistryOperationError, |
| 26 | 26 | CommandId, |
| 27 | authScopeResponse, | |
| 27 | 28 | AuthAccessStreamError, |
| 28 | 29 | type AuthAccessStreamEvent, |
| 29 | 30 | AuthOrchestrationOperateScope, |
| ... | ... | @@ -561,7 +562,7 @@ function toAuthAccessStreamEvent( |
| 561 | 562 | version: 1, |
| 562 | 563 | revision, |
| 563 | 564 | type: "pairingLinkUpserted", |
| 564 | payload: change.pairingLink, | |
| 565 | payload: { ...change.pairingLink, ...authScopeResponse(change.pairingLink.scopes) }, | |
| 565 | 566 | }; |
| 566 | 567 | case "pairingLinkRemoved": |
| 567 | 568 | return { |
| ... | ... | @@ -577,6 +578,7 @@ function toAuthAccessStreamEvent( |
| 577 | 578 | type: "clientUpserted", |
| 578 | 579 | payload: { |
| 579 | 580 | ...change.clientSession, |
| 581 | ...authScopeResponse(change.clientSession.scopes), | |
| 580 | 582 | current: change.clientSession.sessionId === currentSessionId, |
| 581 | 583 | }, |
| 582 | 584 | }; |
apps/web/src/components/PermissionUpdateNotice.test.tsx created+111| ... | ... | @@ -0,0 +1,111 @@ |
| 1 | import { act, create, type ReactTestRenderer } from "react-test-renderer"; | |
| 2 | import { beforeEach, afterEach, expect, it, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | const 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 | })); | |
| 17 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.session })); | |
| 18 | vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate })); | |
| 19 | vi.mock("../state/environments", () => ({ | |
| 20 | useEnvironments: () => ({ | |
| 21 | environments: [{ environmentId: `env-${state.id}`, label: "Work laptop" }], | |
| 22 | }), | |
| 23 | })); | |
| 24 | vi.mock("../state/session", () => ({ environmentSession: { sessionStateAtom: () => null } })); | |
| 25 | vi.mock("../hooks/useLocalStorage", () => ({ | |
| 26 | getLocalStorageItem: () => state.saved, | |
| 27 | setLocalStorageItem: (...args: unknown[]) => state.save(...args), | |
| 28 | })); | |
| 29 | vi.mock("./ui/toast", () => ({ toastManager: { add: state.add, close: state.close } })); | |
| 30 | import { PermissionUpdateNotice } from "./PermissionUpdateNotice"; | |
| 31 | ||
| 32 | let renderer: ReactTestRenderer; | |
| 33 | beforeEach(() => { | |
| 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 | }); | |
| 43 | afterEach(async () => { | |
| 44 | if (renderer) await act(async () => renderer.unmount()); | |
| 45 | }); | |
| 46 | async function render() { | |
| 47 | await act(async () => { | |
| 48 | renderer = create(<PermissionUpdateNotice />); | |
| 49 | }); | |
| 50 | } | |
| 51 | ||
| 52 | it("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 | }); | |
| 70 | it("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 | }); | |
| 77 | it("skips a notice dismissed on an earlier launch", async () => { | |
| 78 | state.saved = true; | |
| 79 | await render(); | |
| 80 | expect(state.add).not.toHaveBeenCalled(); | |
| 81 | }); | |
| 82 | it("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 | }); | |
| 93 | it("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 | ||
| 99 | it("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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { sessionHasLegacyPermissions, type EnvironmentId } from "@t3tools/contracts"; | |
| 3 | import { useNavigate } from "@tanstack/react-router"; | |
| 4 | import * as Schema from "effect/Schema"; | |
| 5 | import { useEffect } from "react"; | |
| 6 | ||
| 7 | import { getLocalStorageItem, setLocalStorageItem } from "../hooks/useLocalStorage"; | |
| 8 | import { useEnvironments } from "../state/environments"; | |
| 9 | import { environmentSession } from "../state/session"; | |
| 10 | import { toastManager } from "./ui/toast"; | |
| 11 | ||
| 12 | // Keep an active toast across remounts, including Strict Mode effect replay. | |
| 13 | const shown = new Set<EnvironmentId>(); | |
| 14 | ||
| 15 | function 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 | ||
| 73 | export 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 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { AuthOrchestrationOperateScope, type AuthSessionState } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | AuthEnvironmentMaintainScope, | |
| 4 | type AuthSessionState, | |
| 5 | sessionGrantsScope, | |
| 6 | } from "@t3tools/contracts"; | |
| 3 | 7 | import type { AsyncResult } from "effect/reactivity"; |
| 4 | 8 | import { environmentSession } from "~/state/session"; |
| 5 | 9 | import type { |
| ... | ... | @@ -162,13 +166,7 @@ export function ServerUpdatesAction({ |
| 162 | 166 | |
| 163 | 167 | function canUpdateServer(result: AsyncResult.AsyncResult<AuthSessionState, unknown>): boolean { |
| 164 | 168 | if (result._tag !== "Success" || !result.value.authenticated) return false; |
| 165 | const session = result.value; | |
| 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 | ); | |
| 169 | return sessionGrantsScope(result.value, AuthEnvironmentMaintainScope); | |
| 172 | 170 | } |
| 173 | 171 | |
| 174 | 172 | /** |
apps/web/src/components/media/MediaActions.tsx+4-5| ... | ... | @@ -11,6 +11,8 @@ import { |
| 11 | 11 | type AuthSessionState, |
| 12 | 12 | type ContextMenuItem, |
| 13 | 13 | type EnvironmentId, |
| 14 | sessionGrantsScope, | |
| 15 | type SessionGrantInput, | |
| 14 | 16 | } from "@t3tools/contracts"; |
| 15 | 17 | import * as Option from "effect/Option"; |
| 16 | 18 | import { AsyncResult } from "effect/reactivity"; |
| ... | ... | @@ -43,11 +45,8 @@ function mediaFileName(source: MediaActionSource): string { |
| 43 | 45 | } |
| 44 | 46 | |
| 45 | 47 | /** An explicit action may ask the server while its grant is still unresolved. */ |
| 46 | function allowsHostMedia(session: Pick<AuthSessionState, "authenticated" | "scopes"> | null) { | |
| 47 | return ( | |
| 48 | session === null || | |
| 49 | (session.authenticated && session.scopes?.includes(AuthFilesystemReadScope) === true) | |
| 50 | ); | |
| 48 | function allowsHostMedia(session: SessionGrantInput | null) { | |
| 49 | return session === null || sessionGrantsScope(session, AuthFilesystemReadScope); | |
| 51 | 50 | } |
| 52 | 51 | |
| 53 | 52 | function canReadHostMedia(environmentId: EnvironmentId | null): boolean { |
apps/web/src/components/settings/ConnectionsSettings.tsx+3-1| ... | ... | @@ -534,6 +534,7 @@ function sortDesktopClientSessions(sessions: ReadonlyArray<ServerClientSessionRe |
| 534 | 534 | function toDesktopPairingLinkRecord(pairingLink: AuthPairingLink): ServerPairingLinkRecord { |
| 535 | 535 | return { |
| 536 | 536 | ...pairingLink, |
| 537 | scopes: pairingLink.permissions ?? pairingLink.scopes, | |
| 537 | 538 | createdAt: DateTime.formatIso(pairingLink.createdAt), |
| 538 | 539 | expiresAt: DateTime.formatIso(pairingLink.expiresAt), |
| 539 | 540 | }; |
| ... | ... | @@ -542,6 +543,7 @@ function toDesktopPairingLinkRecord(pairingLink: AuthPairingLink): ServerPairing |
| 542 | 543 | function toDesktopClientSessionRecord(clientSession: AuthClientSession): ServerClientSessionRecord { |
| 543 | 544 | return { |
| 544 | 545 | ...clientSession, |
| 546 | scopes: clientSession.permissions ?? clientSession.scopes, | |
| 545 | 547 | issuedAt: DateTime.formatIso(clientSession.issuedAt), |
| 546 | 548 | expiresAt: DateTime.formatIso(clientSession.expiresAt), |
| 547 | 549 | lastConnectedAt: |
| ... | ... | @@ -2108,7 +2110,7 @@ export function ConnectionsSettings() { |
| 2108 | 2110 | const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; |
| 2109 | 2111 | const primarySessionState = usePrimarySessionState(); |
| 2110 | 2112 | const currentSessionScopes = primarySessionState.data?.authenticated |
| 2111 | ? (primarySessionState.data.scopes ?? null) | |
| 2113 | ? (primarySessionState.data.permissions ?? primarySessionState.data.scopes ?? null) | |
| 2112 | 2114 | : null; |
| 2113 | 2115 | const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null); |
| 2114 | 2116 | // 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 | 231 | it("does not treat the old orchestration grant as provider management", () => { |
| 232 | 232 | expect( |
| 233 | 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 | 239 | isPending: false, |
| 236 | 240 | hasError: false, |
| 237 | 241 | }), |
| 238 | 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 | 254 | it("derives access from the environment session's granted scopes", () => { |
| 241 | 255 | expect( |
| 242 | 256 | resolveRemoteOperateAccess({ |
apps/web/src/components/settings/ProviderSettingsPanel.logic.ts+6-6| ... | ... | @@ -3,6 +3,8 @@ import { |
| 3 | 3 | AuthProvidersManageScope, |
| 4 | 4 | type AuthSessionState, |
| 5 | 5 | type EnvironmentId, |
| 6 | sessionGrantsScope, | |
| 7 | type SessionGrantInput, | |
| 6 | 8 | } from "@t3tools/contracts"; |
| 7 | 9 | |
| 8 | 10 | export interface ProviderEnvironmentOptionLike { |
| ... | ... | @@ -76,21 +78,19 @@ export type ProviderOperateAccess = "granted" | "denied" | "pending"; |
| 76 | 78 | |
| 77 | 79 | /** Cached grants remain usable during revalidation; unknown or failed lookups grant nothing. */ |
| 78 | 80 | function resolveSessionOperateAccess(input: { |
| 79 | readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null; | |
| 81 | readonly session: SessionGrantInput | null; | |
| 80 | 82 | readonly isPending: boolean; |
| 81 | 83 | readonly hasError: boolean; |
| 82 | 84 | }): ProviderOperateAccess { |
| 83 | 85 | if (input.hasError) return "denied"; |
| 84 | 86 | if (input.session === null) return input.isPending ? "pending" : "denied"; |
| 85 | return input.session.authenticated && input.session.scopes?.includes(AuthProvidersManageScope) | |
| 86 | ? "granted" | |
| 87 | : "denied"; | |
| 87 | return sessionGrantsScope(input.session, AuthProvidersManageScope) ? "granted" : "denied"; | |
| 88 | 88 | } |
| 89 | 89 | |
| 90 | 90 | export function resolvePrimaryOperateAccess(input: { |
| 91 | 91 | readonly isPrimary: boolean; |
| 92 | 92 | readonly hasDesktopBridge: boolean; |
| 93 | readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null; | |
| 93 | readonly session: SessionGrantInput | null; | |
| 94 | 94 | readonly isPending: boolean; |
| 95 | 95 | readonly hasError: boolean; |
| 96 | 96 | }): ProviderOperateAccess { |
| ... | ... | @@ -98,7 +98,7 @@ export function resolvePrimaryOperateAccess(input: { |
| 98 | 98 | } |
| 99 | 99 | |
| 100 | 100 | export function resolveRemoteOperateAccess(input: { |
| 101 | readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null; | |
| 101 | readonly session: SessionGrantInput | null; | |
| 102 | 102 | readonly isPending: boolean; |
| 103 | 103 | readonly hasError: boolean; |
| 104 | 104 | }): ProviderOperateAccess { |
apps/web/src/components/usage/UsagePriceOverrides.tsx+2-2| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { AuthSettingsWriteScope, type EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { AuthSettingsWriteScope, type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts"; | |
| 3 | 3 | import { ChevronDownIcon, PlusIcon, RotateCcwIcon, XIcon } from "lucide-react"; |
| 4 | 4 | import * as Option from "effect/Option"; |
| 5 | 5 | import { AsyncResult, Atom } from "effect/reactivity"; |
| ... | ... | @@ -54,7 +54,7 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] => |
| 54 | 54 | ? session.waiting |
| 55 | 55 | ? "pending" |
| 56 | 56 | : "denied" |
| 57 | : sessionData.authenticated && sessionData.scopes?.includes(AuthSettingsWriteScope) | |
| 57 | : sessionGrantsScope(sessionData, AuthSettingsWriteScope) | |
| 58 | 58 | ? "granted" |
| 59 | 59 | : "denied"; |
| 60 | 60 | return { |
apps/web/src/hooks/useSettings.ts+2-1| ... | ... | @@ -19,6 +19,7 @@ import { |
| 19 | 19 | type ProviderInstanceMutation, |
| 20 | 20 | ServerSettings, |
| 21 | 21 | type ServerSettingsPatch, |
| 22 | sessionGrantsScope, | |
| 22 | 23 | } from "@t3tools/contracts"; |
| 23 | 24 | import { |
| 24 | 25 | type ClientSettingsPatch, |
| ... | ... | @@ -446,7 +447,7 @@ function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray<E |
| 446 | 447 | } |
| 447 | 448 | const session = |
| 448 | 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 | 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 | 72 | : AsyncResult.success({ |
| 73 | 73 | authenticated: true, |
| 74 | 74 | scopes: [...(state.scopes.get(environmentId) ?? [])], |
| 75 | auth: { serverUpdateScope: "environment:maintain" }, | |
| 75 | 76 | }), |
| 76 | 77 | })); |
| 77 | 78 | vi.mock("../state/threads", () => ({ |
apps/web/src/hooks/useThreadActions.ts+2-2| ... | ... | @@ -14,6 +14,7 @@ import { |
| 14 | 14 | EnvironmentId, |
| 15 | 15 | type ScopedThreadRef, |
| 16 | 16 | ThreadId, |
| 17 | sessionGrantsScope, | |
| 17 | 18 | } from "@t3tools/contracts"; |
| 18 | 19 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; |
| 19 | 20 | import * as Cause from "effect/Cause"; |
| ... | ... | @@ -496,8 +497,7 @@ export function useThreadActions() { |
| 496 | 497 | if (permissionFailure) return permissionFailure; |
| 497 | 498 | canDeleteWorktree = |
| 498 | 499 | sessionResult._tag === "Success" && |
| 499 | sessionResult.value.authenticated && | |
| 500 | sessionResult.value.scopes?.includes(AuthSourceControlWriteScope) === true; | |
| 500 | sessionGrantsScope(sessionResult.value, AuthSourceControlWriteScope); | |
| 501 | 501 | } |
| 502 | 502 | let shouldDeleteWorktree = false; |
| 503 | 503 | const environmentSettings = environmentConfig?.settings; |
apps/web/src/routes/__root.tsx+2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { PermissionUpdateNotice } from "../components/PermissionUpdateNotice"; | |
| 1 | 2 | import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; |
| 2 | 3 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 3 | 4 | import { |
| ... | ... | @@ -237,6 +238,7 @@ function RootRouteView() { |
| 237 | 238 | <ConfirmDialogHost /> |
| 238 | 239 | <CustomSnoozeDialogHost /> |
| 239 | 240 | <SlowRpcRequestToastCoordinator /> |
| 241 | <PermissionUpdateNotice /> | |
| 240 | 242 | {primaryEnvironmentAuthenticated ? <LegacyThreadMigrationToast /> : null} |
| 241 | 243 | <ProjectCloneToastCoordinator /> |
| 242 | 244 | <HostedStaticEnvironmentBootstrap /> |
apps/web/src/state/session.ts+18-18| ... | ... | @@ -1,6 +1,11 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; |
| 3 | import type { AuthEnvironmentScope, AuthSessionState, EnvironmentId } from "@t3tools/contracts"; | |
| 3 | import { | |
| 4 | type AuthEnvironmentScope, | |
| 5 | type AuthSessionState, | |
| 6 | type EnvironmentId, | |
| 7 | sessionGrantsScope, | |
| 8 | } from "@t3tools/contracts"; | |
| 4 | 9 | import { useMemo } from "react"; |
| 5 | 10 | import * as Option from "effect/Option"; |
| 6 | 11 | import { AsyncResult, Atom } from "effect/reactivity"; |
| ... | ... | @@ -22,12 +27,15 @@ export function useEnvironmentScope( |
| 22 | 27 | ? EMPTY_SESSION_STATE_ATOM |
| 23 | 28 | : environmentSession.sessionStateAtom(environmentId), |
| 24 | 29 | ); |
| 30 | return sessionHasScope(result, scope); | |
| 31 | } | |
| 32 | ||
| 33 | function sessionHasScope( | |
| 34 | result: AsyncResult.AsyncResult<AuthSessionState, unknown>, | |
| 35 | scope: AuthEnvironmentScope, | |
| 36 | ): boolean { | |
| 25 | 37 | const session = Option.getOrNull(AsyncResult.value(result)); |
| 26 | return ( | |
| 27 | result._tag !== "Failure" && | |
| 28 | session?.authenticated === true && | |
| 29 | session.scopes?.includes(scope) === true | |
| 30 | ); | |
| 38 | return result._tag !== "Failure" && session !== null && sessionGrantsScope(session, scope); | |
| 31 | 39 | } |
| 32 | 40 | |
| 33 | 41 | /** Subscribe to the grants of every selected environment. */ |
| ... | ... | @@ -41,12 +49,7 @@ export function useEnvironmentsWithScope( |
| 41 | 49 | const ids = new Set<EnvironmentId>(); |
| 42 | 50 | for (const { environmentId } of environments) { |
| 43 | 51 | const result = get(environmentSession.sessionStateAtom(environmentId)); |
| 44 | const session = Option.getOrNull(AsyncResult.value(result)); | |
| 45 | if ( | |
| 46 | result._tag !== "Failure" && | |
| 47 | session?.authenticated === true && | |
| 48 | session.scopes?.includes(scope) | |
| 49 | ) { | |
| 52 | if (sessionHasScope(result, scope)) { | |
| 50 | 53 | ids.add(environmentId); |
| 51 | 54 | } |
| 52 | 55 | } |
| ... | ... | @@ -61,12 +64,9 @@ export function readEnvironmentScope( |
| 61 | 64 | environmentId: EnvironmentId, |
| 62 | 65 | scope: AuthEnvironmentScope, |
| 63 | 66 | ): boolean { |
| 64 | const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)); | |
| 65 | const session = Option.getOrNull(AsyncResult.value(result)); | |
| 66 | return ( | |
| 67 | result._tag !== "Failure" && | |
| 68 | session?.authenticated === true && | |
| 69 | session.scopes?.includes(scope) === true | |
| 67 | return sessionHasScope( | |
| 68 | appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)), | |
| 69 | scope, | |
| 70 | 70 | ); |
| 71 | 71 | } |
| 72 | 72 |
docs/internals/environment-auth.md+12-4| ... | ... | @@ -58,10 +58,18 @@ extra authority: [every RPC declares a required |
| 58 | 58 | scope](../../apps/server/src/auth/RpcAuthorization.ts), and the WebSocket RPC |
| 59 | 59 | group's `RpcScopeAuthorization` middleware checks it before any handler runs. |
| 60 | 60 | |
| 61 | Self-update must work across authorization protocol changes. New servers advertise | |
| 62 | `auth.serverUpdateScope`; only an older server that omits it uses | |
| 63 | `orchestration:operate` for updates. An unchanged grant on an upgraded server must | |
| 64 | still include `environment:maintain`. | |
| 61 | Scope changes must not prevent older clients from connecting. Token exchange | |
| 62 | intersects recognized requests with the pairing grant; retired and unknown names | |
| 63 | are dropped. A request with no granted scopes fails before consuming the link. | |
| 64 | Stored credentials are never expanded when scopes split. | |
| 65 | ||
| 66 | Auth responses keep `scopes` within the original wire vocabulary and include | |
| 67 | `permissions` for the exact grant. New clients use `permissions` when present, | |
| 68 | even if empty. Older servers omit it, so clients use legacy parent checks for | |
| 69 | features those servers already support. These client checks never change server | |
| 70 | authorization. Permission errors likewise retain a legacy `requiredScope` and | |
| 71 | add the exact `requiredPermission`, so a denied RPC stays decodable by old clients. | |
| 72 | Unknown response permissions are ignored; grant inputs stay strict. | |
| 65 | 73 | |
| 66 | 74 | Desktop restarts forget the previous local bearer token, so its reusable |
| 67 | 75 | bootstrap 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 | 289 | |
| 290 | 290 | Settings changes, provider management, and environment maintenance can be granted |
| 291 | 291 | separately from access administration. New standard pairings include these |
| 292 | permissions. Existing clients keep their original grants after an update; to | |
| 293 | receive newly separated permissions, pair the client again with the scopes it | |
| 294 | needs. Reconnecting or refreshing a session does not expand its grant. | |
| 292 | permissions. Existing clients can stay connected after an update, but newly separated | |
| 293 | features may require pairing again with the permissions they need. Older clients | |
| 294 | may show controls that the server denies. Create a fresh pairing link to change | |
| 295 | a client's permissions. | |
| 295 | 296 | |
| 296 | 297 | `filesystem:read` allows browsing host files, opening workspace files, and viewing |
| 297 | 298 | local 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 | 1 | import { |
| 2 | 2 | EnvironmentAuthorizationError, |
| 3 | 3 | clientRpcRequiredScopes, |
| 4 | authScopeRequiredResponse, | |
| 4 | 5 | type EnvironmentId, |
| 5 | 6 | type ClientGuardedRpcTag, |
| 6 | 7 | ORCHESTRATION_V2_WS_METHODS, |
| ... | ... | @@ -165,7 +166,7 @@ export class RpcPermissionGuard extends Context.Reference<{ |
| 165 | 166 | ? Effect.void |
| 166 | 167 | : Effect.fail( |
| 167 | 168 | new EnvironmentAuthorizationError({ |
| 168 | requiredScope: scope, | |
| 169 | ...authScopeRequiredResponse(scope), | |
| 169 | 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 | import { requestGuarded, runStreamGuarded } from "../rpc/client.ts"; | |
| 2 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; | |
| 3 | import * as SubscriptionRef from "effect/SubscriptionRef"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | import * as Stream from "effect/Stream"; | |
| 6 | import type { RpcSession } from "../rpc/session.ts"; | |
| 1 | 7 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 8 | import { vi } from "vite-plus/test"; |
| 3 | 9 | import { |
| ... | ... | @@ -36,6 +42,7 @@ const grant = (allowed: boolean): AuthSessionState => ({ |
| 36 | 42 | sessionCookieName: "test", |
| 37 | 43 | }, |
| 38 | 44 | scopes: allowed ? [AuthOrchestrationOperateScope] : [], |
| 45 | permissions: allowed ? [AuthOrchestrationOperateScope] : [], | |
| 39 | 46 | }); |
| 40 | 47 | const runtime = Atom.runtime( |
| 41 | 48 | Layer.succeed(EnvironmentRegistry, { |
| ... | ... | @@ -161,12 +168,16 @@ it.effect( |
| 161 | 168 | const registry = yield* setup; |
| 162 | 169 | registry.set(sessions(env), AsyncResult.success(grant(true))); |
| 163 | 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 | 172 | AuthSourceControlWriteScope, |
| 166 | 173 | ); |
| 167 | 174 | registry.set( |
| 168 | 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 | 182 | yield* git.authorize(registry, env); |
| 172 | 183 | const prepare = createCommandPermissions(runtime, WS_METHODS.gitPreparePullRequestThread); |
| ... | ... | @@ -186,6 +197,7 @@ it.effect( |
| 186 | 197 | AsyncResult.success({ |
| 187 | 198 | ...grant(true), |
| 188 | 199 | scopes: [AuthSourceControlWriteScope, AuthOrchestrationOperateScope], |
| 200 | permissions: [AuthSourceControlWriteScope, AuthOrchestrationOperateScope], | |
| 189 | 201 | }), |
| 190 | 202 | ); |
| 191 | 203 | expect(registry.get(prepare.permissionAtom(env, input))).toBe(true); |
| ... | ... | @@ -193,3 +205,59 @@ it.effect( |
| 193 | 205 | }), |
| 194 | 206 | ), |
| 195 | 207 | ); |
| 208 | ||
| 209 | it.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 | ||
| 229 | it.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 | 1 | import { |
| 2 | 2 | clientRpcRequiredScopes, |
| 3 | sessionGrantsScope, | |
| 4 | authScopeRequiredResponse, | |
| 3 | 5 | EnvironmentAuthorizationError, |
| 4 | 6 | type EnvironmentId, |
| 5 | 7 | type AuthSessionState, |
| ... | ... | @@ -11,10 +13,6 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; |
| 11 | 13 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 12 | 14 | import { createEnvironmentSessionAtoms } from "./session.ts"; |
| 13 | 15 | |
| 14 | function grants(session: AuthSessionState, scope: AuthEnvironmentScope) { | |
| 15 | return session.authenticated && session.scopes?.includes(scope) === true; | |
| 16 | } | |
| 17 | ||
| 18 | 16 | /** UI availability and dispatch use the same target session and method policy. */ |
| 19 | 17 | function makeCommandPermissions<R, E>( |
| 20 | 18 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, |
| ... | ... | @@ -33,7 +31,7 @@ function makeCommandPermissions<R, E>( |
| 33 | 31 | return ( |
| 34 | 32 | result._tag !== "Failure" && |
| 35 | 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 | 57 | Effect.catch(() => Effect.succeed(Option.none<AuthSessionState>())), |
| 60 | 58 | ); |
| 61 | 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 | 62 | if (missing !== undefined) |
| 65 | 63 | return yield* Effect.fail( |
| 66 | 64 | new EnvironmentAuthorizationError({ |
| 67 | requiredScope: missing, | |
| 65 | ...authScopeRequiredResponse(missing), | |
| 68 | 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 | 9 | resolveFilesystemReadAccess, |
| 10 | 10 | } from "./filesystem.ts"; |
| 11 | 11 | |
| 12 | /** A server that already splits scopes; it never falls back to a parent grant. */ | |
| 13 | const SPLIT_SCOPES_SERVER = { serverUpdateScope: "environment:maintain" } as const; | |
| 14 | ||
| 12 | 15 | describe("filesystem read access", () => { |
| 13 | 16 | it("waits for the initial catalog before declaring a missing environment disconnected", () => { |
| 14 | 17 | expect( |
| ... | ... | @@ -100,8 +103,8 @@ describe("filesystem read access", () => { |
| 100 | 103 | ); |
| 101 | 104 | |
| 102 | 105 | it.each([ |
| 103 | { authenticated: true, scopes: [AuthOrchestrationReadScope] }, | |
| 104 | { authenticated: false, scopes: [AuthFilesystemReadScope] }, | |
| 106 | { authenticated: true, scopes: [AuthOrchestrationReadScope], auth: SPLIT_SCOPES_SERVER }, | |
| 107 | { authenticated: false, scopes: [AuthFilesystemReadScope], auth: SPLIT_SCOPES_SERVER }, | |
| 105 | 108 | ] as const)("does not infer file access from an ungranted session", (session) => { |
| 106 | 109 | expect( |
| 107 | 110 | resolveFilesystemReadAccess({ |
| ... | ... | @@ -112,6 +115,17 @@ describe("filesystem read access", () => { |
| 112 | 115 | }), |
| 113 | 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 | }); |
| 116 | 130 | |
| 117 | 131 | describe("filesystem browse model", () => { |
packages/client-runtime/src/state/filesystem.ts+4-4| ... | ... | @@ -3,6 +3,8 @@ import { |
| 3 | 3 | type AuthSessionState, |
| 4 | 4 | type FilesystemBrowseEntry, |
| 5 | 5 | WS_METHODS, |
| 6 | sessionGrantsScope, | |
| 7 | type SessionGrantInput, | |
| 6 | 8 | } from "@t3tools/contracts"; |
| 7 | 9 | import { Atom } from "effect/reactivity"; |
| 8 | 10 | |
| ... | ... | @@ -24,7 +26,7 @@ import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; |
| 24 | 26 | export function resolveFilesystemReadAccess(input: { |
| 25 | 27 | readonly isCatalogReady: boolean; |
| 26 | 28 | readonly connection: Pick<EnvironmentConnectionPresentation, "phase" | "error"> | null; |
| 27 | readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null; | |
| 29 | readonly session: SessionGrantInput | null; | |
| 28 | 30 | readonly sessionError: string | null; |
| 29 | 31 | }) { |
| 30 | 32 | if (input.sessionError !== null) { |
| ... | ... | @@ -45,9 +47,7 @@ export function resolveFilesystemReadAccess(input: { |
| 45 | 47 | }; |
| 46 | 48 | } |
| 47 | 49 | return { |
| 48 | canReadFiles: | |
| 49 | input.session.authenticated && | |
| 50 | input.session.scopes?.includes(AuthFilesystemReadScope) === true, | |
| 50 | canReadFiles: sessionGrantsScope(input.session, AuthFilesystemReadScope), | |
| 51 | 51 | isPending: false, |
| 52 | 52 | error: null, |
| 53 | 53 | }; |
packages/client-runtime/src/state/usageAccess.test.ts+5-1| ... | ... | @@ -39,7 +39,11 @@ describe("resolveUsageAccess", () => { |
| 39 | 39 | it("distinguishes a failed check from a resolved grant without diagnostics access", () => { |
| 40 | 40 | const denied = resolveUsageAccess({ |
| 41 | 41 | connectionPhase: "connected", |
| 42 | session: { authenticated: true, scopes: [AuthOrchestrationReadScope] }, | |
| 42 | session: { | |
| 43 | authenticated: true, | |
| 44 | scopes: [AuthOrchestrationReadScope], | |
| 45 | auth: { serverUpdateScope: "environment:maintain" }, | |
| 46 | }, | |
| 43 | 47 | hasSessionError: false, |
| 44 | 48 | }); |
| 45 | 49 | const failed = resolveUsageAccess({ |
packages/client-runtime/src/state/usageAccess.ts+8-5| ... | ... | @@ -1,10 +1,15 @@ |
| 1 | import { AuthDiagnosticsReadScope, type AuthSessionState } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | AuthDiagnosticsReadScope, | |
| 3 | type AuthSessionState, | |
| 4 | sessionGrantsScope, | |
| 5 | type SessionGrantInput, | |
| 6 | } from "@t3tools/contracts"; | |
| 2 | 7 | |
| 3 | 8 | import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; |
| 4 | 9 | |
| 5 | 10 | export function resolveUsageAccess(input: { |
| 6 | 11 | readonly connectionPhase: EnvironmentConnectionPhase; |
| 7 | readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null; | |
| 12 | readonly session: SessionGrantInput | null; | |
| 8 | 13 | readonly hasSessionError: boolean; |
| 9 | 14 | }) { |
| 10 | 15 | if (input.hasSessionError) { |
| ... | ... | @@ -27,9 +32,7 @@ export function resolveUsageAccess(input: { |
| 27 | 32 | error: isPending ? null : "This environment is not connected.", |
| 28 | 33 | }; |
| 29 | 34 | } |
| 30 | const canReadDiagnostics = | |
| 31 | input.session.authenticated && | |
| 32 | input.session.scopes?.includes(AuthDiagnosticsReadScope) === true; | |
| 35 | const canReadDiagnostics = sessionGrantsScope(input.session, AuthDiagnosticsReadScope); | |
| 33 | 36 | return { |
| 34 | 37 | canReadDiagnostics, |
| 35 | 38 | isPending: false, |
packages/contracts/src/auth.test.ts+143-1| ... | ... | @@ -1,7 +1,17 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | |
| 4 | import { AuthEnvironmentScopes, AuthGrantScopes, AuthStandardClientScopes } from "./auth.ts"; | |
| 4 | import { | |
| 5 | AuthEnvironmentScopes, | |
| 6 | AuthEnvironmentScope, | |
| 7 | authScopeRequiredResponse, | |
| 8 | AuthGrantScopes, | |
| 9 | AuthStandardClientScopes, | |
| 10 | authScopeResponse, | |
| 11 | AuthSessionState, | |
| 12 | sessionGrantsScope, | |
| 13 | sessionHasLegacyPermissions, | |
| 14 | } from "./auth.ts"; | |
| 5 | 15 | |
| 6 | 16 | describe("authorization grants", () => { |
| 7 | 17 | it("decodes legacy review credentials without offering them in new grants", () => { |
| ... | ... | @@ -11,4 +21,136 @@ describe("authorization grants", () => { |
| 11 | 21 | expect(() => Schema.decodeUnknownSync(AuthGrantScopes)(["review:write"])).toThrow(); |
| 12 | 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 | ||
| 132 | describe("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 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | AuthSessionId, |
| 6 | ForwardCompatibleArray, | |
| 6 | 7 | ClientSurface, |
| 7 | 8 | ClientWebDeployment, |
| 8 | 9 | TrimmedNonEmptyString, |
| ... | ... | @@ -126,6 +127,81 @@ export type AuthGrantScope = typeof AuthGrantScope.Type; |
| 126 | 127 | export const AuthGrantScopes = Schema.Array(AuthGrantScope); |
| 127 | 128 | export type AuthGrantScopes = typeof AuthGrantScopes.Type; |
| 128 | 129 | |
| 130 | // Frozen wire vocabulary for clients released before granular permissions. | |
| 131 | const 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. */ | |
| 143 | export function authScopeResponse(scopes: ReadonlyArray<AuthEnvironmentScope>) { | |
| 144 | return { scopes: scopes.filter((scope) => legacyScopes.has(scope)), permissions: scopes }; | |
| 145 | } | |
| 146 | ||
| 147 | const 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. | |
| 154 | const 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. */ | |
| 167 | export function authScopeRequiredResponse(requiredPermission: AuthEnvironmentScope) { | |
| 168 | return { | |
| 169 | requiredScope: legacyParents[requiredPermission] ?? requiredPermission, | |
| 170 | requiredPermission, | |
| 171 | }; | |
| 172 | } | |
| 173 | ||
| 174 | export 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 | ||
| 181 | export 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. */ | |
| 195 | export 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 | ||
| 129 | 205 | export const AuthStandardClientScopes = [ |
| 130 | 206 | AuthOrchestrationReadScope, |
| 131 | 207 | AuthOrchestrationOperateScope, |
| ... | ... | @@ -191,7 +267,7 @@ export type AuthBrowserSessionRequest = typeof AuthBrowserSessionRequest.Type; |
| 191 | 267 | |
| 192 | 268 | export const AuthBrowserSessionResult = Schema.Struct({ |
| 193 | 269 | authenticated: Schema.Literal(true), |
| 194 | scopes: AuthEnvironmentScopes, | |
| 270 | ...authScopeResponseFields, | |
| 195 | 271 | sessionMethod: ServerAuthSessionMethod, |
| 196 | 272 | expiresAt: Schema.DateTimeUtc, |
| 197 | 273 | }); |
| ... | ... | @@ -257,7 +333,7 @@ export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Typ |
| 257 | 333 | // Read models contain metadata only. Credentials are returned by creation alone. |
| 258 | 334 | export const AuthPairingLink = Schema.Struct({ |
| 259 | 335 | id: TrimmedNonEmptyString, |
| 260 | scopes: AuthEnvironmentScopes, | |
| 336 | ...authScopeResponseFields, | |
| 261 | 337 | subject: TrimmedNonEmptyString, |
| 262 | 338 | label: Schema.optionalKey(TrimmedNonEmptyString), |
| 263 | 339 | createdAt: Schema.DateTimeUtc, |
| ... | ... | @@ -278,7 +354,7 @@ export type AuthClientMetadata = typeof AuthClientMetadata.Type; |
| 278 | 354 | export const AuthClientSession = Schema.Struct({ |
| 279 | 355 | sessionId: AuthSessionId, |
| 280 | 356 | subject: TrimmedNonEmptyString, |
| 281 | scopes: AuthEnvironmentScopes, | |
| 357 | ...authScopeResponseFields, | |
| 282 | 358 | method: ServerAuthSessionMethod, |
| 283 | 359 | client: AuthClientMetadata, |
| 284 | 360 | issuedAt: Schema.DateTimeUtc, |
| ... | ... | @@ -335,6 +411,7 @@ export class EnvironmentAuthorizationError extends Schema.TaggedError<Environmen |
| 335 | 411 | { |
| 336 | 412 | message: Schema.String, |
| 337 | 413 | requiredScope: AuthEnvironmentScope, |
| 414 | requiredPermission: Schema.optionalKey(Schema.String), | |
| 338 | 415 | }, |
| 339 | 416 | ) {} |
| 340 | 417 | |
| ... | ... | @@ -385,6 +462,7 @@ export const AuthSessionState = Schema.Struct({ |
| 385 | 462 | authenticated: Schema.Boolean, |
| 386 | 463 | auth: ServerAuthDescriptor, |
| 387 | 464 | scopes: Schema.optionalKey(AuthEnvironmentScopes), |
| 465 | permissions: authScopeResponseFields.permissions, | |
| 388 | 466 | sessionMethod: Schema.optionalKey(ServerAuthSessionMethod), |
| 389 | 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 | 170 | { |
| 171 | 171 | code: Schema.Literal("insufficient_scope"), |
| 172 | 172 | requiredScope: AuthEnvironmentScope, |
| 173 | requiredPermission: Schema.optionalKey(Schema.String), | |
| 173 | 174 | traceId: TrimmedNonEmptyString, |
| 174 | 175 | }, |
| 175 | 176 | { httpApiStatus: 403 }, |
| ... | ... | @@ -179,7 +180,7 @@ export class EnvironmentScopeRequiredError extends Schema.TaggedError<Environmen |
| 179 | 180 | } |
| 180 | 181 | |
| 181 | 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 | } |
| 185 | 186 |