diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx
index 645bc0b8a2fb..f2a809361ee4 100644
--- a/apps/mobile/src/App.tsx
+++ b/apps/mobile/src/App.tsx
@@ -1,3 +1,4 @@
+import { PermissionUpdateNotice } from "./components/PermissionUpdateNotice";
import * as Linking from "expo-linking";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
@@ -75,6 +76,7 @@ function AppContent() {
<>
+
diff --git a/apps/mobile/src/components/PermissionUpdateNotice.tsx b/apps/mobile/src/components/PermissionUpdateNotice.tsx
new file mode 100644
index 000000000000..55f2de0638fc
--- /dev/null
+++ b/apps/mobile/src/components/PermissionUpdateNotice.tsx
@@ -0,0 +1,84 @@
+import { useAtomValue } from "@effect/atom-react";
+import { sessionHasLegacyPermissions } from "@t3tools/contracts";
+import { Atom } from "effect/reactivity";
+import * as SecureStore from "expo-secure-store";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { Alert } from "react-native";
+
+import { useEnvironments } from "../state/environments";
+import { environmentSession } from "../state/session";
+
+const storageKey = "t3code.permission-update.v1";
+const dismissedThisLaunch = new Set();
+const shownThisLaunch = new Set();
+
+export function PermissionUpdateNotice() {
+ const { environments } = useEnvironments();
+ const [dismissed, setDismissed] = useState | null>(null);
+ const showing = useRef(false);
+ const affected = useAtomValue(
+ useMemo(
+ () =>
+ Atom.make((get) =>
+ environments.filter(({ environmentId }) => {
+ const session = get(environmentSession.sessionStateAtom(environmentId));
+ return (
+ session._tag === "Success" &&
+ !session.waiting &&
+ sessionHasLegacyPermissions(session.value)
+ );
+ }),
+ ),
+ [environments],
+ ),
+ );
+
+ useEffect(() => {
+ let active = true;
+ void SecureStore.getItemAsync(storageKey)
+ .then((raw) => {
+ const value: unknown = raw === null ? [] : JSON.parse(raw);
+ if (active)
+ setDismissed(
+ new Set(
+ Array.isArray(value)
+ ? value.filter((id): id is string => typeof id === "string")
+ : [],
+ ),
+ );
+ })
+ .catch(() => {
+ if (active) setDismissed(new Set());
+ });
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (dismissed === null || showing.current) return;
+ const environment = affected.find(
+ ({ environmentId }) => !dismissed.has(environmentId) && !shownThisLaunch.has(environmentId),
+ );
+ if (!environment) return;
+ showing.current = true;
+ shownThisLaunch.add(environment.environmentId);
+ const dismiss = () => {
+ dismissedThisLaunch.add(environment.environmentId);
+ const next = new Set([...dismissed, ...dismissedThisLaunch]);
+ // Keep notices serial when several environments have old grants.
+ showing.current = false;
+ setDismissed(next);
+ void SecureStore.setItemAsync(storageKey, JSON.stringify([...next])).catch(() => {
+ // Remember for this launch even if persistent storage is unavailable.
+ });
+ };
+ Alert.alert(
+ `Permissions have changed for ${environment.label}`,
+ "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.",
+ [{ text: "Got it", onPress: dismiss }],
+ { cancelable: false },
+ );
+ }, [affected, dismissed]);
+ return null;
+}
diff --git a/apps/mobile/src/features/settings/environment-maintenance.test.ts b/apps/mobile/src/features/settings/environment-maintenance.test.ts
index fb90d33b473f..e2e1ebdf99a4 100644
--- a/apps/mobile/src/features/settings/environment-maintenance.test.ts
+++ b/apps/mobile/src/features/settings/environment-maintenance.test.ts
@@ -76,6 +76,33 @@ describe("environment maintenance access", () => {
).toBe(expected);
});
+ it.each([
+ { permissions: ["environment:maintain"], expected: true },
+ { permissions: ["providers:manage"], expected: false },
+ { permissions: [], expected: false },
+ ] as const)(
+ "honors exact permissions over legacy scopes: $permissions",
+ ({ permissions, expected }) => {
+ expect(
+ canMaintainEnvironment(
+ {
+ authenticated: true,
+ auth: {
+ policy: "remote-reachable",
+ bootstrapMethods: [],
+ sessionMethods: [],
+ sessionCookieName: "session",
+ serverUpdateScope: "environment:maintain",
+ },
+ scopes: ["orchestration:operate"],
+ permissions,
+ },
+ true,
+ ),
+ ).toBe(expected);
+ },
+ );
+
it("requires remote desktop update support for desktop hosts", () => {
expect(supportsEnvironmentUpdate({})).toBe(false);
expect(supportsEnvironmentUpdate({ serverSelfUpdate: "respawn" })).toBe(true);
diff --git a/apps/mobile/src/features/settings/environment-maintenance.ts b/apps/mobile/src/features/settings/environment-maintenance.ts
index 02e149a42318..3dd1d559c71d 100644
--- a/apps/mobile/src/features/settings/environment-maintenance.ts
+++ b/apps/mobile/src/features/settings/environment-maintenance.ts
@@ -1,5 +1,6 @@
import {
- AuthOrchestrationOperateScope,
+ AuthEnvironmentMaintainScope,
+ sessionGrantsScope,
type AuthSessionState,
type ExecutionEnvironmentCapabilities,
type ServerProvider,
@@ -16,8 +17,7 @@ export function canMaintainEnvironment(session: AuthSessionState | null, connect
return (
connected &&
session?.authenticated === true &&
- session.scopes?.includes(session.auth.serverUpdateScope ?? AuthOrchestrationOperateScope) ===
- true
+ sessionGrantsScope(session, AuthEnvironmentMaintainScope)
);
}
diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
index 255fad6f4eaf..767a1fa1dc7f 100644
--- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
+++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
@@ -4,6 +4,7 @@ import {
DEFAULT_TERMINAL_ID,
EnvironmentId,
ThreadId,
+ sessionGrantsScope,
} from "@t3tools/contracts";
import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
import { SymbolView } from "../../components/AppSymbol";
@@ -279,9 +280,13 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
const isAuthenticated =
terminalSession.error === null && terminalSession.data?.authenticated === true;
const canOperateTerminal =
- isAuthenticated && terminalSession.data?.scopes?.includes(AuthTerminalOperateScope) === true;
+ isAuthenticated &&
+ terminalSession.data !== null &&
+ sessionGrantsScope(terminalSession.data, AuthTerminalOperateScope);
const canReadTerminal =
- isAuthenticated && terminalSession.data?.scopes?.includes(AuthTerminalReadScope) === true;
+ isAuthenticated &&
+ terminalSession.data !== null &&
+ sessionGrantsScope(terminalSession.data, AuthTerminalReadScope);
const environment = useEnvironmentPresentation(routeEnvironmentId);
const isEnvironmentReady = environment.presentation?.connection.phase === "connected";
const requestedTerminalId = firstRouteParam(params.terminalId);
diff --git a/apps/mobile/src/state/mediaActions.ts b/apps/mobile/src/state/mediaActions.ts
index 48af952df2cf..7658b8c46057 100644
--- a/apps/mobile/src/state/mediaActions.ts
+++ b/apps/mobile/src/state/mediaActions.ts
@@ -3,8 +3,9 @@ import { useNavigation } from "@react-navigation/native";
import type { MediaActionId } from "@t3tools/client-runtime/media-actions";
import {
AuthFilesystemReadScope,
- type AuthSessionState,
type EnvironmentId,
+ sessionGrantsScope,
+ type SessionGrantInput,
} from "@t3tools/contracts";
import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links";
import * as Option from "effect/Option";
@@ -21,11 +22,8 @@ import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
/** An explicit action may ask the server while its grant is still unresolved. */
-function allowsHostMedia(session: Pick | null) {
- return (
- session === null ||
- (session.authenticated && session.scopes?.includes(AuthFilesystemReadScope) === true)
- );
+function allowsHostMedia(session: SessionGrantInput | null) {
+ return session === null || sessionGrantsScope(session, AuthFilesystemReadScope);
}
function canReadHostMedia(environmentId: EnvironmentId | null): boolean {
diff --git a/apps/mobile/src/state/session.test.ts b/apps/mobile/src/state/session.test.ts
index d1ee156235da..aeb8f684e0f7 100644
--- a/apps/mobile/src/state/session.test.ts
+++ b/apps/mobile/src/state/session.test.ts
@@ -98,3 +98,18 @@ it("reacts to grant revocation, failure, and regrant without a connection list c
expect(appAtomRegistry.get(observed)).toEqual(new Set([secondary]));
expect(changes).not.toHaveLength(0);
});
+
+it("uses exact new-server permissions and keeps old-server grants usable", () => {
+ // A legacy representation must never override an explicitly narrowed grant.
+ appAtomRegistry.set(source(primary), AsyncResult.success({ ...session(true), permissions: [] }));
+ expect(useEnvironmentScope(primary, AuthOrchestrationOperateScope)).toBe(false);
+ expect(readEnvironmentScope(primary, AuthOrchestrationOperateScope)).toBe(false);
+ appAtomRegistry.set(
+ source(primary),
+ AsyncResult.success({ ...session(false), permissions: [AuthOrchestrationOperateScope] }),
+ );
+ expect(useEnvironmentScope(primary, AuthOrchestrationOperateScope)).toBe(true);
+ expect(readEnvironmentScope(primary, AuthOrchestrationOperateScope)).toBe(true);
+ appAtomRegistry.set(source(primary), AsyncResult.success(session(true)));
+ expect(useEnvironmentScope(primary, AuthOrchestrationOperateScope)).toBe(true);
+});
diff --git a/apps/mobile/src/state/session.ts b/apps/mobile/src/state/session.ts
index dffff631d8a0..8d3fa134869c 100644
--- a/apps/mobile/src/state/session.ts
+++ b/apps/mobile/src/state/session.ts
@@ -1,6 +1,11 @@
import { useAtomValue } from "@effect/atom-react";
import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";
-import type { AuthEnvironmentScope, AuthSessionState, EnvironmentId } from "@t3tools/contracts";
+import {
+ type AuthEnvironmentScope,
+ type AuthSessionState,
+ type EnvironmentId,
+ sessionGrantsScope,
+} from "@t3tools/contracts";
import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/reactivity";
import { useMemo } from "react";
@@ -17,11 +22,7 @@ function sessionHasScope(
scope: AuthEnvironmentScope,
): boolean {
const session = Option.getOrNull(AsyncResult.value(result));
- return (
- result._tag !== "Failure" &&
- session?.authenticated === true &&
- session.scopes?.includes(scope) === true
- );
+ return result._tag !== "Failure" && session !== null && sessionGrantsScope(session, scope);
}
/** Uses the selected environment's grant, including cached scopes during a refresh. */
diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts
index 71ee138ddc3a..a85db0fd1492 100644
--- a/apps/server/src/auth/EnvironmentAuth.test.ts
+++ b/apps/server/src/auth/EnvironmentAuth.test.ts
@@ -1,5 +1,9 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
-import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts";
+import {
+ authScopeResponse,
+ AuthAdministrativeScopes,
+ AuthStandardClientScopes,
+} from "@t3tools/contracts";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
@@ -102,6 +106,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
const authenticated = yield* serverAuth.authenticateHttpRequest(request);
expect(devExchange.cookieName).toMatch(/^t3_dev_session_/);
expect(devExchange.expireNormalCookie).toBe(true);
+ expect(devExchange.response).toMatchObject(authScopeResponse(AuthAdministrativeScopes));
expect(authenticated.scopes).toEqual(["orchestration:read"]);
}).pipe(
Effect.provide(
@@ -214,6 +219,16 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
expect(firstSession.subject).toBe("reusable-dev-token-child");
expect(secondSession.subject).toBe("reusable-dev-token-child");
expect((yield* sessions.verify(token)).subject).toBe("reusable-dev-token");
+ const before = yield* serverAuth.listClientSessions(firstSession.sessionId);
+ const denied = yield* serverAuth
+ .exchangeBootstrapCredentialForAccessToken(token, ["review:write"], requestMetadata)
+ .pipe(Effect.flip);
+ expect(denied._tag).toBe("ServerAuthScopeNotGrantedError");
+ const empty = yield* serverAuth
+ .exchangeBootstrapCredentialForAccessToken(token, [], requestMetadata)
+ .pipe(Effect.flip);
+ expect(empty._tag).toBe("ServerAuthScopeNotGrantedError");
+ expect(yield* serverAuth.listClientSessions(firstSession.sessionId)).toEqual(before);
}).pipe(
Effect.provide(
layerEnvironmentAuth({
@@ -241,7 +256,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError");
expect(
(yield* serverAuth.createBrowserSession(recovery.credential, requestMetadata)).response,
- ).toMatchObject({ authenticated: true, scopes: AuthAdministrativeScopes });
+ ).toMatchObject({ authenticated: true, ...authScopeResponse(AuthAdministrativeScopes) });
}).pipe(
Effect.provide(
layerEnvironmentAuth({
@@ -355,7 +370,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
const error = yield* serverAuth
.exchangeBootstrapCredentialForAccessToken(
pairingCredential.credential,
- ["orchestration:read", "access:write"],
+ ["access:write"],
requestMetadata,
)
.pipe(Effect.flip);
@@ -393,7 +408,10 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
it.effect.each([
{ label: "omits scope", requestedScopes: undefined },
- { label: "requests no scopes", requestedScopes: [] },
+ {
+ label: "requests unsupported permissions alongside a granted one",
+ requestedScopes: ["orchestration:read", "access:write"] as const,
+ },
])("inherits a constrained pairing grant when token exchange $label", ({ requestedScopes }) =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts
index dae46d1351cb..ac90e67b6f26 100644
--- a/apps/server/src/auth/EnvironmentAuth.ts
+++ b/apps/server/src/auth/EnvironmentAuth.ts
@@ -16,6 +16,7 @@ import {
type AuthPairingCredentialResult,
type AuthSessionId,
type AuthSessionState,
+ authScopeResponse,
type ServerAuthDescriptor,
type ServerAuthSessionMethod,
type AuthWebSocketTicketResult,
@@ -773,7 +774,7 @@ export const make = Effect.gen(function* () {
({
authenticated: true,
auth: descriptor,
- scopes: session.scopes,
+ ...authScopeResponse(session.scopes),
sessionMethod: session.method,
...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}),
}) satisfies AuthSessionState,
@@ -800,7 +801,7 @@ export const make = Effect.gen(function* () {
({
response: {
authenticated: true,
- scopes: session.scopes,
+ ...authScopeResponse(session.scopes),
sessionMethod: session.method,
expiresAt: DateTime.toUtc(DateTime.add(now, { days: 30 })),
} satisfies AuthBrowserSessionResult,
@@ -841,7 +842,7 @@ export const make = Effect.gen(function* () {
return {
response: {
authenticated: true,
- scopes: session.scopes,
+ ...authScopeResponse(session.scopes),
sessionMethod: session.method,
expiresAt: DateTime.toUtc(session.expiresAt),
} satisfies AuthBrowserSessionResult,
@@ -890,15 +891,20 @@ export const make = Effect.gen(function* () {
};
const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] =
- (credential, requestedScopesInput, requestMetadata, input) => {
- const requestedScopes = requestedScopesInput?.length ? requestedScopesInput : undefined;
+ (credential, requestedScopes, requestMetadata, input) => {
return resolveBootstrapGrant(credential, {
...input,
...(requestedScopes !== undefined ? { requestedScopes } : {}),
}).pipe(
Effect.flatMap((grant) =>
Effect.gen(function* () {
- const grantedScopes = requestedScopes ?? grant.scopes;
+ const grantedScopes =
+ requestedScopes === undefined
+ ? grant.scopes
+ : [...new Set(requestedScopes)].filter((scope) => grant.scopes.includes(scope));
+ if (grantedScopes.length === 0) {
+ return yield* new ServerAuthScopeNotGrantedError({});
+ }
return yield* sessions
.issue({
method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token",
@@ -1005,6 +1011,7 @@ export const make = Effect.gen(function* () {
];
return pairingLinks
.filter((pairingLink) => !excludedSubjects.includes(pairingLink.subject))
+ .map((link) => ({ ...link, ...authScopeResponse(link.scopes) }))
.toSorted(
(left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds,
);
@@ -1110,6 +1117,7 @@ export const make = Effect.gen(function* () {
Effect.map((clientSessions) =>
clientSessions.map((clientSession): AuthClientSession => ({
...clientSession,
+ ...authScopeResponse(clientSession.scopes),
current: clientSession.sessionId === currentSessionId,
})),
),
diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts
index 6fcc087400d9..d3f5bf7ef8e5 100644
--- a/apps/server/src/auth/PairingGrantStore.ts
+++ b/apps/server/src/auth/PairingGrantStore.ts
@@ -504,7 +504,10 @@ export const make = Effect.gen(function* () {
];
}
- if (input?.requestedScopes?.some((scope) => !grant.scopes.includes(scope))) {
+ if (
+ input?.requestedScopes !== undefined &&
+ !input.requestedScopes.some((scope) => grant.scopes.includes(scope))
+ ) {
return [
{
_tag: "error",
@@ -608,7 +611,10 @@ export const make = Effect.gen(function* () {
return yield* new BootstrapCredentialProofKeyMismatchError({});
}
- if (input?.requestedScopes?.some((scope) => !matching.value.scopes.includes(scope))) {
+ if (
+ input?.requestedScopes !== undefined &&
+ !input.requestedScopes.some((scope) => matching.value.scopes.includes(scope))
+ ) {
return yield* new BootstrapCredentialScopeNotGrantedError({});
}
diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts
index 70cd809b70ac..97a8f8dc0424 100644
--- a/apps/server/src/auth/RpcAuthorization.test.ts
+++ b/apps/server/src/auth/RpcAuthorization.test.ts
@@ -247,7 +247,7 @@ describe("RPC scope middleware", () => {
yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip),
).toMatchObject({
_tag: "EnvironmentAuthorizationError",
- requiredScope: missing,
+ requiredPermission: missing,
});
expect(handled).toEqual([]);
}).pipe(Effect.scoped),
@@ -291,7 +291,7 @@ describe("settings mutation authorization", () => {
patch: { defaultRuntimeMode: "full-access" },
providerInstanceMutation,
}).pipe(Effect.flip),
- ).toMatchObject({ requiredScope: AuthSettingsWriteScope });
+ ).toMatchObject({ requiredPermission: AuthSettingsWriteScope });
expect(handled).toBe(1);
}).pipe(Effect.scoped),
);
@@ -317,7 +317,7 @@ describe("settings mutation authorization", () => {
patch: {},
providerInstanceMutation,
}).pipe(Effect.flip),
- ).toMatchObject({ requiredScope: AuthProvidersManageScope });
+ ).toMatchObject({ requiredPermission: AuthProvidersManageScope });
expect(handled).toBe(false);
}).pipe(Effect.scoped),
);
@@ -355,7 +355,7 @@ it.effect("requires task permission before attaching a prepared worktree to a th
mode: "worktree",
threadId: ThreadId.make("thread"),
}).pipe(Effect.flip),
- ).toMatchObject({ requiredScope: AuthOrchestrationOperateScope });
+ ).toMatchObject({ requiredPermission: AuthOrchestrationOperateScope });
expect(handled).toBe(false);
}).pipe(Effect.scoped),
);
@@ -394,7 +394,10 @@ it.effect("separates host file URLs from readable attachment URLs", () =>
] as const) {
expect(
yield* client[WS_METHODS.assetsCreateUrl]({ resource }).pipe(Effect.flip),
- ).toMatchObject({ requiredScope: AuthFilesystemReadScope });
+ ).toMatchObject({
+ requiredScope: AuthOrchestrationReadScope,
+ requiredPermission: AuthFilesystemReadScope,
+ });
}
expect(handled).toBe(1);
}).pipe(Effect.scoped),
diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts
index b4b96f2f57cb..ee0d24e44d5a 100644
--- a/apps/server/src/auth/RpcAuthorization.ts
+++ b/apps/server/src/auth/RpcAuthorization.ts
@@ -2,6 +2,7 @@ import {
CLIENT_GUARDED_RPC_SCOPES,
type DeviceListInput,
clientRpcRequiredScopes,
+ authScopeRequiredResponse,
AssetCreateUrlInput,
AuthAccessReadScope,
ServerSettingsPatch,
@@ -213,7 +214,7 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope
export const rpcAuthorizationError = (requiredScope: AuthEnvironmentScope) =>
new EnvironmentAuthorizationError({
message: `The authenticated token is missing required scope: ${requiredScope}.`,
- requiredScope,
+ ...authScopeRequiredResponse(requiredScope),
});
const SettingsUpdate = Schema.Struct({
diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts
index f7d3e7e1f5ac..374a51be6872 100644
--- a/apps/server/src/auth/SessionStore.test.ts
+++ b/apps/server/src/auth/SessionStore.test.ts
@@ -20,6 +20,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts";
import * as AuthSessions from "../persistence/AuthSessions.ts";
import * as SessionStore from "./SessionStore.ts";
import * as ServerSecretStore from "./ServerSecretStore.ts";
+import { base64UrlDecodeUtf8, base64UrlEncode, signPayload } from "./utils.ts";
const layerServerConfig = (overrides?: Partial) =>
Layer.effect(
@@ -290,6 +291,34 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
expect((yield* sessions.verify(uncapped.token)).runtimeModeCeiling).toBeUndefined();
}).pipe(Effect.provide(layerSessionStore())),
);
+ it.effect("keeps recorded scopes unchanged for both token versions", () =>
+ Effect.gen(function* () {
+ const sessions = yield* SessionStore.SessionStore;
+ const secrets = yield* ServerSecretStore.ServerSecretStore;
+ const legacyScopes = ["orchestration:read", "terminal:operate", "review:write"] as const;
+ const issued = yield* sessions.issue({ subject: "one-time-token", scopes: legacyScopes });
+ // Accept prerelease v2 credentials without widening their recorded grant.
+ const [encodedPayload] = issued.token.split(".");
+ const currentClaims = base64UrlDecodeUtf8(encodedPayload!);
+ expect(currentClaims).toContain('"v":1');
+ const legacyPayload = base64UrlEncode(currentClaims.replace('"v":1', '"v":2'));
+ const secret = yield* secrets.getOrCreateRandom("server-signing-key", 32);
+ const legacyToken = `${legacyPayload}.${signPayload(legacyPayload, secret)}`;
+
+ expect((yield* sessions.verify(issued.token)).scopes).toEqual(legacyScopes);
+ expect((yield* sessions.verify(legacyToken)).scopes).toEqual(legacyScopes);
+ }).pipe(
+ Effect.provide(
+ SessionStore.layer.pipe(
+ Layer.provideMerge(ServerSecretStore.layer),
+ Layer.provide(SqlitePersistence.layerMemory),
+ Layer.provide(layerServerEnvironment(EnvironmentId.make("test-environment"))),
+ Layer.provide(layerServerConfig()),
+ ),
+ ),
+ ),
+ );
+
it.effect("rejects malformed session tokens", () =>
Effect.gen(function* () {
const sessions = yield* SessionStore.SessionStore;
diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts
index 007106f25a39..4db7e95e3066 100644
--- a/apps/server/src/auth/SessionStore.ts
+++ b/apps/server/src/auth/SessionStore.ts
@@ -428,8 +428,9 @@ export class SessionStore extends Context.Service<
const SIGNING_SECRET_NAME = "server-signing-key";
const DEFAULT_SESSION_TTL = Duration.days(30);
const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5);
+
const SessionClaims = Schema.Struct({
- v: Schema.Literal(1),
+ v: Schema.Literals([1, 2]),
kind: Schema.Literal("session"),
sid: AuthSessionId,
sub: Schema.String,
diff --git a/apps/server/src/auth/http.test.ts b/apps/server/src/auth/http.test.ts
index e879f0a72ef3..064578f727a0 100644
--- a/apps/server/src/auth/http.test.ts
+++ b/apps/server/src/auth/http.test.ts
@@ -1,6 +1,9 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import {
AuthSessionId,
+ AuthTokenExchangeGrantType,
+ AuthEnvironmentBootstrapTokenType,
+ AuthAccessTokenType,
EnvironmentAuthenticatedAuth,
EnvironmentHttpApi,
} from "@t3tools/contracts";
@@ -100,6 +103,22 @@ it.effect("sets the selected browser session cookies through the HTTP route", ()
requestContext,
);
expect(devResponse.status).toBe(200);
+ const retiredScopeResponse = await environmentA.handler(
+ new Request("http://127.0.0.1/oauth/token", {
+ method: "POST",
+ body: new URLSearchParams({
+ grant_type: AuthTokenExchangeGrantType,
+ subject_token: DEV_TOKEN,
+ subject_token_type: AuthEnvironmentBootstrapTokenType,
+ requested_token_type: AuthAccessTokenType,
+ scope: "review:write",
+ }),
+ }),
+ requestContext,
+ );
+ expect(retiredScopeResponse.status).toBe(400);
+ expect(await retiredScopeResponse.json()).toMatchObject({ reason: "invalid_scope" });
+
const devCookies = devResponse.headers.getSetCookie();
const devCookie = devCookies.find((cookie) => cookie.startsWith("t3_dev_session_"));
expect(devCookie).toContain("HttpOnly");
diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts
index eba89f0a66fa..c0d2946813f9 100644
--- a/apps/server/src/auth/http.ts
+++ b/apps/server/src/auth/http.ts
@@ -1,4 +1,5 @@
import {
+ authScopeRequiredResponse,
AuthAccessReadScope,
AuthAccessWriteScope,
AuthStandardClientScopes,
@@ -18,11 +19,13 @@ import {
EnvironmentAuthenticatedPrincipal,
} from "@t3tools/contracts";
import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts";
-import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope";
+import { parseOAuthScope } from "@t3tools/shared/oauthScope";
import { causeErrorTag } from "@t3tools/shared/observability";
import * as Clock from "effect/Clock";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import { identity } from "effect/Function";
import * as Layer from "effect/Layer";
import * as Cookies from "effect/http/Cookies";
import * as HttpEffect from "effect/http/HttpEffect";
@@ -122,7 +125,7 @@ export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope
Effect.fail(
new EnvironmentScopeRequiredError({
code: "insufficient_scope",
- requiredScope,
+ ...authScopeRequiredResponse(requiredScope),
traceId,
}),
),
@@ -356,11 +359,8 @@ export const layer = HttpApiBuilder.group(
const requestedScopes =
args.payload.scope === undefined
? undefined
- : parseAllowedOAuthScope({
- value: args.payload.scope,
- allowedScopes: new Set(AuthGrantScope.literals),
- });
- if (requestedScopes === null) {
+ : (parseOAuthScope(args.payload.scope)?.filter(Schema.is(AuthGrantScope)) ?? null);
+ if (requestedScopes === null || requestedScopes?.length === 0) {
return yield* failEnvironmentInvalidRequest("invalid_scope");
}
const proofKeyThumbprint = args.headers.dpop
diff --git a/apps/server/src/cliAuthFormat.ts b/apps/server/src/cliAuthFormat.ts
index 2ef5ba10a80b..2af32d71c8de 100644
--- a/apps/server/src/cliAuthFormat.ts
+++ b/apps/server/src/cliAuthFormat.ts
@@ -77,7 +77,7 @@ export function formatPairingCredentialList(
credentials.map((credential) => ({
id: credential.id,
...(credential.label ? { label: credential.label } : {}),
- scopes: credential.scopes,
+ scopes: credential.permissions ?? credential.scopes,
createdAt: toIsoString(credential.createdAt),
expiresAt: toIsoString(credential.expiresAt),
})),
@@ -95,7 +95,7 @@ export function formatPairingCredentialList(
.map((credential) =>
[
`${credential.id}${credential.label ? ` (${credential.label})` : ""}`,
- ` scopes: ${credential.scopes.join(" ")}`,
+ ` scopes: ${(credential.permissions ?? credential.scopes).join(" ")}`,
` created: ${toIsoString(credential.createdAt)}`,
` expires: ${toIsoString(credential.expiresAt)}`,
].join(newline),
diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts
index fa10dd7493c2..ca18088b4bf7 100644
--- a/apps/server/src/persistence/AuthPairingLinks.ts
+++ b/apps/server/src/persistence/AuthPairingLinks.ts
@@ -172,13 +172,13 @@ export const make = Effect.gen(function* () {
proof_key_thumbprint IS NULL
OR proof_key_thumbprint = ${proofKeyThumbprint}
)
- AND NOT EXISTS (
+ AND (${requestedScopes === undefined} OR EXISTS (
SELECT 1
FROM json_each(${JSON.stringify(requestedScopes ?? [])}) AS requested
- WHERE requested.value NOT IN (
+ WHERE requested.value IN (
SELECT value FROM json_each(auth_pairing_links.scopes)
)
- )
+ ))
RETURNING
id AS "id",
credential AS "credential",
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 4cba3fca0197..fab4edb44406 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -24,6 +24,7 @@ import {
DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL,
AcpRegistryOperationError,
CommandId,
+ authScopeResponse,
AuthAccessStreamError,
type AuthAccessStreamEvent,
AuthOrchestrationOperateScope,
@@ -561,7 +562,7 @@ function toAuthAccessStreamEvent(
version: 1,
revision,
type: "pairingLinkUpserted",
- payload: change.pairingLink,
+ payload: { ...change.pairingLink, ...authScopeResponse(change.pairingLink.scopes) },
};
case "pairingLinkRemoved":
return {
@@ -577,6 +578,7 @@ function toAuthAccessStreamEvent(
type: "clientUpserted",
payload: {
...change.clientSession,
+ ...authScopeResponse(change.clientSession.scopes),
current: change.clientSession.sessionId === currentSessionId,
},
};
diff --git a/apps/web/src/components/PermissionUpdateNotice.test.tsx b/apps/web/src/components/PermissionUpdateNotice.test.tsx
new file mode 100644
index 000000000000..f96a2d410f16
--- /dev/null
+++ b/apps/web/src/components/PermissionUpdateNotice.test.tsx
@@ -0,0 +1,111 @@
+import { act, create, type ReactTestRenderer } from "react-test-renderer";
+import { beforeEach, afterEach, expect, it, vi } from "vite-plus/test";
+
+const state = vi.hoisted(() => ({
+ id: 0,
+ saved: false,
+ session: {
+ _tag: "Success",
+ waiting: false,
+ value: { authenticated: true, permissions: ["orchestration:operate"] },
+ },
+ add: vi.fn((_notice: unknown) => "notice"),
+ close: vi.fn(),
+ save: vi.fn(),
+ navigate: vi.fn(),
+}));
+vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.session }));
+vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate }));
+vi.mock("../state/environments", () => ({
+ useEnvironments: () => ({
+ environments: [{ environmentId: `env-${state.id}`, label: "Work laptop" }],
+ }),
+}));
+vi.mock("../state/session", () => ({ environmentSession: { sessionStateAtom: () => null } }));
+vi.mock("../hooks/useLocalStorage", () => ({
+ getLocalStorageItem: () => state.saved,
+ setLocalStorageItem: (...args: unknown[]) => state.save(...args),
+}));
+vi.mock("./ui/toast", () => ({ toastManager: { add: state.add, close: state.close } }));
+import { PermissionUpdateNotice } from "./PermissionUpdateNotice";
+
+let renderer: ReactTestRenderer;
+beforeEach(() => {
+ state.id++;
+ state.saved = false;
+ state.session = {
+ _tag: "Success",
+ waiting: false,
+ value: { authenticated: true, permissions: ["orchestration:operate"] },
+ };
+ vi.clearAllMocks();
+});
+afterEach(async () => {
+ if (renderer) await act(async () => renderer.unmount());
+});
+async function render() {
+ await act(async () => {
+ renderer = create();
+ });
+}
+
+it("keeps the notice visible and persists dismissal, with a route to pairing settings", async () => {
+ await render();
+ const notice = state.add.mock.calls[0]![0] as unknown as {
+ timeout: number;
+ onClose: () => void;
+ actionProps: { onClick: () => void };
+ };
+ expect(notice.timeout).toBe(0);
+ expect(state.save).not.toHaveBeenCalled();
+ notice.actionProps.onClick();
+ expect(state.navigate).toHaveBeenCalledWith({ to: "/settings/connections" });
+ expect(state.close).toHaveBeenCalledWith("notice");
+ expect(state.save).toHaveBeenCalledWith(
+ `t3code:permission-update:v1:env-${state.id}`,
+ true,
+ expect.anything(),
+ );
+});
+it("does not repeat on session refresh or remount", async () => {
+ await render();
+ await act(async () => renderer.unmount());
+ state.session = { ...state.session };
+ await render();
+ expect(state.add).toHaveBeenCalledTimes(1);
+});
+it("skips a notice dismissed on an earlier launch", async () => {
+ state.saved = true;
+ await render();
+ expect(state.add).not.toHaveBeenCalled();
+});
+it("waits for a successful, settled session check", async () => {
+ state.session._tag = "Failure";
+ await render();
+ expect(state.add).not.toHaveBeenCalled();
+ state.session = { ...state.session, _tag: "Success", waiting: true };
+ await act(async () => renderer.update());
+ expect(state.add).not.toHaveBeenCalled();
+ state.session = { ...state.session, waiting: false };
+ await act(async () => renderer.update());
+ expect(state.add).toHaveBeenCalledTimes(1);
+});
+it("does not warn for a granular grant", async () => {
+ state.session.value.permissions.push("filesystem:read");
+ await render();
+ expect(state.add).not.toHaveBeenCalled();
+});
+
+it("persists the secondary dismissal without relying on the toast close callback", async () => {
+ await render();
+ const notice = state.add.mock.calls[0]![0] as {
+ data: { secondaryActionProps: { onClick: () => void } };
+ };
+ notice.data.secondaryActionProps.onClick();
+ expect(state.save).toHaveBeenCalledWith(
+ `t3code:permission-update:v1:env-${state.id}`,
+ true,
+ expect.anything(),
+ );
+ expect(state.close).toHaveBeenCalledWith("notice");
+});
diff --git a/apps/web/src/components/PermissionUpdateNotice.tsx b/apps/web/src/components/PermissionUpdateNotice.tsx
new file mode 100644
index 000000000000..6c01afe2ccfe
--- /dev/null
+++ b/apps/web/src/components/PermissionUpdateNotice.tsx
@@ -0,0 +1,78 @@
+import { useAtomValue } from "@effect/atom-react";
+import { sessionHasLegacyPermissions, type EnvironmentId } from "@t3tools/contracts";
+import { useNavigate } from "@tanstack/react-router";
+import * as Schema from "effect/Schema";
+import { useEffect } from "react";
+
+import { getLocalStorageItem, setLocalStorageItem } from "../hooks/useLocalStorage";
+import { useEnvironments } from "../state/environments";
+import { environmentSession } from "../state/session";
+import { toastManager } from "./ui/toast";
+
+// Keep an active toast across remounts, including Strict Mode effect replay.
+const shown = new Set();
+
+function EnvironmentPermissionNotice({
+ environmentId,
+ label,
+}: {
+ environmentId: EnvironmentId;
+ label: string;
+}) {
+ const session = useAtomValue(environmentSession.sessionStateAtom(environmentId));
+ const navigate = useNavigate();
+ useEffect(() => {
+ if (
+ session._tag !== "Success" ||
+ session.waiting ||
+ !sessionHasLegacyPermissions(session.value)
+ )
+ return;
+ if (shown.has(environmentId)) return;
+ const key = `t3code:permission-update:v1:${environmentId}`;
+ try {
+ if (getLocalStorageItem(key, Schema.Boolean)) return;
+ } catch {
+ // An unavailable store must not prevent the notice.
+ }
+ shown.add(environmentId);
+ const persistDismissal = () => {
+ try {
+ setLocalStorageItem(key, true, Schema.Boolean);
+ } catch {
+ // The in-memory marker still prevents repeats during this launch.
+ }
+ };
+ const dismiss = () => {
+ persistDismissal();
+ toastManager.close(id);
+ };
+ const id = toastManager.add({
+ title: `Permissions have changed for ${label}`,
+ description:
+ "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.",
+ timeout: 0,
+ onClose: persistDismissal,
+ actionProps: {
+ children: "Open Connections",
+ onClick: () => {
+ dismiss();
+ void navigate({ to: "/settings/connections" });
+ },
+ },
+ data: {
+ actionLayout: "stacked-end",
+ secondaryActionProps: { children: "Dismiss", onClick: dismiss },
+ secondaryActionVariant: "ghost",
+ },
+ });
+ }, [environmentId, label, navigate, session]);
+ return null;
+}
+
+export function PermissionUpdateNotice() {
+ const { environments } = useEnvironments();
+ return environments.map(({ environmentId, label }) => (
+
+ ));
+}
diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx
index 013361915794..e40d4f5d0792 100644
--- a/apps/web/src/components/ServerUpdateAction.tsx
+++ b/apps/web/src/components/ServerUpdateAction.tsx
@@ -1,5 +1,9 @@
import { useAtomValue } from "@effect/atom-react";
-import { AuthOrchestrationOperateScope, type AuthSessionState } from "@t3tools/contracts";
+import {
+ AuthEnvironmentMaintainScope,
+ type AuthSessionState,
+ sessionGrantsScope,
+} from "@t3tools/contracts";
import type { AsyncResult } from "effect/reactivity";
import { environmentSession } from "~/state/session";
import type {
@@ -162,13 +166,7 @@ export function ServerUpdatesAction({
function canUpdateServer(result: AsyncResult.AsyncResult): boolean {
if (result._tag !== "Success" || !result.value.authenticated) return false;
- const session = result.value;
- // Only self-update bridges the old authorization protocol. Upgraded servers
- // advertise the new scope even when this client's grant predates it.
- return (
- session.scopes?.includes(session.auth.serverUpdateScope ?? AuthOrchestrationOperateScope) ===
- true
- );
+ return sessionGrantsScope(result.value, AuthEnvironmentMaintainScope);
}
/**
diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx
index 28e13b49443a..4e1c92f5ffaf 100644
--- a/apps/web/src/components/media/MediaActions.tsx
+++ b/apps/web/src/components/media/MediaActions.tsx
@@ -11,6 +11,8 @@ import {
type AuthSessionState,
type ContextMenuItem,
type EnvironmentId,
+ sessionGrantsScope,
+ type SessionGrantInput,
} from "@t3tools/contracts";
import * as Option from "effect/Option";
import { AsyncResult } from "effect/reactivity";
@@ -43,11 +45,8 @@ function mediaFileName(source: MediaActionSource): string {
}
/** An explicit action may ask the server while its grant is still unresolved. */
-function allowsHostMedia(session: Pick | null) {
- return (
- session === null ||
- (session.authenticated && session.scopes?.includes(AuthFilesystemReadScope) === true)
- );
+function allowsHostMedia(session: SessionGrantInput | null) {
+ return session === null || sessionGrantsScope(session, AuthFilesystemReadScope);
}
function canReadHostMedia(environmentId: EnvironmentId | null): boolean {
diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx
index 1520097eda7e..5877f1bbda6c 100644
--- a/apps/web/src/components/settings/ConnectionsSettings.tsx
+++ b/apps/web/src/components/settings/ConnectionsSettings.tsx
@@ -534,6 +534,7 @@ function sortDesktopClientSessions(sessions: ReadonlyArray {
it("does not treat the old orchestration grant as provider management", () => {
expect(
resolveRemoteOperateAccess({
- session: { authenticated: true, scopes: ["orchestration:operate"] },
+ session: {
+ authenticated: true,
+ scopes: ["orchestration:operate"],
+ auth: { serverUpdateScope: "environment:maintain" },
+ },
isPending: false,
hasError: false,
}),
).toBe("denied");
});
+
+ it("accepts the orchestration grant from a server that predates providers:manage", () => {
+ expect(
+ resolveRemoteOperateAccess({
+ session: { authenticated: true, scopes: ["orchestration:operate"], auth: {} },
+ isPending: false,
+ hasError: false,
+ }),
+ ).toBe("granted");
+ });
it("derives access from the environment session's granted scopes", () => {
expect(
resolveRemoteOperateAccess({
diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts
index 4e7faab872d1..6e05957c76b5 100644
--- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts
+++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts
@@ -3,6 +3,8 @@ import {
AuthProvidersManageScope,
type AuthSessionState,
type EnvironmentId,
+ sessionGrantsScope,
+ type SessionGrantInput,
} from "@t3tools/contracts";
export interface ProviderEnvironmentOptionLike {
@@ -76,21 +78,19 @@ export type ProviderOperateAccess = "granted" | "denied" | "pending";
/** Cached grants remain usable during revalidation; unknown or failed lookups grant nothing. */
function resolveSessionOperateAccess(input: {
- readonly session: Pick | null;
+ readonly session: SessionGrantInput | null;
readonly isPending: boolean;
readonly hasError: boolean;
}): ProviderOperateAccess {
if (input.hasError) return "denied";
if (input.session === null) return input.isPending ? "pending" : "denied";
- return input.session.authenticated && input.session.scopes?.includes(AuthProvidersManageScope)
- ? "granted"
- : "denied";
+ return sessionGrantsScope(input.session, AuthProvidersManageScope) ? "granted" : "denied";
}
export function resolvePrimaryOperateAccess(input: {
readonly isPrimary: boolean;
readonly hasDesktopBridge: boolean;
- readonly session: Pick | null;
+ readonly session: SessionGrantInput | null;
readonly isPending: boolean;
readonly hasError: boolean;
}): ProviderOperateAccess {
@@ -98,7 +98,7 @@ export function resolvePrimaryOperateAccess(input: {
}
export function resolveRemoteOperateAccess(input: {
- readonly session: Pick | null;
+ readonly session: SessionGrantInput | null;
readonly isPending: boolean;
readonly hasError: boolean;
}): ProviderOperateAccess {
diff --git a/apps/web/src/components/usage/UsagePriceOverrides.tsx b/apps/web/src/components/usage/UsagePriceOverrides.tsx
index 50d0953ce842..4fe68f66c8a3 100644
--- a/apps/web/src/components/usage/UsagePriceOverrides.tsx
+++ b/apps/web/src/components/usage/UsagePriceOverrides.tsx
@@ -1,5 +1,5 @@
import { useAtomValue } from "@effect/atom-react";
-import { AuthSettingsWriteScope, type EnvironmentId } from "@t3tools/contracts";
+import { AuthSettingsWriteScope, type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts";
import { ChevronDownIcon, PlusIcon, RotateCcwIcon, XIcon } from "lucide-react";
import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/reactivity";
@@ -54,7 +54,7 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] =>
? session.waiting
? "pending"
: "denied"
- : sessionData.authenticated && sessionData.scopes?.includes(AuthSettingsWriteScope)
+ : sessionGrantsScope(sessionData, AuthSettingsWriteScope)
? "granted"
: "denied";
return {
diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts
index 2e089668ccc9..04fab1c1d1c0 100644
--- a/apps/web/src/hooks/useSettings.ts
+++ b/apps/web/src/hooks/useSettings.ts
@@ -19,6 +19,7 @@ import {
type ProviderInstanceMutation,
ServerSettings,
type ServerSettingsPatch,
+ sessionGrantsScope,
} from "@t3tools/contracts";
import {
type ClientSettingsPatch,
@@ -446,7 +447,7 @@ function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray ({
: AsyncResult.success({
authenticated: true,
scopes: [...(state.scopes.get(environmentId) ?? [])],
+ auth: { serverUpdateScope: "environment:maintain" },
}),
}));
vi.mock("../state/threads", () => ({
diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts
index 6592a48a1f4c..cab4dde3dd20 100644
--- a/apps/web/src/hooks/useThreadActions.ts
+++ b/apps/web/src/hooks/useThreadActions.ts
@@ -14,6 +14,7 @@ import {
EnvironmentId,
type ScopedThreadRef,
ThreadId,
+ sessionGrantsScope,
} from "@t3tools/contracts";
import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
import * as Cause from "effect/Cause";
@@ -496,8 +497,7 @@ export function useThreadActions() {
if (permissionFailure) return permissionFailure;
canDeleteWorktree =
sessionResult._tag === "Success" &&
- sessionResult.value.authenticated &&
- sessionResult.value.scopes?.includes(AuthSourceControlWriteScope) === true;
+ sessionGrantsScope(sessionResult.value, AuthSourceControlWriteScope);
}
let shouldDeleteWorktree = false;
const environmentSettings = environmentConfig?.settings;
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index 2418fb1132fa..d1c4ef730c86 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -1,3 +1,4 @@
+import { PermissionUpdateNotice } from "../components/PermissionUpdateNotice";
import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts";
import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
import {
@@ -237,6 +238,7 @@ function RootRouteView() {
+
{primaryEnvironmentAuthenticated ? : null}
diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts
index a2c00df4f26f..883a899ef2c0 100644
--- a/apps/web/src/state/session.ts
+++ b/apps/web/src/state/session.ts
@@ -1,6 +1,11 @@
import { useAtomValue } from "@effect/atom-react";
import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";
-import type { AuthEnvironmentScope, AuthSessionState, EnvironmentId } from "@t3tools/contracts";
+import {
+ type AuthEnvironmentScope,
+ type AuthSessionState,
+ type EnvironmentId,
+ sessionGrantsScope,
+} from "@t3tools/contracts";
import { useMemo } from "react";
import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/reactivity";
@@ -22,12 +27,15 @@ export function useEnvironmentScope(
? EMPTY_SESSION_STATE_ATOM
: environmentSession.sessionStateAtom(environmentId),
);
+ return sessionHasScope(result, scope);
+}
+
+function sessionHasScope(
+ result: AsyncResult.AsyncResult,
+ scope: AuthEnvironmentScope,
+): boolean {
const session = Option.getOrNull(AsyncResult.value(result));
- return (
- result._tag !== "Failure" &&
- session?.authenticated === true &&
- session.scopes?.includes(scope) === true
- );
+ return result._tag !== "Failure" && session !== null && sessionGrantsScope(session, scope);
}
/** Subscribe to the grants of every selected environment. */
@@ -41,12 +49,7 @@ export function useEnvironmentsWithScope(
const ids = new Set();
for (const { environmentId } of environments) {
const result = get(environmentSession.sessionStateAtom(environmentId));
- const session = Option.getOrNull(AsyncResult.value(result));
- if (
- result._tag !== "Failure" &&
- session?.authenticated === true &&
- session.scopes?.includes(scope)
- ) {
+ if (sessionHasScope(result, scope)) {
ids.add(environmentId);
}
}
@@ -61,12 +64,9 @@ export function readEnvironmentScope(
environmentId: EnvironmentId,
scope: AuthEnvironmentScope,
): boolean {
- const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId));
- const session = Option.getOrNull(AsyncResult.value(result));
- return (
- result._tag !== "Failure" &&
- session?.authenticated === true &&
- session.scopes?.includes(scope) === true
+ return sessionHasScope(
+ appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)),
+ scope,
);
}
diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md
index d83ad25ad7a4..2526ee69caf9 100644
--- a/docs/internals/environment-auth.md
+++ b/docs/internals/environment-auth.md
@@ -58,10 +58,18 @@ extra authority: [every RPC declares a required
scope](../../apps/server/src/auth/RpcAuthorization.ts), and the WebSocket RPC
group's `RpcScopeAuthorization` middleware checks it before any handler runs.
-Self-update must work across authorization protocol changes. New servers advertise
-`auth.serverUpdateScope`; only an older server that omits it uses
-`orchestration:operate` for updates. An unchanged grant on an upgraded server must
-still include `environment:maintain`.
+Scope changes must not prevent older clients from connecting. Token exchange
+intersects recognized requests with the pairing grant; retired and unknown names
+are dropped. A request with no granted scopes fails before consuming the link.
+Stored credentials are never expanded when scopes split.
+
+Auth responses keep `scopes` within the original wire vocabulary and include
+`permissions` for the exact grant. New clients use `permissions` when present,
+even if empty. Older servers omit it, so clients use legacy parent checks for
+features those servers already support. These client checks never change server
+authorization. Permission errors likewise retain a legacy `requiredScope` and
+add the exact `requiredPermission`, so a denied RPC stays decodable by old clients.
+Unknown response permissions are ignored; grant inputs stay strict.
Desktop restarts forget the previous local bearer token, so its reusable
bootstrap grant replaces earlier sessions for the same subject and method.
diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md
index 8f9544a32418..f4f36d929d4f 100644
--- a/docs/user/remote-access.md
+++ b/docs/user/remote-access.md
@@ -289,9 +289,10 @@ and the agent it runs can use Git however the environment allows.
Settings changes, provider management, and environment maintenance can be granted
separately from access administration. New standard pairings include these
-permissions. Existing clients keep their original grants after an update; to
-receive newly separated permissions, pair the client again with the scopes it
-needs. Reconnecting or refreshing a session does not expand its grant.
+permissions. Existing clients can stay connected after an update, but newly separated
+features may require pairing again with the permissions they need. Older clients
+may show controls that the server denies. Create a fresh pairing link to change
+a client's permissions.
`filesystem:read` allows browsing host files, opening workspace files, and viewing
local changes. Add `filesystem:write` to allow editing files or saving plans to
diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts
index 1bbd507bb757..483dff47a4e5 100644
--- a/packages/client-runtime/src/rpc/client.ts
+++ b/packages/client-runtime/src/rpc/client.ts
@@ -1,6 +1,7 @@
import {
EnvironmentAuthorizationError,
clientRpcRequiredScopes,
+ authScopeRequiredResponse,
type EnvironmentId,
type ClientGuardedRpcTag,
ORCHESTRATION_V2_WS_METHODS,
@@ -165,7 +166,7 @@ export class RpcPermissionGuard extends Context.Reference<{
? Effect.void
: Effect.fail(
new EnvironmentAuthorizationError({
- requiredScope: scope,
+ ...authScopeRequiredResponse(scope),
message: `This connection requires ${scope}.`,
}),
);
diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts
index 3ae08c2e38c1..b9d1ebf6b738 100644
--- a/packages/client-runtime/src/state/commandPermissions.test.ts
+++ b/packages/client-runtime/src/state/commandPermissions.test.ts
@@ -1,3 +1,9 @@
+import { requestGuarded, runStreamGuarded } from "../rpc/client.ts";
+import { EnvironmentSupervisor } from "../connection/supervisor.ts";
+import * as SubscriptionRef from "effect/SubscriptionRef";
+import * as Option from "effect/Option";
+import * as Stream from "effect/Stream";
+import type { RpcSession } from "../rpc/session.ts";
import { describe, expect, it } from "@effect/vitest";
import { vi } from "vite-plus/test";
import {
@@ -36,6 +42,7 @@ const grant = (allowed: boolean): AuthSessionState => ({
sessionCookieName: "test",
},
scopes: allowed ? [AuthOrchestrationOperateScope] : [],
+ permissions: allowed ? [AuthOrchestrationOperateScope] : [],
});
const runtime = Atom.runtime(
Layer.succeed(EnvironmentRegistry, {
@@ -161,12 +168,16 @@ it.effect(
const registry = yield* setup;
registry.set(sessions(env), AsyncResult.success(grant(true)));
const git = createCommandPermissions(runtime, WS_METHODS.vcsInit);
- expect((yield* git.authorize(registry, env).pipe(Effect.flip)).requiredScope).toBe(
+ expect((yield* git.authorize(registry, env).pipe(Effect.flip)).requiredPermission).toBe(
AuthSourceControlWriteScope,
);
registry.set(
sessions(env),
- AsyncResult.success({ ...grant(false), scopes: [AuthSourceControlWriteScope] }),
+ AsyncResult.success({
+ ...grant(false),
+ scopes: [AuthSourceControlWriteScope],
+ permissions: [AuthSourceControlWriteScope],
+ }),
);
yield* git.authorize(registry, env);
const prepare = createCommandPermissions(runtime, WS_METHODS.gitPreparePullRequestThread);
@@ -186,6 +197,7 @@ it.effect(
AsyncResult.success({
...grant(true),
scopes: [AuthSourceControlWriteScope, AuthOrchestrationOperateScope],
+ permissions: [AuthSourceControlWriteScope, AuthOrchestrationOperateScope],
}),
);
expect(registry.get(prepare.permissionAtom(env, input))).toBe(true);
@@ -193,3 +205,59 @@ it.effect(
}),
),
);
+
+it.effect("honors exact empty permissions and preserves legacy parent grants", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const registry = yield* setup;
+ const git = createCommandPermissions(runtime, WS_METHODS.vcsInit);
+ registry.set(sessions(env), AsyncResult.success({ ...grant(true), permissions: [] }));
+ expect(registry.get(git.permissionAtom(env))).toBe(false);
+ const denied = yield* git.authorize(registry, env).pipe(Effect.flip);
+ expect(denied).toMatchObject({
+ requiredPermission: AuthSourceControlWriteScope,
+ requiredScope: AuthOrchestrationOperateScope,
+ });
+ const { permissions: _exact, ...legacy } = grant(true);
+ registry.set(sessions(env), AsyncResult.success(legacy));
+ expect(registry.get(git.permissionAtom(env))).toBe(true);
+ yield* git.authorize(registry, env);
+ }),
+ ),
+);
+
+it.effect("rejects protected unary and streamed RPCs outside a guarded command", () =>
+ Effect.gen(function* () {
+ let writes = 0;
+ const session = {
+ client: {
+ [WS_METHODS.scheduledTasksDelete]: () =>
+ Effect.sync(() => {
+ writes++;
+ return { id: ScheduledTaskId.make("task") };
+ }),
+ [WS_METHODS.gitRunStackedAction]: () =>
+ Stream.fromEffect(
+ Effect.sync(() => {
+ writes++;
+ }),
+ ),
+ },
+ } as unknown as RpcSession;
+ const supervisor = {
+ target: { environmentId: env, label: "target" },
+ session: yield* SubscriptionRef.make(Option.some(session)),
+ } as unknown as EnvironmentSupervisor["Service"];
+ const unary = yield* requestGuarded(WS_METHODS.scheduledTasksDelete, {
+ id: ScheduledTaskId.make("task"),
+ }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor), Effect.flip);
+ expect(unary._tag).toBe("EnvironmentAuthorizationError");
+ const streamed = yield* runStreamGuarded(WS_METHODS.gitRunStackedAction, {
+ actionId: "test-action",
+ cwd: "/repo",
+ action: "commit",
+ }).pipe(Stream.runDrain, Effect.provideService(EnvironmentSupervisor, supervisor), Effect.flip);
+ expect(streamed._tag).toBe("EnvironmentAuthorizationError");
+ expect(writes).toBe(0);
+ }),
+);
diff --git a/packages/client-runtime/src/state/commandPermissions.ts b/packages/client-runtime/src/state/commandPermissions.ts
index c6dccd290817..289757bc6ab5 100644
--- a/packages/client-runtime/src/state/commandPermissions.ts
+++ b/packages/client-runtime/src/state/commandPermissions.ts
@@ -1,5 +1,7 @@
import {
clientRpcRequiredScopes,
+ sessionGrantsScope,
+ authScopeRequiredResponse,
EnvironmentAuthorizationError,
type EnvironmentId,
type AuthSessionState,
@@ -11,10 +13,6 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
import type { EnvironmentRegistry } from "../connection/registry.ts";
import { createEnvironmentSessionAtoms } from "./session.ts";
-function grants(session: AuthSessionState, scope: AuthEnvironmentScope) {
- return session.authenticated && session.scopes?.includes(scope) === true;
-}
-
/** UI availability and dispatch use the same target session and method policy. */
function makeCommandPermissions(
runtime: Atom.AtomRuntime,
@@ -33,7 +31,7 @@ function makeCommandPermissions(
return (
result._tag !== "Failure" &&
session !== null &&
- scopes.every((scope) => grants(session, scope))
+ scopes.every((scope) => sessionGrantsScope(session, scope))
);
}),
),
@@ -59,12 +57,12 @@ function makeCommandPermissions(
Effect.catch(() => Effect.succeed(Option.none())),
);
const missing = scopes.find(
- (scope) => Option.isNone(session) || !grants(session.value, scope),
+ (scope) => Option.isNone(session) || !sessionGrantsScope(session.value, scope),
);
if (missing !== undefined)
return yield* Effect.fail(
new EnvironmentAuthorizationError({
- requiredScope: missing,
+ ...authScopeRequiredResponse(missing),
message: `This connection requires ${missing}.`,
}),
);
diff --git a/packages/client-runtime/src/state/filesystem.test.ts b/packages/client-runtime/src/state/filesystem.test.ts
index 2fc46a760e17..b674b8953249 100644
--- a/packages/client-runtime/src/state/filesystem.test.ts
+++ b/packages/client-runtime/src/state/filesystem.test.ts
@@ -9,6 +9,9 @@ import {
resolveFilesystemReadAccess,
} from "./filesystem.ts";
+/** A server that already splits scopes; it never falls back to a parent grant. */
+const SPLIT_SCOPES_SERVER = { serverUpdateScope: "environment:maintain" } as const;
+
describe("filesystem read access", () => {
it("waits for the initial catalog before declaring a missing environment disconnected", () => {
expect(
@@ -100,8 +103,8 @@ describe("filesystem read access", () => {
);
it.each([
- { authenticated: true, scopes: [AuthOrchestrationReadScope] },
- { authenticated: false, scopes: [AuthFilesystemReadScope] },
+ { authenticated: true, scopes: [AuthOrchestrationReadScope], auth: SPLIT_SCOPES_SERVER },
+ { authenticated: false, scopes: [AuthFilesystemReadScope], auth: SPLIT_SCOPES_SERVER },
] as const)("does not infer file access from an ungranted session", (session) => {
expect(
resolveFilesystemReadAccess({
@@ -112,6 +115,17 @@ describe("filesystem read access", () => {
}),
).toEqual({ canReadFiles: false, isPending: false, error: null });
});
+
+ it("falls back to the orchestration grant on a server that predates filesystem:read", () => {
+ expect(
+ resolveFilesystemReadAccess({
+ isCatalogReady: true,
+ connection: { phase: "connected", error: null },
+ session: { authenticated: true, scopes: [AuthOrchestrationReadScope], auth: {} },
+ sessionError: null,
+ }),
+ ).toEqual({ canReadFiles: true, isPending: false, error: null });
+ });
});
describe("filesystem browse model", () => {
diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts
index 293b66b3b3b0..b9ceb665d9a4 100644
--- a/packages/client-runtime/src/state/filesystem.ts
+++ b/packages/client-runtime/src/state/filesystem.ts
@@ -3,6 +3,8 @@ import {
type AuthSessionState,
type FilesystemBrowseEntry,
WS_METHODS,
+ sessionGrantsScope,
+ type SessionGrantInput,
} from "@t3tools/contracts";
import { Atom } from "effect/reactivity";
@@ -24,7 +26,7 @@ import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
export function resolveFilesystemReadAccess(input: {
readonly isCatalogReady: boolean;
readonly connection: Pick | null;
- readonly session: Pick | null;
+ readonly session: SessionGrantInput | null;
readonly sessionError: string | null;
}) {
if (input.sessionError !== null) {
@@ -45,9 +47,7 @@ export function resolveFilesystemReadAccess(input: {
};
}
return {
- canReadFiles:
- input.session.authenticated &&
- input.session.scopes?.includes(AuthFilesystemReadScope) === true,
+ canReadFiles: sessionGrantsScope(input.session, AuthFilesystemReadScope),
isPending: false,
error: null,
};
diff --git a/packages/client-runtime/src/state/usageAccess.test.ts b/packages/client-runtime/src/state/usageAccess.test.ts
index 1280a61fe943..82dee473360f 100644
--- a/packages/client-runtime/src/state/usageAccess.test.ts
+++ b/packages/client-runtime/src/state/usageAccess.test.ts
@@ -39,7 +39,11 @@ describe("resolveUsageAccess", () => {
it("distinguishes a failed check from a resolved grant without diagnostics access", () => {
const denied = resolveUsageAccess({
connectionPhase: "connected",
- session: { authenticated: true, scopes: [AuthOrchestrationReadScope] },
+ session: {
+ authenticated: true,
+ scopes: [AuthOrchestrationReadScope],
+ auth: { serverUpdateScope: "environment:maintain" },
+ },
hasSessionError: false,
});
const failed = resolveUsageAccess({
diff --git a/packages/client-runtime/src/state/usageAccess.ts b/packages/client-runtime/src/state/usageAccess.ts
index 66ddf4944143..19d497f3f191 100644
--- a/packages/client-runtime/src/state/usageAccess.ts
+++ b/packages/client-runtime/src/state/usageAccess.ts
@@ -1,10 +1,15 @@
-import { AuthDiagnosticsReadScope, type AuthSessionState } from "@t3tools/contracts";
+import {
+ AuthDiagnosticsReadScope,
+ type AuthSessionState,
+ sessionGrantsScope,
+ type SessionGrantInput,
+} from "@t3tools/contracts";
import type { EnvironmentConnectionPhase } from "../connection/presentation.ts";
export function resolveUsageAccess(input: {
readonly connectionPhase: EnvironmentConnectionPhase;
- readonly session: Pick | null;
+ readonly session: SessionGrantInput | null;
readonly hasSessionError: boolean;
}) {
if (input.hasSessionError) {
@@ -27,9 +32,7 @@ export function resolveUsageAccess(input: {
error: isPending ? null : "This environment is not connected.",
};
}
- const canReadDiagnostics =
- input.session.authenticated &&
- input.session.scopes?.includes(AuthDiagnosticsReadScope) === true;
+ const canReadDiagnostics = sessionGrantsScope(input.session, AuthDiagnosticsReadScope);
return {
canReadDiagnostics,
isPending: false,
diff --git a/packages/contracts/src/auth.test.ts b/packages/contracts/src/auth.test.ts
index 12cf77411c73..5d8fb43afe61 100644
--- a/packages/contracts/src/auth.test.ts
+++ b/packages/contracts/src/auth.test.ts
@@ -1,7 +1,17 @@
import { describe, expect, it } from "vite-plus/test";
import * as Schema from "effect/Schema";
-import { AuthEnvironmentScopes, AuthGrantScopes, AuthStandardClientScopes } from "./auth.ts";
+import {
+ AuthEnvironmentScopes,
+ AuthEnvironmentScope,
+ authScopeRequiredResponse,
+ AuthGrantScopes,
+ AuthStandardClientScopes,
+ authScopeResponse,
+ AuthSessionState,
+ sessionGrantsScope,
+ sessionHasLegacyPermissions,
+} from "./auth.ts";
describe("authorization grants", () => {
it("decodes legacy review credentials without offering them in new grants", () => {
@@ -11,4 +21,136 @@ describe("authorization grants", () => {
expect(() => Schema.decodeUnknownSync(AuthGrantScopes)(["review:write"])).toThrow();
expect(AuthStandardClientScopes).not.toContain("review:write");
});
+
+ // Frozen vocabulary from the client before granular scopes shipped. Do not
+ // derive it from the current enum: that would hide compatibility regressions.
+ const oldScopes = Schema.Array(
+ Schema.Literals([
+ "orchestration:read",
+ "orchestration:operate",
+ "terminal:operate",
+ "review:write",
+ "access:read",
+ "access:write",
+ "relay:read",
+ "relay:write",
+ ]),
+ );
+
+ const decodeOldScopes = Schema.decodeUnknownSync(oldScopes);
+
+ it.each(AuthEnvironmentScope.literals)(
+ "keeps %s permission errors decodable by old clients",
+ (scope) => {
+ const response = authScopeRequiredResponse(scope);
+ expect(decodeOldScopes([response.requiredScope])).toEqual([response.requiredScope]);
+ expect(response.requiredPermission).toBe(scope);
+ },
+ );
+
+ it("keeps old clients able to decode grants with new permissions", () => {
+ const response = authScopeResponse(AuthStandardClientScopes);
+ expect(decodeOldScopes(response.scopes)).toEqual(response.scopes);
+ expect(response.permissions).toEqual(AuthStandardClientScopes);
+ expect(response.scopes).not.toContain("filesystem:read");
+ });
+
+ it("ignores unknown response permissions without falling back to broader scopes", () => {
+ const session = Schema.decodeUnknownSync(AuthSessionState)({
+ authenticated: true,
+ auth: {
+ policy: "loopback-browser",
+ bootstrapMethods: [],
+ sessionMethods: [],
+ sessionCookieName: "session",
+ },
+ scopes: ["orchestration:operate"],
+ permissions: ["future:permission"],
+ });
+ expect(session.permissions).toEqual([]);
+ expect(sessionGrantsScope(session, "orchestration:operate")).toBe(false);
+ expect(sessionGrantsScope(session, "settings:write")).toBe(false);
+ });
+
+ it.each([
+ {
+ label: "exact permissions over the legacy presentation",
+ session: {
+ authenticated: true,
+ scopes: ["orchestration:operate"],
+ permissions: ["filesystem:read"],
+ },
+ scope: "settings:write",
+ expected: false,
+ },
+ {
+ label: "a permission absent from the legacy presentation",
+ session: { authenticated: true, scopes: [], permissions: ["filesystem:read"] },
+ scope: "filesystem:read",
+ expected: true,
+ },
+
+ {
+ label: "the parent on a server that predates the split",
+ session: { authenticated: true, scopes: ["orchestration:operate"], auth: {} },
+ scope: "settings:write",
+ expected: true,
+ },
+ {
+ label: "only the exact scope on a server that knows the split",
+ session: {
+ authenticated: true,
+ scopes: ["orchestration:operate"],
+ auth: { serverUpdateScope: "environment:maintain" },
+ },
+ scope: "settings:write",
+ expected: false,
+ },
+ {
+ label: "the exact scope regardless of server version",
+ session: { authenticated: true, scopes: ["settings:write"], auth: {} },
+ scope: "settings:write",
+ expected: true,
+ },
+ {
+ label: "nothing for an unauthenticated session",
+ session: { authenticated: false, scopes: ["orchestration:operate"], auth: {} },
+ scope: "settings:write",
+ expected: false,
+ },
+ {
+ label: "no parent for scopes that were never split",
+ session: { authenticated: true, scopes: ["orchestration:operate"], auth: {} },
+ scope: "access:write",
+ expected: false,
+ },
+ ] as const)("sessionGrantsScope accepts $label", ({ session, scope, expected }) => {
+ expect(sessionGrantsScope(session, scope)).toBe(expected);
+ });
+});
+
+describe("legacy permission notice", () => {
+ it.each(["orchestration:read", "orchestration:operate", "terminal:operate"] as const)(
+ "recognizes an old %s grant on an upgraded server",
+ (scope) =>
+ expect(sessionHasLegacyPermissions({ authenticated: true, permissions: [scope] })).toBe(true),
+ );
+ it("waits for a new server and an authenticated session", () => {
+ expect(
+ sessionHasLegacyPermissions({ authenticated: true, scopes: ["orchestration:operate"] }),
+ ).toBe(false);
+ expect(
+ sessionHasLegacyPermissions({ authenticated: false, permissions: ["orchestration:operate"] }),
+ ).toBe(false);
+ });
+ it("skips new grants and old grants that lost no implied permissions", () => {
+ for (const permissions of [
+ AuthStandardClientScopes,
+ ["orchestration:read", "filesystem:read"] as const,
+ ["access:read"] as const,
+ [],
+ ]) {
+ expect(sessionHasLegacyPermissions({ authenticated: true, permissions })).toBe(false);
+ }
+ });
});
diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts
index 5df81853adbf..f0478da0c8aa 100644
--- a/packages/contracts/src/auth.ts
+++ b/packages/contracts/src/auth.ts
@@ -3,6 +3,7 @@ import * as HttpApiSchema from "effect/http-api/HttpApiSchema";
import {
AuthSessionId,
+ ForwardCompatibleArray,
ClientSurface,
ClientWebDeployment,
TrimmedNonEmptyString,
@@ -126,6 +127,81 @@ export type AuthGrantScope = typeof AuthGrantScope.Type;
export const AuthGrantScopes = Schema.Array(AuthGrantScope);
export type AuthGrantScopes = typeof AuthGrantScopes.Type;
+// Frozen wire vocabulary for clients released before granular permissions.
+const legacyScopes = new Set([
+ AuthOrchestrationReadScope,
+ AuthOrchestrationOperateScope,
+ AuthTerminalOperateScope,
+ AuthReviewWriteScope,
+ AuthAccessReadScope,
+ AuthAccessWriteScope,
+ AuthRelayReadScope,
+ AuthRelayWriteScope,
+]);
+
+/** Format public auth metadata without changing the server's authorization grant. */
+export function authScopeResponse(scopes: ReadonlyArray) {
+ return { scopes: scopes.filter((scope) => legacyScopes.has(scope)), permissions: scopes };
+}
+
+const authScopeResponseFields = {
+ scopes: AuthEnvironmentScopes,
+ permissions: Schema.optionalKey(ForwardCompatibleArray(AuthEnvironmentScope)),
+};
+
+// Only clients talking to an old server use these parent checks. Servers never
+// expand stored grants, and an explicitly empty permissions array grants nothing.
+const legacyParents: Partial> = {
+ [AuthFilesystemReadScope]: AuthOrchestrationReadScope,
+ [AuthDiagnosticsReadScope]: AuthOrchestrationReadScope,
+ [AuthSettingsWriteScope]: AuthOrchestrationOperateScope,
+ [AuthProvidersManageScope]: AuthOrchestrationOperateScope,
+ [AuthEnvironmentMaintainScope]: AuthOrchestrationOperateScope,
+ [AuthPreviewOperateScope]: AuthOrchestrationOperateScope,
+ [AuthSourceControlWriteScope]: AuthOrchestrationOperateScope,
+ [AuthFilesystemWriteScope]: AuthOrchestrationOperateScope,
+ [AuthTerminalReadScope]: AuthTerminalOperateScope,
+};
+
+/** Keep permission denials decodable by clients with the original scope enum. */
+export function authScopeRequiredResponse(requiredPermission: AuthEnvironmentScope) {
+ return {
+ requiredScope: legacyParents[requiredPermission] ?? requiredPermission,
+ requiredPermission,
+ };
+}
+
+export interface SessionGrantInput {
+ readonly authenticated: boolean;
+ readonly scopes?: ReadonlyArray | undefined;
+ readonly permissions?: ReadonlyArray | undefined;
+ readonly auth?: { readonly serverUpdateScope?: string | undefined } | undefined;
+}
+
+export function sessionGrantsScope(
+ session: SessionGrantInput,
+ scope: AuthEnvironmentScope,
+): boolean {
+ if (!session.authenticated) return false;
+ if (session.permissions !== undefined) return session.permissions.includes(scope);
+ if (session.scopes?.includes(scope)) return true;
+ // Also recognize servers from the first granular-scope release.
+ if (session.auth?.serverUpdateScope !== undefined) return false;
+ const parent = legacyParents[scope];
+ return parent !== undefined && session.scopes?.includes(parent) === true;
+}
+
+/** Old-only grants lost the child permissions formerly implied by their broad scopes. */
+export function sessionHasLegacyPermissions(session: SessionGrantInput): boolean {
+ const permissions = session.permissions;
+ return (
+ session.authenticated &&
+ permissions !== undefined &&
+ permissions.every((scope) => legacyScopes.has(scope)) &&
+ Object.values(legacyParents).some((parent) => permissions.includes(parent))
+ );
+}
+
export const AuthStandardClientScopes = [
AuthOrchestrationReadScope,
AuthOrchestrationOperateScope,
@@ -191,7 +267,7 @@ export type AuthBrowserSessionRequest = typeof AuthBrowserSessionRequest.Type;
export const AuthBrowserSessionResult = Schema.Struct({
authenticated: Schema.Literal(true),
- scopes: AuthEnvironmentScopes,
+ ...authScopeResponseFields,
sessionMethod: ServerAuthSessionMethod,
expiresAt: Schema.DateTimeUtc,
});
@@ -257,7 +333,7 @@ export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Typ
// Read models contain metadata only. Credentials are returned by creation alone.
export const AuthPairingLink = Schema.Struct({
id: TrimmedNonEmptyString,
- scopes: AuthEnvironmentScopes,
+ ...authScopeResponseFields,
subject: TrimmedNonEmptyString,
label: Schema.optionalKey(TrimmedNonEmptyString),
createdAt: Schema.DateTimeUtc,
@@ -278,7 +354,7 @@ export type AuthClientMetadata = typeof AuthClientMetadata.Type;
export const AuthClientSession = Schema.Struct({
sessionId: AuthSessionId,
subject: TrimmedNonEmptyString,
- scopes: AuthEnvironmentScopes,
+ ...authScopeResponseFields,
method: ServerAuthSessionMethod,
client: AuthClientMetadata,
issuedAt: Schema.DateTimeUtc,
@@ -335,6 +411,7 @@ export class EnvironmentAuthorizationError extends Schema.TaggedError