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 @@
1import { PermissionUpdateNotice } from "./components/PermissionUpdateNotice";
12import * as Linking from "expo-linking";
23import * as SplashScreen from "expo-splash-screen";
34import { useEffect } from "react";
......@@ -75,6 +76,7 @@ function AppContent() {
7576 <>
7677 <SplashScreenCoordinator />
7778 <SubscriptionUsageCoordinator />
79 <PermissionUpdateNotice />
7880 <GestureHandlerRootView className="flex-1">
7981 <KeyboardProvider statusBarTranslucent>
8082 <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", () => {
7676 ).toBe(expected);
7777 });
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
79106 it("requires remote desktop update support for desktop hosts", () => {
80107 expect(supportsEnvironmentUpdate({})).toBe(false);
81108 expect(supportsEnvironmentUpdate({ serverSelfUpdate: "respawn" })).toBe(true);
apps/mobile/src/features/settings/environment-maintenance.ts+3-3
......@@ -1,5 +1,6 @@
11import {
2 AuthOrchestrationOperateScope,
2 AuthEnvironmentMaintainScope,
3 sessionGrantsScope,
34 type AuthSessionState,
45 type ExecutionEnvironmentCapabilities,
56 type ServerProvider,
......@@ -16,8 +17,7 @@ export function canMaintainEnvironment(session: AuthSessionState | null, connect
1617 return (
1718 connected &&
1819 session?.authenticated === true &&
19 session.scopes?.includes(session.auth.serverUpdateScope ?? AuthOrchestrationOperateScope) ===
20 true
20 sessionGrantsScope(session, AuthEnvironmentMaintainScope)
2121 );
2222}
2323
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+7-2
......@@ -4,6 +4,7 @@ import {
44 DEFAULT_TERMINAL_ID,
55 EnvironmentId,
66 ThreadId,
7 sessionGrantsScope,
78} from "@t3tools/contracts";
89import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
910import { SymbolView } from "../../components/AppSymbol";
......@@ -279,9 +280,13 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
279280 const isAuthenticated =
280281 terminalSession.error === null && terminalSession.data?.authenticated === true;
281282 const canOperateTerminal =
282 isAuthenticated && terminalSession.data?.scopes?.includes(AuthTerminalOperateScope) === true;
283 isAuthenticated &&
284 terminalSession.data !== null &&
285 sessionGrantsScope(terminalSession.data, AuthTerminalOperateScope);
283286 const canReadTerminal =
284 isAuthenticated && terminalSession.data?.scopes?.includes(AuthTerminalReadScope) === true;
287 isAuthenticated &&
288 terminalSession.data !== null &&
289 sessionGrantsScope(terminalSession.data, AuthTerminalReadScope);
285290 const environment = useEnvironmentPresentation(routeEnvironmentId);
286291 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";
287292 const requestedTerminalId = firstRouteParam(params.terminalId);
apps/mobile/src/state/mediaActions.ts+4-6
......@@ -3,8 +3,9 @@ import { useNavigation } from "@react-navigation/native";
33import type { MediaActionId } from "@t3tools/client-runtime/media-actions";
44import {
55 AuthFilesystemReadScope,
6 type AuthSessionState,
76 type EnvironmentId,
7 sessionGrantsScope,
8 type SessionGrantInput,
89} from "@t3tools/contracts";
910import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links";
1011import * as Option from "effect/Option";
......@@ -21,11 +22,8 @@ import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
2122import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
2223
2324/** An explicit action may ask the server while its grant is still unresolved. */
24function allowsHostMedia(session: Pick<AuthSessionState, "authenticated" | "scopes"> | null) {
25 return (
26 session === null ||
27 (session.authenticated && session.scopes?.includes(AuthFilesystemReadScope) === true)
28 );
25function allowsHostMedia(session: SessionGrantInput | null) {
26 return session === null || sessionGrantsScope(session, AuthFilesystemReadScope);
2927}
3028
3129function 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
9898 expect(appAtomRegistry.get(observed)).toEqual(new Set([secondary]));
9999 expect(changes).not.toHaveLength(0);
100100});
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 @@
11import { useAtomValue } from "@effect/atom-react";
22import { 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";
49import * as Option from "effect/Option";
510import { AsyncResult, Atom } from "effect/reactivity";
611import { useMemo } from "react";
......@@ -17,11 +22,7 @@ function sessionHasScope(
1722 scope: AuthEnvironmentScope,
1823): boolean {
1924 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);
2526}
2627
2728/** 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 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
2import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts";
2import {
3 authScopeResponse,
4 AuthAdministrativeScopes,
5 AuthStandardClientScopes,
6} from "@t3tools/contracts";
37import { expect, it } from "@effect/vitest";
48import * as Effect from "effect/Effect";
59import * as Layer from "effect/Layer";
......@@ -102,6 +106,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
102106 const authenticated = yield* serverAuth.authenticateHttpRequest(request);
103107 expect(devExchange.cookieName).toMatch(/^t3_dev_session_/);
104108 expect(devExchange.expireNormalCookie).toBe(true);
109 expect(devExchange.response).toMatchObject(authScopeResponse(AuthAdministrativeScopes));
105110 expect(authenticated.scopes).toEqual(["orchestration:read"]);
106111 }).pipe(
107112 Effect.provide(
......@@ -214,6 +219,16 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
214219 expect(firstSession.subject).toBe("reusable-dev-token-child");
215220 expect(secondSession.subject).toBe("reusable-dev-token-child");
216221 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);
217232 }).pipe(
218233 Effect.provide(
219234 layerEnvironmentAuth({
......@@ -241,7 +256,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
241256 expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError");
242257 expect(
243258 (yield* serverAuth.createBrowserSession(recovery.credential, requestMetadata)).response,
244 ).toMatchObject({ authenticated: true, scopes: AuthAdministrativeScopes });
259 ).toMatchObject({ authenticated: true, ...authScopeResponse(AuthAdministrativeScopes) });
245260 }).pipe(
246261 Effect.provide(
247262 layerEnvironmentAuth({
......@@ -355,7 +370,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
355370 const error = yield* serverAuth
356371 .exchangeBootstrapCredentialForAccessToken(
357372 pairingCredential.credential,
358 ["orchestration:read", "access:write"],
373 ["access:write"],
359374 requestMetadata,
360375 )
361376 .pipe(Effect.flip);
......@@ -393,7 +408,10 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
393408
394409 it.effect.each([
395410 { 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 },
397415 ])("inherits a constrained pairing grant when token exchange $label", ({ requestedScopes }) =>
398416 Effect.gen(function* () {
399417 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
apps/server/src/auth/EnvironmentAuth.ts+14-6
......@@ -16,6 +16,7 @@ import {
1616 type AuthPairingCredentialResult,
1717 type AuthSessionId,
1818 type AuthSessionState,
19 authScopeResponse,
1920 type ServerAuthDescriptor,
2021 type ServerAuthSessionMethod,
2122 type AuthWebSocketTicketResult,
......@@ -773,7 +774,7 @@ export const make = Effect.gen(function* () {
773774 ({
774775 authenticated: true,
775776 auth: descriptor,
776 scopes: session.scopes,
777 ...authScopeResponse(session.scopes),
777778 sessionMethod: session.method,
778779 ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}),
779780 }) satisfies AuthSessionState,
......@@ -800,7 +801,7 @@ export const make = Effect.gen(function* () {
800801 ({
801802 response: {
802803 authenticated: true,
803 scopes: session.scopes,
804 ...authScopeResponse(session.scopes),
804805 sessionMethod: session.method,
805806 expiresAt: DateTime.toUtc(DateTime.add(now, { days: 30 })),
806807 } satisfies AuthBrowserSessionResult,
......@@ -841,7 +842,7 @@ export const make = Effect.gen(function* () {
841842 return {
842843 response: {
843844 authenticated: true,
844 scopes: session.scopes,
845 ...authScopeResponse(session.scopes),
845846 sessionMethod: session.method,
846847 expiresAt: DateTime.toUtc(session.expiresAt),
847848 } satisfies AuthBrowserSessionResult,
......@@ -890,15 +891,20 @@ export const make = Effect.gen(function* () {
890891 };
891892
892893 const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] =
893 (credential, requestedScopesInput, requestMetadata, input) => {
894 const requestedScopes = requestedScopesInput?.length ? requestedScopesInput : undefined;
894 (credential, requestedScopes, requestMetadata, input) => {
895895 return resolveBootstrapGrant(credential, {
896896 ...input,
897897 ...(requestedScopes !== undefined ? { requestedScopes } : {}),
898898 }).pipe(
899899 Effect.flatMap((grant) =>
900900 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 }
902908 return yield* sessions
903909 .issue({
904910 method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token",
......@@ -1005,6 +1011,7 @@ export const make = Effect.gen(function* () {
10051011 ];
10061012 return pairingLinks
10071013 .filter((pairingLink) => !excludedSubjects.includes(pairingLink.subject))
1014 .map((link) => ({ ...link, ...authScopeResponse(link.scopes) }))
10081015 .toSorted(
10091016 (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds,
10101017 );
......@@ -1110,6 +1117,7 @@ export const make = Effect.gen(function* () {
11101117 Effect.map((clientSessions) =>
11111118 clientSessions.map((clientSession): AuthClientSession => ({
11121119 ...clientSession,
1120 ...authScopeResponse(clientSession.scopes),
11131121 current: clientSession.sessionId === currentSessionId,
11141122 })),
11151123 ),
apps/server/src/auth/PairingGrantStore.ts+8-2
......@@ -504,7 +504,10 @@ export const make = Effect.gen(function* () {
504504 ];
505505 }
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 ) {
508511 return [
509512 {
510513 _tag: "error",
......@@ -608,7 +611,10 @@ export const make = Effect.gen(function* () {
608611 return yield* new BootstrapCredentialProofKeyMismatchError({});
609612 }
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 ) {
612618 return yield* new BootstrapCredentialScopeNotGrantedError({});
613619 }
614620
apps/server/src/auth/RpcAuthorization.test.ts+8-5
......@@ -247,7 +247,7 @@ describe("RPC scope middleware", () => {
247247 yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip),
248248 ).toMatchObject({
249249 _tag: "EnvironmentAuthorizationError",
250 requiredScope: missing,
250 requiredPermission: missing,
251251 });
252252 expect(handled).toEqual([]);
253253 }).pipe(Effect.scoped),
......@@ -291,7 +291,7 @@ describe("settings mutation authorization", () => {
291291 patch: { defaultRuntimeMode: "full-access" },
292292 providerInstanceMutation,
293293 }).pipe(Effect.flip),
294 ).toMatchObject({ requiredScope: AuthSettingsWriteScope });
294 ).toMatchObject({ requiredPermission: AuthSettingsWriteScope });
295295 expect(handled).toBe(1);
296296 }).pipe(Effect.scoped),
297297 );
......@@ -317,7 +317,7 @@ describe("settings mutation authorization", () => {
317317 patch: {},
318318 providerInstanceMutation,
319319 }).pipe(Effect.flip),
320 ).toMatchObject({ requiredScope: AuthProvidersManageScope });
320 ).toMatchObject({ requiredPermission: AuthProvidersManageScope });
321321 expect(handled).toBe(false);
322322 }).pipe(Effect.scoped),
323323 );
......@@ -355,7 +355,7 @@ it.effect("requires task permission before attaching a prepared worktree to a th
355355 mode: "worktree",
356356 threadId: ThreadId.make("thread"),
357357 }).pipe(Effect.flip),
358 ).toMatchObject({ requiredScope: AuthOrchestrationOperateScope });
358 ).toMatchObject({ requiredPermission: AuthOrchestrationOperateScope });
359359 expect(handled).toBe(false);
360360 }).pipe(Effect.scoped),
361361);
......@@ -394,7 +394,10 @@ it.effect("separates host file URLs from readable attachment URLs", () =>
394394 ] as const) {
395395 expect(
396396 yield* client[WS_METHODS.assetsCreateUrl]({ resource }).pipe(Effect.flip),
397 ).toMatchObject({ requiredScope: AuthFilesystemReadScope });
397 ).toMatchObject({
398 requiredScope: AuthOrchestrationReadScope,
399 requiredPermission: AuthFilesystemReadScope,
400 });
398401 }
399402 expect(handled).toBe(1);
400403 }).pipe(Effect.scoped),
apps/server/src/auth/RpcAuthorization.ts+2-1
......@@ -2,6 +2,7 @@ import {
22 CLIENT_GUARDED_RPC_SCOPES,
33 type DeviceListInput,
44 clientRpcRequiredScopes,
5 authScopeRequiredResponse,
56 AssetCreateUrlInput,
67 AuthAccessReadScope,
78 ServerSettingsPatch,
......@@ -213,7 +214,7 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope
213214export const rpcAuthorizationError = (requiredScope: AuthEnvironmentScope) =>
214215 new EnvironmentAuthorizationError({
215216 message: `The authenticated token is missing required scope: ${requiredScope}.`,
216 requiredScope,
217 ...authScopeRequiredResponse(requiredScope),
217218 });
218219
219220const SettingsUpdate = Schema.Struct({
apps/server/src/auth/SessionStore.test.ts+29
......@@ -20,6 +20,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts";
2020import * as AuthSessions from "../persistence/AuthSessions.ts";
2121import * as SessionStore from "./SessionStore.ts";
2222import * as ServerSecretStore from "./ServerSecretStore.ts";
23import { base64UrlDecodeUtf8, base64UrlEncode, signPayload } from "./utils.ts";
2324
2425const layerServerConfig = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>
2526 Layer.effect(
......@@ -290,6 +291,34 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
290291 expect((yield* sessions.verify(uncapped.token)).runtimeModeCeiling).toBeUndefined();
291292 }).pipe(Effect.provide(layerSessionStore())),
292293 );
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
293322 it.effect("rejects malformed session tokens", () =>
294323 Effect.gen(function* () {
295324 const sessions = yield* SessionStore.SessionStore;
apps/server/src/auth/SessionStore.ts+2-1
......@@ -428,8 +428,9 @@ export class SessionStore extends Context.Service<
428428const SIGNING_SECRET_NAME = "server-signing-key";
429429const DEFAULT_SESSION_TTL = Duration.days(30);
430430const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5);
431
431432const SessionClaims = Schema.Struct({
432 v: Schema.Literal(1),
433 v: Schema.Literals([1, 2]),
433434 kind: Schema.Literal("session"),
434435 sid: AuthSessionId,
435436 sub: Schema.String,
apps/server/src/auth/http.test.ts+19
......@@ -1,6 +1,9 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import {
33 AuthSessionId,
4 AuthTokenExchangeGrantType,
5 AuthEnvironmentBootstrapTokenType,
6 AuthAccessTokenType,
47 EnvironmentAuthenticatedAuth,
58 EnvironmentHttpApi,
69} from "@t3tools/contracts";
......@@ -100,6 +103,22 @@ it.effect("sets the selected browser session cookies through the HTTP route", ()
100103 requestContext,
101104 );
102105 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
103122 const devCookies = devResponse.headers.getSetCookie();
104123 const devCookie = devCookies.find((cookie) => cookie.startsWith("t3_dev_session_"));
105124 expect(devCookie).toContain("HttpOnly");
apps/server/src/auth/http.ts+7-7
......@@ -1,4 +1,5 @@
11import {
2 authScopeRequiredResponse,
23 AuthAccessReadScope,
34 AuthAccessWriteScope,
45 AuthStandardClientScopes,
......@@ -18,11 +19,13 @@ import {
1819 EnvironmentAuthenticatedPrincipal,
1920} from "@t3tools/contracts";
2021import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts";
21import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope";
22import { parseOAuthScope } from "@t3tools/shared/oauthScope";
2223import { causeErrorTag } from "@t3tools/shared/observability";
2324import * as Clock from "effect/Clock";
2425import * as DateTime from "effect/DateTime";
2526import * as Effect from "effect/Effect";
27import * as Schema from "effect/Schema";
28import { identity } from "effect/Function";
2629import * as Layer from "effect/Layer";
2730import * as Cookies from "effect/http/Cookies";
2831import * as HttpEffect from "effect/http/HttpEffect";
......@@ -122,7 +125,7 @@ export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope
122125 Effect.fail(
123126 new EnvironmentScopeRequiredError({
124127 code: "insufficient_scope",
125 requiredScope,
128 ...authScopeRequiredResponse(requiredScope),
126129 traceId,
127130 }),
128131 ),
......@@ -356,11 +359,8 @@ export const layer = HttpApiBuilder.group(
356359 const requestedScopes =
357360 args.payload.scope === undefined
358361 ? 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) {
364364 return yield* failEnvironmentInvalidRequest("invalid_scope");
365365 }
366366 const proofKeyThumbprint = args.headers.dpop
apps/server/src/cliAuthFormat.ts+2-2
......@@ -77,7 +77,7 @@ export function formatPairingCredentialList(
7777 credentials.map((credential) => ({
7878 id: credential.id,
7979 ...(credential.label ? { label: credential.label } : {}),
80 scopes: credential.scopes,
80 scopes: credential.permissions ?? credential.scopes,
8181 createdAt: toIsoString(credential.createdAt),
8282 expiresAt: toIsoString(credential.expiresAt),
8383 })),
......@@ -95,7 +95,7 @@ export function formatPairingCredentialList(
9595 .map((credential) =>
9696 [
9797 `${credential.id}${credential.label ? ` (${credential.label})` : ""}`,
98 ` scopes: ${credential.scopes.join(" ")}`,
98 ` scopes: ${(credential.permissions ?? credential.scopes).join(" ")}`,
9999 ` created: ${toIsoString(credential.createdAt)}`,
100100 ` expires: ${toIsoString(credential.expiresAt)}`,
101101 ].join(newline),
apps/server/src/persistence/AuthPairingLinks.ts+3-3
......@@ -172,13 +172,13 @@ export const make = Effect.gen(function* () {
172172 proof_key_thumbprint IS NULL
173173 OR proof_key_thumbprint = ${proofKeyThumbprint}
174174 )
175 AND NOT EXISTS (
175 AND (${requestedScopes === undefined} OR EXISTS (
176176 SELECT 1
177177 FROM json_each(${JSON.stringify(requestedScopes ?? [])}) AS requested
178 WHERE requested.value NOT IN (
178 WHERE requested.value IN (
179179 SELECT value FROM json_each(auth_pairing_links.scopes)
180180 )
181 )
181 ))
182182 RETURNING
183183 id AS "id",
184184 credential AS "credential",
apps/server/src/ws.ts+3-1
......@@ -24,6 +24,7 @@ import {
2424 DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL,
2525 AcpRegistryOperationError,
2626 CommandId,
27 authScopeResponse,
2728 AuthAccessStreamError,
2829 type AuthAccessStreamEvent,
2930 AuthOrchestrationOperateScope,
......@@ -561,7 +562,7 @@ function toAuthAccessStreamEvent(
561562 version: 1,
562563 revision,
563564 type: "pairingLinkUpserted",
564 payload: change.pairingLink,
565 payload: { ...change.pairingLink, ...authScopeResponse(change.pairingLink.scopes) },
565566 };
566567 case "pairingLinkRemoved":
567568 return {
......@@ -577,6 +578,7 @@ function toAuthAccessStreamEvent(
577578 type: "clientUpserted",
578579 payload: {
579580 ...change.clientSession,
581 ...authScopeResponse(change.clientSession.scopes),
580582 current: change.clientSession.sessionId === currentSessionId,
581583 },
582584 };
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 @@
11import { useAtomValue } from "@effect/atom-react";
2import { AuthOrchestrationOperateScope, type AuthSessionState } from "@t3tools/contracts";
2import {
3 AuthEnvironmentMaintainScope,
4 type AuthSessionState,
5 sessionGrantsScope,
6} from "@t3tools/contracts";
37import type { AsyncResult } from "effect/reactivity";
48import { environmentSession } from "~/state/session";
59import type {
......@@ -162,13 +166,7 @@ export function ServerUpdatesAction({
162166
163167function canUpdateServer(result: AsyncResult.AsyncResult<AuthSessionState, unknown>): boolean {
164168 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);
172170}
173171
174172/**
apps/web/src/components/media/MediaActions.tsx+4-5
......@@ -11,6 +11,8 @@ import {
1111 type AuthSessionState,
1212 type ContextMenuItem,
1313 type EnvironmentId,
14 sessionGrantsScope,
15 type SessionGrantInput,
1416} from "@t3tools/contracts";
1517import * as Option from "effect/Option";
1618import { AsyncResult } from "effect/reactivity";
......@@ -43,11 +45,8 @@ function mediaFileName(source: MediaActionSource): string {
4345}
4446
4547/** An explicit action may ask the server while its grant is still unresolved. */
46function allowsHostMedia(session: Pick<AuthSessionState, "authenticated" | "scopes"> | null) {
47 return (
48 session === null ||
49 (session.authenticated && session.scopes?.includes(AuthFilesystemReadScope) === true)
50 );
48function allowsHostMedia(session: SessionGrantInput | null) {
49 return session === null || sessionGrantsScope(session, AuthFilesystemReadScope);
5150}
5251
5352function canReadHostMedia(environmentId: EnvironmentId | null): boolean {
apps/web/src/components/settings/ConnectionsSettings.tsx+3-1
......@@ -534,6 +534,7 @@ function sortDesktopClientSessions(sessions: ReadonlyArray<ServerClientSessionRe
534534function toDesktopPairingLinkRecord(pairingLink: AuthPairingLink): ServerPairingLinkRecord {
535535 return {
536536 ...pairingLink,
537 scopes: pairingLink.permissions ?? pairingLink.scopes,
537538 createdAt: DateTime.formatIso(pairingLink.createdAt),
538539 expiresAt: DateTime.formatIso(pairingLink.expiresAt),
539540 };
......@@ -542,6 +543,7 @@ function toDesktopPairingLinkRecord(pairingLink: AuthPairingLink): ServerPairing
542543function toDesktopClientSessionRecord(clientSession: AuthClientSession): ServerClientSessionRecord {
543544 return {
544545 ...clientSession,
546 scopes: clientSession.permissions ?? clientSession.scopes,
545547 issuedAt: DateTime.formatIso(clientSession.issuedAt),
546548 expiresAt: DateTime.formatIso(clientSession.expiresAt),
547549 lastConnectedAt:
......@@ -2108,7 +2110,7 @@ export function ConnectionsSettings() {
21082110 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
21092111 const primarySessionState = usePrimarySessionState();
21102112 const currentSessionScopes = primarySessionState.data?.authenticated
2111 ? (primarySessionState.data.scopes ?? null)
2113 ? (primarySessionState.data.permissions ?? primarySessionState.data.scopes ?? null)
21122114 : null;
21132115 const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null);
21142116 // 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", () => {
231231 it("does not treat the old orchestration grant as provider management", () => {
232232 expect(
233233 resolveRemoteOperateAccess({
234 session: { authenticated: true, scopes: ["orchestration:operate"] },
234 session: {
235 authenticated: true,
236 scopes: ["orchestration:operate"],
237 auth: { serverUpdateScope: "environment:maintain" },
238 },
235239 isPending: false,
236240 hasError: false,
237241 }),
238242 ).toBe("denied");
239243 });
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 });
240254 it("derives access from the environment session's granted scopes", () => {
241255 expect(
242256 resolveRemoteOperateAccess({
apps/web/src/components/settings/ProviderSettingsPanel.logic.ts+6-6
......@@ -3,6 +3,8 @@ import {
33 AuthProvidersManageScope,
44 type AuthSessionState,
55 type EnvironmentId,
6 sessionGrantsScope,
7 type SessionGrantInput,
68} from "@t3tools/contracts";
79
810export interface ProviderEnvironmentOptionLike {
......@@ -76,21 +78,19 @@ export type ProviderOperateAccess = "granted" | "denied" | "pending";
7678
7779/** Cached grants remain usable during revalidation; unknown or failed lookups grant nothing. */
7880function resolveSessionOperateAccess(input: {
79 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;
81 readonly session: SessionGrantInput | null;
8082 readonly isPending: boolean;
8183 readonly hasError: boolean;
8284}): ProviderOperateAccess {
8385 if (input.hasError) return "denied";
8486 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";
8888}
8989
9090export function resolvePrimaryOperateAccess(input: {
9191 readonly isPrimary: boolean;
9292 readonly hasDesktopBridge: boolean;
93 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;
93 readonly session: SessionGrantInput | null;
9494 readonly isPending: boolean;
9595 readonly hasError: boolean;
9696}): ProviderOperateAccess {
......@@ -98,7 +98,7 @@ export function resolvePrimaryOperateAccess(input: {
9898}
9999
100100export function resolveRemoteOperateAccess(input: {
101 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;
101 readonly session: SessionGrantInput | null;
102102 readonly isPending: boolean;
103103 readonly hasError: boolean;
104104}): ProviderOperateAccess {
apps/web/src/components/usage/UsagePriceOverrides.tsx+2-2
......@@ -1,5 +1,5 @@
11import { useAtomValue } from "@effect/atom-react";
2import { AuthSettingsWriteScope, type EnvironmentId } from "@t3tools/contracts";
2import { AuthSettingsWriteScope, type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts";
33import { ChevronDownIcon, PlusIcon, RotateCcwIcon, XIcon } from "lucide-react";
44import * as Option from "effect/Option";
55import { AsyncResult, Atom } from "effect/reactivity";
......@@ -54,7 +54,7 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] =>
5454 ? session.waiting
5555 ? "pending"
5656 : "denied"
57 : sessionData.authenticated && sessionData.scopes?.includes(AuthSettingsWriteScope)
57 : sessionGrantsScope(sessionData, AuthSettingsWriteScope)
5858 ? "granted"
5959 : "denied";
6060 return {
apps/web/src/hooks/useSettings.ts+2-1
......@@ -19,6 +19,7 @@ import {
1919 type ProviderInstanceMutation,
2020 ServerSettings,
2121 type ServerSettingsPatch,
22 sessionGrantsScope,
2223} from "@t3tools/contracts";
2324import {
2425 type ClientSettingsPatch,
......@@ -446,7 +447,7 @@ function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray<E
446447 }
447448 const session =
448449 result._tag === "Failure" ? null : Option.getOrNull(AsyncResult.value(result));
449 return session?.authenticated && session.scopes?.includes(AuthSettingsWriteScope)
450 return session !== null && sessionGrantsScope(session, AuthSettingsWriteScope)
450451 ? [environment.environmentId]
451452 : [];
452453 }),
apps/web/src/hooks/useThreadActions.permissions.test.ts+1
......@@ -72,6 +72,7 @@ vi.mock("../state/use-atom-query-runner", () => ({
7272 : AsyncResult.success({
7373 authenticated: true,
7474 scopes: [...(state.scopes.get(environmentId) ?? [])],
75 auth: { serverUpdateScope: "environment:maintain" },
7576 }),
7677}));
7778vi.mock("../state/threads", () => ({
apps/web/src/hooks/useThreadActions.ts+2-2
......@@ -14,6 +14,7 @@ import {
1414 EnvironmentId,
1515 type ScopedThreadRef,
1616 ThreadId,
17 sessionGrantsScope,
1718} from "@t3tools/contracts";
1819import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
1920import * as Cause from "effect/Cause";
......@@ -496,8 +497,7 @@ export function useThreadActions() {
496497 if (permissionFailure) return permissionFailure;
497498 canDeleteWorktree =
498499 sessionResult._tag === "Success" &&
499 sessionResult.value.authenticated &&
500 sessionResult.value.scopes?.includes(AuthSourceControlWriteScope) === true;
500 sessionGrantsScope(sessionResult.value, AuthSourceControlWriteScope);
501501 }
502502 let shouldDeleteWorktree = false;
503503 const environmentSettings = environmentConfig?.settings;
apps/web/src/routes/__root.tsx+2
......@@ -1,3 +1,4 @@
1import { PermissionUpdateNotice } from "../components/PermissionUpdateNotice";
12import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts";
23import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
34import {
......@@ -237,6 +238,7 @@ function RootRouteView() {
237238 <ConfirmDialogHost />
238239 <CustomSnoozeDialogHost />
239240 <SlowRpcRequestToastCoordinator />
241 <PermissionUpdateNotice />
240242 {primaryEnvironmentAuthenticated ? <LegacyThreadMigrationToast /> : null}
241243 <ProjectCloneToastCoordinator />
242244 <HostedStaticEnvironmentBootstrap />
apps/web/src/state/session.ts+18-18
......@@ -1,6 +1,11 @@
11import { useAtomValue } from "@effect/atom-react";
22import { 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";
49import { useMemo } from "react";
510import * as Option from "effect/Option";
611import { AsyncResult, Atom } from "effect/reactivity";
......@@ -22,12 +27,15 @@ export function useEnvironmentScope(
2227 ? EMPTY_SESSION_STATE_ATOM
2328 : environmentSession.sessionStateAtom(environmentId),
2429 );
30 return sessionHasScope(result, scope);
31}
32
33function sessionHasScope(
34 result: AsyncResult.AsyncResult<AuthSessionState, unknown>,
35 scope: AuthEnvironmentScope,
36): boolean {
2537 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);
3139}
3240
3341/** Subscribe to the grants of every selected environment. */
......@@ -41,12 +49,7 @@ export function useEnvironmentsWithScope(
4149 const ids = new Set<EnvironmentId>();
4250 for (const { environmentId } of environments) {
4351 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)) {
5053 ids.add(environmentId);
5154 }
5255 }
......@@ -61,12 +64,9 @@ export function readEnvironmentScope(
6164 environmentId: EnvironmentId,
6265 scope: AuthEnvironmentScope,
6366): 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,
7070 );
7171}
7272
docs/internals/environment-auth.md+12-4
......@@ -58,10 +58,18 @@ extra authority: [every RPC declares a required
5858scope](../../apps/server/src/auth/RpcAuthorization.ts), and the WebSocket RPC
5959group's `RpcScopeAuthorization` middleware checks it before any handler runs.
6060
61Self-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
64still include `environment:maintain`.
61Scope changes must not prevent older clients from connecting. Token exchange
62intersects recognized requests with the pairing grant; retired and unknown names
63are dropped. A request with no granted scopes fails before consuming the link.
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
6674Desktop restarts forget the previous local bearer token, so its reusable
6775bootstrap 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.
289289
290290Settings changes, provider management, and environment maintenance can be granted
291291separately from access administration. New standard pairings include these
292permissions. Existing clients keep their original grants after an update; to
293receive newly separated permissions, pair the client again with the scopes it
294needs. Reconnecting or refreshing a session does not expand its grant.
292permissions. Existing clients can stay connected after an update, but newly separated
293features may require pairing again with the permissions they need. Older clients
294may show controls that the server denies. Create a fresh pairing link to change
295a client's permissions.
295296
296297`filesystem:read` allows browsing host files, opening workspace files, and viewing
297298local 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 @@
11import {
22 EnvironmentAuthorizationError,
33 clientRpcRequiredScopes,
4 authScopeRequiredResponse,
45 type EnvironmentId,
56 type ClientGuardedRpcTag,
67 ORCHESTRATION_V2_WS_METHODS,
......@@ -165,7 +166,7 @@ export class RpcPermissionGuard extends Context.Reference<{
165166 ? Effect.void
166167 : Effect.fail(
167168 new EnvironmentAuthorizationError({
168 requiredScope: scope,
169 ...authScopeRequiredResponse(scope),
169170 message: `This connection requires ${scope}.`,
170171 }),
171172 );
packages/client-runtime/src/state/commandPermissions.test.ts+70-2
......@@ -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";
17import { describe, expect, it } from "@effect/vitest";
28import { vi } from "vite-plus/test";
39import {
......@@ -36,6 +42,7 @@ const grant = (allowed: boolean): AuthSessionState => ({
3642 sessionCookieName: "test",
3743 },
3844 scopes: allowed ? [AuthOrchestrationOperateScope] : [],
45 permissions: allowed ? [AuthOrchestrationOperateScope] : [],
3946});
4047const runtime = Atom.runtime(
4148 Layer.succeed(EnvironmentRegistry, {
......@@ -161,12 +168,16 @@ it.effect(
161168 const registry = yield* setup;
162169 registry.set(sessions(env), AsyncResult.success(grant(true)));
163170 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(
165172 AuthSourceControlWriteScope,
166173 );
167174 registry.set(
168175 sessions(env),
169 AsyncResult.success({ ...grant(false), scopes: [AuthSourceControlWriteScope] }),
176 AsyncResult.success({
177 ...grant(false),
178 scopes: [AuthSourceControlWriteScope],
179 permissions: [AuthSourceControlWriteScope],
180 }),
170181 );
171182 yield* git.authorize(registry, env);
172183 const prepare = createCommandPermissions(runtime, WS_METHODS.gitPreparePullRequestThread);
......@@ -186,6 +197,7 @@ it.effect(
186197 AsyncResult.success({
187198 ...grant(true),
188199 scopes: [AuthSourceControlWriteScope, AuthOrchestrationOperateScope],
200 permissions: [AuthSourceControlWriteScope, AuthOrchestrationOperateScope],
189201 }),
190202 );
191203 expect(registry.get(prepare.permissionAtom(env, input))).toBe(true);
......@@ -193,3 +205,59 @@ it.effect(
193205 }),
194206 ),
195207);
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 @@
11import {
22 clientRpcRequiredScopes,
3 sessionGrantsScope,
4 authScopeRequiredResponse,
35 EnvironmentAuthorizationError,
46 type EnvironmentId,
57 type AuthSessionState,
......@@ -11,10 +13,6 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
1113import type { EnvironmentRegistry } from "../connection/registry.ts";
1214import { createEnvironmentSessionAtoms } from "./session.ts";
1315
14function grants(session: AuthSessionState, scope: AuthEnvironmentScope) {
15 return session.authenticated && session.scopes?.includes(scope) === true;
16}
17
1816/** UI availability and dispatch use the same target session and method policy. */
1917function makeCommandPermissions<R, E>(
2018 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
......@@ -33,7 +31,7 @@ function makeCommandPermissions<R, E>(
3331 return (
3432 result._tag !== "Failure" &&
3533 session !== null &&
36 scopes.every((scope) => grants(session, scope))
34 scopes.every((scope) => sessionGrantsScope(session, scope))
3735 );
3836 }),
3937 ),
......@@ -59,12 +57,12 @@ function makeCommandPermissions<R, E>(
5957 Effect.catch(() => Effect.succeed(Option.none<AuthSessionState>())),
6058 );
6159 const missing = scopes.find(
62 (scope) => Option.isNone(session) || !grants(session.value, scope),
60 (scope) => Option.isNone(session) || !sessionGrantsScope(session.value, scope),
6361 );
6462 if (missing !== undefined)
6563 return yield* Effect.fail(
6664 new EnvironmentAuthorizationError({
67 requiredScope: missing,
65 ...authScopeRequiredResponse(missing),
6866 message: `This connection requires ${missing}.`,
6967 }),
7068 );
packages/client-runtime/src/state/filesystem.test.ts+16-2
......@@ -9,6 +9,9 @@ import {
99 resolveFilesystemReadAccess,
1010} 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
1215describe("filesystem read access", () => {
1316 it("waits for the initial catalog before declaring a missing environment disconnected", () => {
1417 expect(
......@@ -100,8 +103,8 @@ describe("filesystem read access", () => {
100103 );
101104
102105 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 },
105108 ] as const)("does not infer file access from an ungranted session", (session) => {
106109 expect(
107110 resolveFilesystemReadAccess({
......@@ -112,6 +115,17 @@ describe("filesystem read access", () => {
112115 }),
113116 ).toEqual({ canReadFiles: false, isPending: false, error: null });
114117 });
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 });
115129});
116130
117131describe("filesystem browse model", () => {
packages/client-runtime/src/state/filesystem.ts+4-4
......@@ -3,6 +3,8 @@ import {
33 type AuthSessionState,
44 type FilesystemBrowseEntry,
55 WS_METHODS,
6 sessionGrantsScope,
7 type SessionGrantInput,
68} from "@t3tools/contracts";
79import { Atom } from "effect/reactivity";
810
......@@ -24,7 +26,7 @@ import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
2426export function resolveFilesystemReadAccess(input: {
2527 readonly isCatalogReady: boolean;
2628 readonly connection: Pick<EnvironmentConnectionPresentation, "phase" | "error"> | null;
27 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;
29 readonly session: SessionGrantInput | null;
2830 readonly sessionError: string | null;
2931}) {
3032 if (input.sessionError !== null) {
......@@ -45,9 +47,7 @@ export function resolveFilesystemReadAccess(input: {
4547 };
4648 }
4749 return {
48 canReadFiles:
49 input.session.authenticated &&
50 input.session.scopes?.includes(AuthFilesystemReadScope) === true,
50 canReadFiles: sessionGrantsScope(input.session, AuthFilesystemReadScope),
5151 isPending: false,
5252 error: null,
5353 };
packages/client-runtime/src/state/usageAccess.test.ts+5-1
......@@ -39,7 +39,11 @@ describe("resolveUsageAccess", () => {
3939 it("distinguishes a failed check from a resolved grant without diagnostics access", () => {
4040 const denied = resolveUsageAccess({
4141 connectionPhase: "connected",
42 session: { authenticated: true, scopes: [AuthOrchestrationReadScope] },
42 session: {
43 authenticated: true,
44 scopes: [AuthOrchestrationReadScope],
45 auth: { serverUpdateScope: "environment:maintain" },
46 },
4347 hasSessionError: false,
4448 });
4549 const failed = resolveUsageAccess({
packages/client-runtime/src/state/usageAccess.ts+8-5
......@@ -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
38import type { EnvironmentConnectionPhase } from "../connection/presentation.ts";
49
510export function resolveUsageAccess(input: {
611 readonly connectionPhase: EnvironmentConnectionPhase;
7 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;
12 readonly session: SessionGrantInput | null;
813 readonly hasSessionError: boolean;
914}) {
1015 if (input.hasSessionError) {
......@@ -27,9 +32,7 @@ export function resolveUsageAccess(input: {
2732 error: isPending ? null : "This environment is not connected.",
2833 };
2934 }
30 const canReadDiagnostics =
31 input.session.authenticated &&
32 input.session.scopes?.includes(AuthDiagnosticsReadScope) === true;
35 const canReadDiagnostics = sessionGrantsScope(input.session, AuthDiagnosticsReadScope);
3336 return {
3437 canReadDiagnostics,
3538 isPending: false,
packages/contracts/src/auth.test.ts+143-1
......@@ -1,7 +1,17 @@
11import { describe, expect, it } from "vite-plus/test";
22import * 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
616describe("authorization grants", () => {
717 it("decodes legacy review credentials without offering them in new grants", () => {
......@@ -11,4 +21,136 @@ describe("authorization grants", () => {
1121 expect(() => Schema.decodeUnknownSync(AuthGrantScopes)(["review:write"])).toThrow();
1222 expect(AuthStandardClientScopes).not.toContain("review:write");
1323 });
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 });
14156});
packages/contracts/src/auth.ts+81-3
......@@ -3,6 +3,7 @@ import * as HttpApiSchema from "effect/http-api/HttpApiSchema";
33
44import {
55 AuthSessionId,
6 ForwardCompatibleArray,
67 ClientSurface,
78 ClientWebDeployment,
89 TrimmedNonEmptyString,
......@@ -126,6 +127,81 @@ export type AuthGrantScope = typeof AuthGrantScope.Type;
126127export const AuthGrantScopes = Schema.Array(AuthGrantScope);
127128export 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
129205export const AuthStandardClientScopes = [
130206 AuthOrchestrationReadScope,
131207 AuthOrchestrationOperateScope,
......@@ -191,7 +267,7 @@ export type AuthBrowserSessionRequest = typeof AuthBrowserSessionRequest.Type;
191267
192268export const AuthBrowserSessionResult = Schema.Struct({
193269 authenticated: Schema.Literal(true),
194 scopes: AuthEnvironmentScopes,
270 ...authScopeResponseFields,
195271 sessionMethod: ServerAuthSessionMethod,
196272 expiresAt: Schema.DateTimeUtc,
197273});
......@@ -257,7 +333,7 @@ export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Typ
257333// Read models contain metadata only. Credentials are returned by creation alone.
258334export const AuthPairingLink = Schema.Struct({
259335 id: TrimmedNonEmptyString,
260 scopes: AuthEnvironmentScopes,
336 ...authScopeResponseFields,
261337 subject: TrimmedNonEmptyString,
262338 label: Schema.optionalKey(TrimmedNonEmptyString),
263339 createdAt: Schema.DateTimeUtc,
......@@ -278,7 +354,7 @@ export type AuthClientMetadata = typeof AuthClientMetadata.Type;
278354export const AuthClientSession = Schema.Struct({
279355 sessionId: AuthSessionId,
280356 subject: TrimmedNonEmptyString,
281 scopes: AuthEnvironmentScopes,
357 ...authScopeResponseFields,
282358 method: ServerAuthSessionMethod,
283359 client: AuthClientMetadata,
284360 issuedAt: Schema.DateTimeUtc,
......@@ -335,6 +411,7 @@ export class EnvironmentAuthorizationError extends Schema.TaggedError<Environmen
335411 {
336412 message: Schema.String,
337413 requiredScope: AuthEnvironmentScope,
414 requiredPermission: Schema.optionalKey(Schema.String),
338415 },
339416) {}
340417
......@@ -385,6 +462,7 @@ export const AuthSessionState = Schema.Struct({
385462 authenticated: Schema.Boolean,
386463 auth: ServerAuthDescriptor,
387464 scopes: Schema.optionalKey(AuthEnvironmentScopes),
465 permissions: authScopeResponseFields.permissions,
388466 sessionMethod: Schema.optionalKey(ServerAuthSessionMethod),
389467 expiresAt: Schema.optionalKey(Schema.DateTimeUtc),
390468});
packages/contracts/src/environmentHttp.ts+2-1
......@@ -170,6 +170,7 @@ export class EnvironmentScopeRequiredError extends Schema.TaggedError<Environmen
170170 {
171171 code: Schema.Literal("insufficient_scope"),
172172 requiredScope: AuthEnvironmentScope,
173 requiredPermission: Schema.optionalKey(Schema.String),
173174 traceId: TrimmedNonEmptyString,
174175 },
175176 { httpApiStatus: 403 },
......@@ -179,7 +180,7 @@ export class EnvironmentScopeRequiredError extends Schema.TaggedError<Environmen
179180 }
180181
181182 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.`;
183184 }
184185}
185186