From a3d87532b1ead377f1479b4626527b03a1fcf55d Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:59:56 -0700 Subject: [PATCH 01/13] feat(auth): keep existing credentials working across the scope split A credential recorded before a scope was split kept only the broad scope and silently lost every split-out capability after the server upgraded; a newer client treated a scope an older server had never issued as a denial. Both directions now consult one table of legacy expansions: - A migration rewrites live pairing links and session rows. - Session tokens carry a claims version; v1 tokens are expanded when verified, so bearer and cookie credentials keep their access. - Clients check the parent scope when the server does not advertise the split, so app.t3.codes and the store apps keep working against servers that have not upgraded. Co-Authored-By: Claude Fable 5 --- .../terminal/ThreadTerminalRouteScreen.tsx | 9 +- apps/mobile/src/state/mediaActions.ts | 10 +- apps/mobile/src/state/session.ts | 13 +-- apps/server/src/auth/SessionStore.test.ts | 35 +++++++ apps/server/src/auth/SessionStore.ts | 13 ++- .../050_ExpandLegacyAuthScopes.test.ts | 95 +++++++++++++++++++ .../Migrations/050_ExpandLegacyAuthScopes.ts | 48 ++++++++++ .../web/src/components/ServerUpdateAction.tsx | 14 ++- .../web/src/components/media/MediaActions.tsx | 9 +- .../ProviderSettingsPanel.logic.test.ts | 16 +++- .../settings/ProviderSettingsPanel.logic.ts | 12 +-- .../components/usage/UsagePriceOverrides.tsx | 4 +- apps/web/src/hooks/useSettings.ts | 3 +- .../useThreadActions.permissions.test.ts | 1 + apps/web/src/hooks/useThreadActions.ts | 4 +- apps/web/src/state/session.ts | 36 +++---- docs/internals/environment-auth.md | 13 ++- docs/user/remote-access.md | 6 +- .../src/state/filesystem.test.ts | 18 +++- .../client-runtime/src/state/filesystem.ts | 8 +- .../src/state/usageAccess.test.ts | 6 +- .../client-runtime/src/state/usageAccess.ts | 13 ++- packages/contracts/src/auth.test.ts | 65 ++++++++++++- packages/contracts/src/auth.ts | 64 +++++++++++++ 24 files changed, 435 insertions(+), 80 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/050_ExpandLegacyAuthScopes.test.ts create mode 100644 apps/server/src/persistence/Migrations/050_ExpandLegacyAuthScopes.ts 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.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/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index f7d3e7e1f5ac..97e1d24d6169 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,40 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect((yield* sessions.verify(uncapped.token)).runtimeModeCeiling).toBeUndefined(); }).pipe(Effect.provide(layerSessionStore())), ); + it.effect("expands scopes in tokens issued before they were split", () => + 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 }); + // Re-sign the same claims as a v1 token, which is what an existing + // credential looks like after the server upgrades. + const [encodedPayload] = issued.token.split("."); + const currentClaims = base64UrlDecodeUtf8(encodedPayload!); + expect(currentClaims).toContain('"v":2'); + const legacyPayload = base64UrlEncode(currentClaims.replace('"v":2', '"v":1')); + 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, + "filesystem:read", + "diagnostics:read", + "terminal:read", + ]); + }).pipe( + Effect.provide( + SessionStore.layer.pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provide(SqlitePersistenceMemory), + Layer.provide(makeServerEnvironmentLayer(EnvironmentId.make("test-environment"))), + Layer.provide(makeServerConfigLayer()), + ), + ), + ), + ); + 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..61a4bbd50ff0 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -8,6 +8,7 @@ import { type AuthEnvironmentScope, type ClientSurface, RuntimeMode, + expandLegacyScopes, type ServerAuthSessionMethod, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -428,8 +429,12 @@ 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); + +// v1 tokens predate the split of the broad scopes. Their recorded scopes are +// expanded on verification so the credential keeps the access it was granted; +// everything issued since carries the split scopes and is used as-is. const SessionClaims = Schema.Struct({ - v: Schema.Literal(1), + v: Schema.Literals([1, 2]), kind: Schema.Literal("session"), sid: AuthSessionId, sub: Schema.String, @@ -665,7 +670,7 @@ export const make = Effect.gen(function* () { milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), }); const claims: SessionClaims = { - v: 1, + v: 2, kind: "session", sid: sessionId, sub: input?.subject ?? "browser", @@ -851,7 +856,7 @@ export const make = Effect.gen(function* () { client: toClientMetadata(row.value.client), expiresAt: expiresAt.value, subject: claims.sub, - scopes: claims.scopes, + scopes: claims.v === 1 ? expandLegacyScopes(claims.scopes) : claims.scopes, ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), ...(claims.rtc ? { runtimeModeCeiling: claims.rtc } : {}), } satisfies VerifiedSession; @@ -961,6 +966,8 @@ export const make = Effect.gen(function* () { client: toClientMetadata(row.value.client), expiresAt: row.value.expiresAt, subject: row.value.subject, + // Rows recorded before the split were rewritten by migration 048, so + // the stored scopes are authoritative here. scopes: row.value.scopes, } satisfies VerifiedSession; }); diff --git a/apps/server/src/persistence/Migrations/050_ExpandLegacyAuthScopes.test.ts b/apps/server/src/persistence/Migrations/050_ExpandLegacyAuthScopes.test.ts new file mode 100644 index 000000000000..ad6fff50c932 --- /dev/null +++ b/apps/server/src/persistence/Migrations/050_ExpandLegacyAuthScopes.test.ts @@ -0,0 +1,95 @@ +import { AuthEnvironmentScopes } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +const ScopesJson = Schema.fromJsonString(AuthEnvironmentScopes); +const encodeScopes = Schema.encodeSync(ScopesJson); +const decodeScopes = Schema.decodeSync(ScopesJson); + +const LEGACY_STANDARD_SCOPES = [ + "orchestration:read", + "orchestration:operate", + "terminal:operate", + "review:write", + "relay:read", +] as const; +const LEGACY_STANDARD = encodeScopes(LEGACY_STANDARD_SCOPES); + +layer("050_ExpandLegacyAuthScopes", (it) => { + it.effect("expands live legacy credentials and leaves the rest alone", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 49 }); + + yield* sql` + INSERT INTO auth_pairing_links ( + id, credential, method, scopes, subject, label, created_at, expires_at, consumed_at, revoked_at + ) + VALUES + ('open', 'cred-open', 'one-time-token', ${LEGACY_STANDARD}, 'one-time-token', NULL, + '2026-09-01T00:00:00.000Z', '2099-01-01T00:00:00.000Z', NULL, NULL), + ('narrow', 'cred-narrow', 'one-time-token', ${encodeScopes(["orchestration:read"])}, 'one-time-token', NULL, + '2026-09-01T00:00:00.000Z', '2099-01-01T00:00:00.000Z', NULL, NULL), + ('consumed', 'cred-consumed', 'one-time-token', ${LEGACY_STANDARD}, 'one-time-token', NULL, + '2026-09-01T00:00:00.000Z', '2099-01-01T00:00:00.000Z', '2026-09-02T00:00:00.000Z', NULL), + ('revoked', 'cred-revoked', 'one-time-token', ${LEGACY_STANDARD}, 'one-time-token', NULL, + '2026-09-01T00:00:00.000Z', '2099-01-01T00:00:00.000Z', NULL, '2026-09-02T00:00:00.000Z') + `; + + yield* sql` + INSERT INTO auth_sessions (session_id, subject, scopes, method, issued_at, expires_at, revoked_at) + VALUES + ('live', 'cloud-connect', ${LEGACY_STANDARD}, 'dpop-access-token', + '2026-09-01T00:00:00.000Z', '2099-01-01T00:00:00.000Z', NULL), + ('gone', 'cloud-connect', ${LEGACY_STANDARD}, 'dpop-access-token', + '2026-09-01T00:00:00.000Z', '2099-01-01T00:00:00.000Z', '2026-09-02T00:00:00.000Z') + `; + + yield* runMigrations({ toMigrationInclusive: 50 }); + + const rows = yield* sql<{ readonly id: string; readonly scopes: string }>` + SELECT id, scopes FROM auth_pairing_links ORDER BY id + `; + const byId = new Map(rows.map((row) => [row.id, decodeScopes(row.scopes)])); + + assert.deepStrictEqual(byId.get("open"), [ + "orchestration:read", + "orchestration:operate", + "terminal:operate", + "review:write", + "relay:read", + "filesystem:read", + "diagnostics:read", + "settings:write", + "providers:manage", + "environment:maintain", + "preview:operate", + "source-control:write", + "filesystem:write", + "terminal:read", + ]); + assert.deepStrictEqual(byId.get("narrow"), [ + "orchestration:read", + "filesystem:read", + "diagnostics:read", + ]); + assert.deepStrictEqual(byId.get("consumed"), LEGACY_STANDARD_SCOPES); + assert.deepStrictEqual(byId.get("revoked"), LEGACY_STANDARD_SCOPES); + + const sessions = yield* sql<{ readonly id: string; readonly scopes: string }>` + SELECT session_id AS id, scopes FROM auth_sessions ORDER BY session_id + `; + const sessionScopes = new Map(sessions.map((row) => [row.id, decodeScopes(row.scopes)])); + assert.deepStrictEqual(sessionScopes.get("live"), byId.get("open")); + assert.deepStrictEqual(sessionScopes.get("gone"), LEGACY_STANDARD_SCOPES); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/050_ExpandLegacyAuthScopes.ts b/apps/server/src/persistence/Migrations/050_ExpandLegacyAuthScopes.ts new file mode 100644 index 000000000000..63255f39a2f6 --- /dev/null +++ b/apps/server/src/persistence/Migrations/050_ExpandLegacyAuthScopes.ts @@ -0,0 +1,48 @@ +import { AuthEnvironmentScopes, expandLegacyScopes } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +const ScopesJson = Schema.fromJsonString(AuthEnvironmentScopes); +const decodeScopes = Schema.decodeUnknownEffect(ScopesJson); +const encodeScopes = Schema.encodeSync(ScopesJson); + +/** + * Credentials recorded before scopes were split still carry the broad ones. + * Rewrite live pairing links and sessions so they grant what the same + * credential meant when it was created. Session rows back websocket tickets; + * the signed session token carries its own copy of the scopes, and v1 tokens + * are expanded when verified. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const expandTable = (table: "auth_pairing_links" | "auth_sessions") => + Effect.gen(function* () { + const rows = + table === "auth_pairing_links" + ? yield* sql<{ readonly id: string; readonly scopes: string }>` + SELECT id, scopes + FROM auth_pairing_links + WHERE revoked_at IS NULL AND consumed_at IS NULL + ` + : yield* sql<{ readonly id: string; readonly scopes: string }>` + SELECT session_id AS id, scopes + FROM auth_sessions + WHERE revoked_at IS NULL + `; + for (const row of rows) { + const scopes = yield* decodeScopes(row.scopes).pipe(Effect.option); + if (scopes._tag === "None") continue; + const expanded = expandLegacyScopes(scopes.value); + if (expanded === scopes.value) continue; + const encoded = encodeScopes(expanded); + if (table === "auth_pairing_links") { + yield* sql`UPDATE auth_pairing_links SET scopes = ${encoded} WHERE id = ${row.id}`; + } else { + yield* sql`UPDATE auth_sessions SET scopes = ${encoded} WHERE session_id = ${row.id}`; + } + } + }); + yield* expandTable("auth_pairing_links"); + yield* expandTable("auth_sessions"); +}); 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/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts index d32634004ed9..5af0c709c269 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -231,12 +231,26 @@ describe("remote operate access", () => { 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/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..344c8ddadce1 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -58,10 +58,15 @@ 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`. +Splitting a scope must not change what an existing credential can do. +[`LEGACY_SCOPE_EXPANSIONS`](../../packages/contracts/src/auth.ts) records which +scopes were carved out of which parent. The server applies it in two places: a +migration rewrites stored pairing links and session rows, and session tokens +carry a claims version so a pre-split token is expanded when verified. Clients +apply it in reverse through `sessionGrantsScope`: a server that omits +`auth.serverUpdateScope` predates the split and still authorizes the split-out +RPCs with the parent scope, so the client checks the parent instead. Add to the +table whenever a scope is split; never remove from it. 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..c0256e7b74d2 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -289,9 +289,9 @@ 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. A client paired before a permission was separated keeps what it +could already do: its grant is read as the permissions it covered when it was +issued. To narrow it, create a fresh pairing link with only the scopes it needs. `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/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..9a60dc93c410 100644 --- a/packages/contracts/src/auth.test.ts +++ b/packages/contracts/src/auth.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; -import { AuthEnvironmentScopes, AuthGrantScopes, AuthStandardClientScopes } from "./auth.ts"; +import { + AuthEnvironmentScopes, + AuthGrantScopes, + AuthStandardClientScopes, + expandLegacyScopes, + sessionGrantsScope, +} from "./auth.ts"; describe("authorization grants", () => { it("decodes legacy review credentials without offering them in new grants", () => { @@ -11,4 +17,61 @@ describe("authorization grants", () => { expect(() => Schema.decodeUnknownSync(AuthGrantScopes)(["review:write"])).toThrow(); expect(AuthStandardClientScopes).not.toContain("review:write"); }); + + it("expands a pre-split standard grant to the current standard set", () => { + const expanded = expandLegacyScopes([ + "orchestration:read", + "orchestration:operate", + "terminal:operate", + "review:write", + "relay:read", + ]); + for (const scope of AuthStandardClientScopes) expect(expanded).toContain(scope); + expect(expanded).not.toContain("access:write"); + expect(expanded).not.toContain("relay:write"); + }); + + it("returns the same array when nothing needs expanding", () => { + const scopes = ["filesystem:read", "relay:read"] as const; + expect(expandLegacyScopes(scopes)).toBe(scopes); + }); + + it.each([ + { + 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); + }); }); diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 5df81853adbf..1a6d2a64c9d0 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -126,6 +126,70 @@ export type AuthGrantScope = typeof AuthGrantScope.Type; export const AuthGrantScopes = Schema.Array(AuthGrantScope); export type AuthGrantScopes = typeof AuthGrantScopes.Type; +/** + * Scopes that were split out of a broader one. A grant recorded before the + * split carries the parent; expanding it yields what the same grant means now. + * Servers apply this to stored credentials; clients use it in reverse to ask an + * older server for the parent when it does not know the split-out scope. + */ +export const LEGACY_SCOPE_EXPANSIONS: Readonly< + Partial>> +> = { + [AuthOrchestrationReadScope]: [AuthFilesystemReadScope, AuthDiagnosticsReadScope], + [AuthOrchestrationOperateScope]: [ + AuthSettingsWriteScope, + AuthProvidersManageScope, + AuthEnvironmentMaintainScope, + AuthPreviewOperateScope, + AuthSourceControlWriteScope, + AuthFilesystemWriteScope, + ], + [AuthTerminalOperateScope]: [AuthTerminalReadScope], + [AuthReviewWriteScope]: [AuthFilesystemReadScope], +}; + +export function expandLegacyScopes( + scopes: ReadonlyArray, +): ReadonlyArray { + const expanded = new Set(scopes); + for (const scope of scopes) { + for (const implied of LEGACY_SCOPE_EXPANSIONS[scope] ?? []) expanded.add(implied); + } + return expanded.size === scopes.length ? scopes : [...expanded]; +} + +/** The scope an older server checked before `scope` was split out, if any. */ +export function legacyParentScope(scope: AuthEnvironmentScope): AuthEnvironmentScope | null { + for (const [parent, children] of Object.entries(LEGACY_SCOPE_EXPANSIONS)) { + if (parent !== AuthReviewWriteScope && children?.includes(scope)) { + return parent as AuthEnvironmentScope; + } + } + return null; +} + +/** + * Whether a session grants `scope`. An older server does not know the + * split-out scopes and still authorizes those RPCs with the parent, so a + * client checks the parent when the server does not advertise the split. + */ +export interface SessionGrantInput { + readonly authenticated: boolean; + readonly scopes?: ReadonlyArray | undefined; + readonly auth?: { readonly serverUpdateScope?: string | undefined } | undefined; +} + +export function sessionGrantsScope( + session: SessionGrantInput, + scope: AuthEnvironmentScope, +): boolean { + if (!session.authenticated || session.scopes === undefined) return false; + if (session.scopes.includes(scope)) return true; + if (session.auth?.serverUpdateScope !== undefined) return false; + const parent = legacyParentScope(scope); + return parent !== null && session.scopes.includes(parent); +} + export const AuthStandardClientScopes = [ AuthOrchestrationReadScope, AuthOrchestrationOperateScope, From d8ba325a6a5c7c4a0b6e0d7a2322935d65bfeb7b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 19:13:05 -0700 Subject: [PATCH 02/13] fix(auth): tolerate legacy clients without expanding grants --- apps/mobile/src/state/session.test.ts | 15 +++ apps/server/src/auth/EnvironmentAuth.test.ts | 7 +- apps/server/src/auth/EnvironmentAuth.ts | 12 ++- apps/server/src/auth/PairingGrantStore.ts | 10 +- apps/server/src/auth/SessionStore.test.ts | 16 +--- apps/server/src/auth/SessionStore.ts | 11 +-- apps/server/src/auth/http.ts | 9 +- apps/server/src/cliAuthFormat.ts | 4 +- .../src/persistence/AuthPairingLinks.ts | 6 +- .../050_ExpandLegacyAuthScopes.test.ts | 95 ------------------- .../Migrations/050_ExpandLegacyAuthScopes.ts | 48 ---------- .../settings/ConnectionsSettings.tsx | 4 +- docs/internals/environment-auth.md | 19 ++-- docs/user/remote-access.md | 7 +- packages/contracts/src/auth.test.ts | 61 ++++++++++-- packages/contracts/src/auth.ts | 94 +++++++++--------- 16 files changed, 164 insertions(+), 254 deletions(-) delete mode 100644 apps/server/src/persistence/Migrations/050_ExpandLegacyAuthScopes.test.ts delete mode 100644 apps/server/src/persistence/Migrations/050_ExpandLegacyAuthScopes.ts 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/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 71ee138ddc3a..dc1437edb4e8 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -355,7 +355,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 +393,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..a51276b26077 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, @@ -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,14 @@ 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)); return yield* sessions .issue({ method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", @@ -1005,6 +1005,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 +1111,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/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 97e1d24d6169..b7265604ad77 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -291,28 +291,22 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect((yield* sessions.verify(uncapped.token)).runtimeModeCeiling).toBeUndefined(); }).pipe(Effect.provide(layerSessionStore())), ); - it.effect("expands scopes in tokens issued before they were split", () => + 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 }); - // Re-sign the same claims as a v1 token, which is what an existing - // credential looks like after the server upgrades. + // Accept prerelease v2 credentials without widening their recorded grant. const [encodedPayload] = issued.token.split("."); const currentClaims = base64UrlDecodeUtf8(encodedPayload!); - expect(currentClaims).toContain('"v":2'); - const legacyPayload = base64UrlEncode(currentClaims.replace('"v":2', '"v":1')); + 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, - "filesystem:read", - "diagnostics:read", - "terminal:read", - ]); + expect((yield* sessions.verify(legacyToken)).scopes).toEqual(legacyScopes); }).pipe( Effect.provide( SessionStore.layer.pipe( diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 61a4bbd50ff0..afdb61ba1af7 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -8,7 +8,7 @@ import { type AuthEnvironmentScope, type ClientSurface, RuntimeMode, - expandLegacyScopes, + type ServerAuthSessionMethod, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -430,9 +430,6 @@ const SIGNING_SECRET_NAME = "server-signing-key"; const DEFAULT_SESSION_TTL = Duration.days(30); const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5); -// v1 tokens predate the split of the broad scopes. Their recorded scopes are -// expanded on verification so the credential keeps the access it was granted; -// everything issued since carries the split scopes and is used as-is. const SessionClaims = Schema.Struct({ v: Schema.Literals([1, 2]), kind: Schema.Literal("session"), @@ -670,7 +667,7 @@ export const make = Effect.gen(function* () { milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), }); const claims: SessionClaims = { - v: 2, + v: 1, kind: "session", sid: sessionId, sub: input?.subject ?? "browser", @@ -856,7 +853,7 @@ export const make = Effect.gen(function* () { client: toClientMetadata(row.value.client), expiresAt: expiresAt.value, subject: claims.sub, - scopes: claims.v === 1 ? expandLegacyScopes(claims.scopes) : claims.scopes, + scopes: claims.scopes, ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), ...(claims.rtc ? { runtimeModeCeiling: claims.rtc } : {}), } satisfies VerifiedSession; @@ -966,8 +963,6 @@ export const make = Effect.gen(function* () { client: toClientMetadata(row.value.client), expiresAt: row.value.expiresAt, subject: row.value.subject, - // Rows recorded before the split were rewritten by migration 048, so - // the stored scopes are authoritative here. scopes: row.value.scopes, } satisfies VerifiedSession; }); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index eba89f0a66fa..ba3c2e7e4304 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -18,11 +18,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"; @@ -356,10 +358,7 @@ export const layer = HttpApiBuilder.group( const requestedScopes = args.payload.scope === undefined ? undefined - : parseAllowedOAuthScope({ - value: args.payload.scope, - allowedScopes: new Set(AuthGrantScope.literals), - }); + : (parseOAuthScope(args.payload.scope)?.filter(Schema.is(AuthGrantScope)) ?? null); if (requestedScopes === null) { return yield* failEnvironmentInvalidRequest("invalid_scope"); } 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/persistence/Migrations/050_ExpandLegacyAuthScopes.test.ts b/apps/server/src/persistence/Migrations/050_ExpandLegacyAuthScopes.test.ts deleted file mode 100644 index ad6fff50c932..000000000000 --- a/apps/server/src/persistence/Migrations/050_ExpandLegacyAuthScopes.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { AuthEnvironmentScopes } from "@t3tools/contracts"; -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; - -const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); - -const ScopesJson = Schema.fromJsonString(AuthEnvironmentScopes); -const encodeScopes = Schema.encodeSync(ScopesJson); -const decodeScopes = Schema.decodeSync(ScopesJson); - -const LEGACY_STANDARD_SCOPES = [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", -] as const; -const LEGACY_STANDARD = encodeScopes(LEGACY_STANDARD_SCOPES); - -layer("050_ExpandLegacyAuthScopes", (it) => { - it.effect("expands live legacy credentials and leaves the rest alone", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* runMigrations({ toMigrationInclusive: 49 }); - - yield* sql` - INSERT INTO auth_pairing_links ( - id, credential, method, scopes, subject, label, created_at, expires_at, consumed_at, revoked_at - ) - VALUES - ('open', 'cred-open', 'one-time-token', ${LEGACY_STANDARD}, 'one-time-token', NULL, - '2026-09-01T00:00:00.000Z', '2099-01-01T00:00:00.000Z', NULL, NULL), - ('narrow', 'cred-narrow', 'one-time-token', ${encodeScopes(["orchestration:read"])}, 'one-time-token', NULL, - '2026-09-01T00:00:00.000Z', '2099-01-01T00:00:00.000Z', NULL, NULL), - ('consumed', 'cred-consumed', 'one-time-token', ${LEGACY_STANDARD}, 'one-time-token', NULL, - '2026-09-01T00:00:00.000Z', '2099-01-01T00:00:00.000Z', '2026-09-02T00:00:00.000Z', NULL), - ('revoked', 'cred-revoked', 'one-time-token', ${LEGACY_STANDARD}, 'one-time-token', NULL, - '2026-09-01T00:00:00.000Z', '2099-01-01T00:00:00.000Z', NULL, '2026-09-02T00:00:00.000Z') - `; - - yield* sql` - INSERT INTO auth_sessions (session_id, subject, scopes, method, issued_at, expires_at, revoked_at) - VALUES - ('live', 'cloud-connect', ${LEGACY_STANDARD}, 'dpop-access-token', - '2026-09-01T00:00:00.000Z', '2099-01-01T00:00:00.000Z', NULL), - ('gone', 'cloud-connect', ${LEGACY_STANDARD}, 'dpop-access-token', - '2026-09-01T00:00:00.000Z', '2099-01-01T00:00:00.000Z', '2026-09-02T00:00:00.000Z') - `; - - yield* runMigrations({ toMigrationInclusive: 50 }); - - const rows = yield* sql<{ readonly id: string; readonly scopes: string }>` - SELECT id, scopes FROM auth_pairing_links ORDER BY id - `; - const byId = new Map(rows.map((row) => [row.id, decodeScopes(row.scopes)])); - - assert.deepStrictEqual(byId.get("open"), [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "filesystem:read", - "diagnostics:read", - "settings:write", - "providers:manage", - "environment:maintain", - "preview:operate", - "source-control:write", - "filesystem:write", - "terminal:read", - ]); - assert.deepStrictEqual(byId.get("narrow"), [ - "orchestration:read", - "filesystem:read", - "diagnostics:read", - ]); - assert.deepStrictEqual(byId.get("consumed"), LEGACY_STANDARD_SCOPES); - assert.deepStrictEqual(byId.get("revoked"), LEGACY_STANDARD_SCOPES); - - const sessions = yield* sql<{ readonly id: string; readonly scopes: string }>` - SELECT session_id AS id, scopes FROM auth_sessions ORDER BY session_id - `; - const sessionScopes = new Map(sessions.map((row) => [row.id, decodeScopes(row.scopes)])); - assert.deepStrictEqual(sessionScopes.get("live"), byId.get("open")); - assert.deepStrictEqual(sessionScopes.get("gone"), LEGACY_STANDARD_SCOPES); - }), - ); -}); diff --git a/apps/server/src/persistence/Migrations/050_ExpandLegacyAuthScopes.ts b/apps/server/src/persistence/Migrations/050_ExpandLegacyAuthScopes.ts deleted file mode 100644 index 63255f39a2f6..000000000000 --- a/apps/server/src/persistence/Migrations/050_ExpandLegacyAuthScopes.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { AuthEnvironmentScopes, expandLegacyScopes } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -const ScopesJson = Schema.fromJsonString(AuthEnvironmentScopes); -const decodeScopes = Schema.decodeUnknownEffect(ScopesJson); -const encodeScopes = Schema.encodeSync(ScopesJson); - -/** - * Credentials recorded before scopes were split still carry the broad ones. - * Rewrite live pairing links and sessions so they grant what the same - * credential meant when it was created. Session rows back websocket tickets; - * the signed session token carries its own copy of the scopes, and v1 tokens - * are expanded when verified. - */ -export default Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const expandTable = (table: "auth_pairing_links" | "auth_sessions") => - Effect.gen(function* () { - const rows = - table === "auth_pairing_links" - ? yield* sql<{ readonly id: string; readonly scopes: string }>` - SELECT id, scopes - FROM auth_pairing_links - WHERE revoked_at IS NULL AND consumed_at IS NULL - ` - : yield* sql<{ readonly id: string; readonly scopes: string }>` - SELECT session_id AS id, scopes - FROM auth_sessions - WHERE revoked_at IS NULL - `; - for (const row of rows) { - const scopes = yield* decodeScopes(row.scopes).pipe(Effect.option); - if (scopes._tag === "None") continue; - const expanded = expandLegacyScopes(scopes.value); - if (expanded === scopes.value) continue; - const encoded = encodeScopes(expanded); - if (table === "auth_pairing_links") { - yield* sql`UPDATE auth_pairing_links SET scopes = ${encoded} WHERE id = ${row.id}`; - } else { - yield* sql`UPDATE auth_sessions SET scopes = ${encoded} WHERE session_id = ${row.id}`; - } - } - }); - yield* expandTable("auth_pairing_links"); - yield* expandTable("auth_sessions"); -}); 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 { expect(AuthStandardClientScopes).not.toContain("review:write"); }); - it("expands a pre-split standard grant to the current standard set", () => { - const expanded = expandLegacyScopes([ + // 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", - ]); - for (const scope of AuthStandardClientScopes) expect(expanded).toContain(scope); - expect(expanded).not.toContain("access:write"); - expect(expanded).not.toContain("relay:write"); + "relay:write", + ]), + ); + + const decodeOldScopes = Schema.decodeUnknownSync(oldScopes); + + 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("returns the same array when nothing needs expanding", () => { - const scopes = ["filesystem:read", "relay:read"] as const; - expect(expandLegacyScopes(scopes)).toBe(scopes); + 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: {} }, diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 1a6d2a64c9d0..bf2b2892901a 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,56 +127,46 @@ export type AuthGrantScope = typeof AuthGrantScope.Type; export const AuthGrantScopes = Schema.Array(AuthGrantScope); export type AuthGrantScopes = typeof AuthGrantScopes.Type; -/** - * Scopes that were split out of a broader one. A grant recorded before the - * split carries the parent; expanding it yields what the same grant means now. - * Servers apply this to stored credentials; clients use it in reverse to ask an - * older server for the parent when it does not know the split-out scope. - */ -export const LEGACY_SCOPE_EXPANSIONS: Readonly< - Partial>> -> = { - [AuthOrchestrationReadScope]: [AuthFilesystemReadScope, AuthDiagnosticsReadScope], - [AuthOrchestrationOperateScope]: [ - AuthSettingsWriteScope, - AuthProvidersManageScope, - AuthEnvironmentMaintainScope, - AuthPreviewOperateScope, - AuthSourceControlWriteScope, - AuthFilesystemWriteScope, - ], - [AuthTerminalOperateScope]: [AuthTerminalReadScope], - [AuthReviewWriteScope]: [AuthFilesystemReadScope], -}; +// Frozen wire vocabulary for clients released before granular permissions. +const legacyScopes = new Set([ + AuthOrchestrationReadScope, + AuthOrchestrationOperateScope, + AuthTerminalOperateScope, + AuthReviewWriteScope, + AuthAccessReadScope, + AuthAccessWriteScope, + AuthRelayReadScope, + AuthRelayWriteScope, +]); -export function expandLegacyScopes( - scopes: ReadonlyArray, -): ReadonlyArray { - const expanded = new Set(scopes); - for (const scope of scopes) { - for (const implied of LEGACY_SCOPE_EXPANSIONS[scope] ?? []) expanded.add(implied); - } - return expanded.size === scopes.length ? scopes : [...expanded]; +/** 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 }; } -/** The scope an older server checked before `scope` was split out, if any. */ -export function legacyParentScope(scope: AuthEnvironmentScope): AuthEnvironmentScope | null { - for (const [parent, children] of Object.entries(LEGACY_SCOPE_EXPANSIONS)) { - if (parent !== AuthReviewWriteScope && children?.includes(scope)) { - return parent as AuthEnvironmentScope; - } - } - return null; -} +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, +}; -/** - * Whether a session grants `scope`. An older server does not know the - * split-out scopes and still authorizes those RPCs with the parent, so a - * client checks the parent when the server does not advertise the split. - */ export interface SessionGrantInput { readonly authenticated: boolean; readonly scopes?: ReadonlyArray | undefined; + readonly permissions?: ReadonlyArray | undefined; readonly auth?: { readonly serverUpdateScope?: string | undefined } | undefined; } @@ -183,11 +174,13 @@ export function sessionGrantsScope( session: SessionGrantInput, scope: AuthEnvironmentScope, ): boolean { - if (!session.authenticated || session.scopes === undefined) return false; - if (session.scopes.includes(scope)) return true; + 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 = legacyParentScope(scope); - return parent !== null && session.scopes.includes(parent); + const parent = legacyParents[scope]; + return parent !== undefined && session.scopes?.includes(parent) === true; } export const AuthStandardClientScopes = [ @@ -255,7 +248,7 @@ export type AuthBrowserSessionRequest = typeof AuthBrowserSessionRequest.Type; export const AuthBrowserSessionResult = Schema.Struct({ authenticated: Schema.Literal(true), - scopes: AuthEnvironmentScopes, + ...authScopeResponseFields, sessionMethod: ServerAuthSessionMethod, expiresAt: Schema.DateTimeUtc, }); @@ -321,7 +314,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, @@ -342,7 +335,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, @@ -449,6 +442,7 @@ export const AuthSessionState = Schema.Struct({ authenticated: Schema.Boolean, auth: ServerAuthDescriptor, scopes: Schema.optionalKey(AuthEnvironmentScopes), + permissions: authScopeResponseFields.permissions, sessionMethod: Schema.optionalKey(ServerAuthSessionMethod), expiresAt: Schema.optionalKey(Schema.DateTimeUtc), }); From 96a00d7679015d36d9c59a541457d695f3771271 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 19:19:45 -0700 Subject: [PATCH 03/13] fix(auth): preserve legacy permission errors and access updates --- apps/server/src/auth/http.ts | 3 ++- apps/server/src/ws.ts | 5 ++++- docs/internals/environment-auth.md | 4 +++- packages/contracts/src/auth.test.ts | 11 +++++++++++ packages/contracts/src/auth.ts | 9 +++++++++ packages/contracts/src/environmentHttp.ts | 3 ++- 6 files changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index ba3c2e7e4304..5362f882fa69 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -1,4 +1,5 @@ import { + authScopeRequiredResponse, AuthAccessReadScope, AuthAccessWriteScope, AuthStandardClientScopes, @@ -124,7 +125,7 @@ export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope Effect.fail( new EnvironmentScopeRequiredError({ code: "insufficient_scope", - requiredScope, + ...authScopeRequiredResponse(requiredScope), traceId, }), ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4cba3fca0197..8404bd33240d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -24,6 +24,8 @@ import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, AcpRegistryOperationError, CommandId, + authScopeRequiredResponse, + authScopeResponse, AuthAccessStreamError, type AuthAccessStreamEvent, AuthOrchestrationOperateScope, @@ -561,7 +563,7 @@ function toAuthAccessStreamEvent( version: 1, revision, type: "pairingLinkUpserted", - payload: change.pairingLink, + payload: { ...change.pairingLink, ...authScopeResponse(change.pairingLink.scopes) }, }; case "pairingLinkRemoved": return { @@ -577,6 +579,7 @@ function toAuthAccessStreamEvent( type: "clientUpserted", payload: { ...change.clientSession, + ...authScopeResponse(change.clientSession.scopes), current: change.clientSession.sessionId === currentSessionId, }, }; diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index 05eb58076123..2526ee69caf9 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -67,7 +67,9 @@ 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. Unknown response permissions are ignored; grant inputs stay strict. +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/packages/contracts/src/auth.test.ts b/packages/contracts/src/auth.test.ts index a689dcdb5220..40e7cedc5ede 100644 --- a/packages/contracts/src/auth.test.ts +++ b/packages/contracts/src/auth.test.ts @@ -3,6 +3,8 @@ import * as Schema from "effect/Schema"; import { AuthEnvironmentScopes, + AuthEnvironmentScope, + authScopeRequiredResponse, AuthGrantScopes, AuthStandardClientScopes, authScopeResponse, @@ -36,6 +38,15 @@ describe("authorization grants", () => { 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); diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index bf2b2892901a..1126193e969c 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -163,6 +163,14 @@ const legacyParents: Partial> [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; @@ -392,6 +400,7 @@ export class EnvironmentAuthorizationError extends Schema.TaggedError Date: Mon, 14 Sep 2026 19:08:17 -0700 Subject: [PATCH 04/13] fix(auth): preserve compatible scope responses for reusable dev pairing --- apps/server/src/auth/EnvironmentAuth.test.ts | 9 +++++++-- apps/server/src/auth/EnvironmentAuth.ts | 7 +++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index dc1437edb4e8..988883e2d0bd 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( @@ -241,7 +246,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({ diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index a51276b26077..715e70a8e2d7 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -801,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, @@ -898,7 +898,10 @@ export const make = Effect.gen(function* () { }).pipe( Effect.flatMap((grant) => Effect.gen(function* () { - const grantedScopes = requestedScopes === undefined ? grant.scopes : [...new Set(requestedScopes)].filter((scope) => grant.scopes.includes(scope)); + const grantedScopes = + requestedScopes === undefined + ? grant.scopes + : [...new Set(requestedScopes)].filter((scope) => grant.scopes.includes(scope)); return yield* sessions .issue({ method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", From 8e57dcd840015ed450dbc5170a13b318563c56c7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 4 Oct 2026 01:22:15 -0700 Subject: [PATCH 05/13] fix(auth): retain legacy error fields in v2 RPC authorization --- apps/server/src/auth/RpcAuthorization.test.ts | 13 ++++++++----- apps/server/src/auth/RpcAuthorization.ts | 3 ++- apps/server/src/ws.ts | 1 - 3 files changed, 10 insertions(+), 7 deletions(-) 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/ws.ts b/apps/server/src/ws.ts index 8404bd33240d..fab4edb44406 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -24,7 +24,6 @@ import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, AcpRegistryOperationError, CommandId, - authScopeRequiredResponse, authScopeResponse, AuthAccessStreamError, type AuthAccessStreamEvent, From 9691817eb0de508d8b13af2045d904b5a6cca01c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 4 Oct 2026 17:56:20 -0700 Subject: [PATCH 06/13] fix(auth): honor exact mobile maintenance permissions --- .../settings/environment-maintenance.test.ts | 27 +++++++++++++++++++ .../settings/environment-maintenance.ts | 6 ++--- 2 files changed, 30 insertions(+), 3 deletions(-) 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) ); } From 03d04dfae6ff25e22529459de64d9d0c7a13cc1c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 4 Oct 2026 18:29:03 -0700 Subject: [PATCH 07/13] fix(auth): honor exact and legacy grants in client dispatch --- packages/client-runtime/src/rpc/client.ts | 3 +- .../src/state/commandPermissions.test.ts | 71 ++++++++++++++++++- .../src/state/commandPermissions.ts | 12 ++-- 3 files changed, 76 insertions(+), 10 deletions(-) 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..0b85a8af69e3 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,58 @@ 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, { + 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}.`, }), ); From 7642b0ccad4d39b46826f92bb6d2f86d6d3e4f57 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 4 Oct 2026 18:30:21 -0700 Subject: [PATCH 08/13] test(auth): supply the streamed action correlation id --- packages/client-runtime/src/state/commandPermissions.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index 0b85a8af69e3..b9d1ebf6b738 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -253,6 +253,7 @@ it.effect("rejects protected unary and streamed RPCs outside a guarded command", }).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); From 34ba8641cb9c775dc55d7683d9cb8053d19492f8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 12:55:47 -0700 Subject: [PATCH 09/13] test(auth): use current session-store layer helpers --- apps/server/src/auth/SessionStore.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index b7265604ad77..374a51be6872 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -311,9 +311,9 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { Effect.provide( SessionStore.layer.pipe( Layer.provideMerge(ServerSecretStore.layer), - Layer.provide(SqlitePersistenceMemory), - Layer.provide(makeServerEnvironmentLayer(EnvironmentId.make("test-environment"))), - Layer.provide(makeServerConfigLayer()), + Layer.provide(SqlitePersistence.layerMemory), + Layer.provide(layerServerEnvironment(EnvironmentId.make("test-environment"))), + Layer.provide(layerServerConfig()), ), ), ), From 760836e7db8bfe049ba49430150652f7beeefc9f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 19:17:51 -0700 Subject: [PATCH 10/13] feat(auth): explain permission changes to clients with legacy grants --- apps/mobile/src/App.tsx | 2 + .../src/components/PermissionUpdateNotice.tsx | 84 ++++++++++++++++ .../PermissionUpdateNotice.test.tsx | 98 +++++++++++++++++++ .../src/components/PermissionUpdateNotice.tsx | 73 ++++++++++++++ apps/web/src/routes/__root.tsx | 2 + packages/contracts/src/auth.test.ts | 27 +++++ packages/contracts/src/auth.ts | 11 +++ 7 files changed, 297 insertions(+) create mode 100644 apps/mobile/src/components/PermissionUpdateNotice.tsx create mode 100644 apps/web/src/components/PermissionUpdateNotice.test.tsx create mode 100644 apps/web/src/components/PermissionUpdateNotice.tsx 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/web/src/components/PermissionUpdateNotice.test.tsx b/apps/web/src/components/PermissionUpdateNotice.test.tsx new file mode 100644 index 000000000000..e72ff2a39038 --- /dev/null +++ b/apps/web/src/components/PermissionUpdateNotice.test.tsx @@ -0,0 +1,98 @@ +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"); + notice.onClose(); + 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(); +}); diff --git a/apps/web/src/components/PermissionUpdateNotice.tsx b/apps/web/src/components/PermissionUpdateNotice.tsx new file mode 100644 index 000000000000..6e27708c8ef0 --- /dev/null +++ b/apps/web/src/components/PermissionUpdateNotice.tsx @@ -0,0 +1,73 @@ +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 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: () => { + try { + setLocalStorageItem(key, true, Schema.Boolean); + } catch { + // The in-memory marker still prevents repeats during this launch. + } + }, + actionProps: { + children: "Open Connections", + onClick: () => { + toastManager.close(id); + void navigate({ to: "/settings/connections" }); + }, + }, + data: { + actionLayout: "stacked-end", + secondaryActionProps: { children: "Dismiss", onClick: () => toastManager.close(id) }, + 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/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/packages/contracts/src/auth.test.ts b/packages/contracts/src/auth.test.ts index 40e7cedc5ede..5d8fb43afe61 100644 --- a/packages/contracts/src/auth.test.ts +++ b/packages/contracts/src/auth.test.ts @@ -10,6 +10,7 @@ import { authScopeResponse, AuthSessionState, sessionGrantsScope, + sessionHasLegacyPermissions, } from "./auth.ts"; describe("authorization grants", () => { @@ -127,3 +128,29 @@ describe("authorization grants", () => { 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 1126193e969c..f0478da0c8aa 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -191,6 +191,17 @@ export function sessionGrantsScope( 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, From d6d6d684c3abac26b63cfe9f58e985e6cc2e6bf5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 19:28:36 -0700 Subject: [PATCH 11/13] style(auth): format session compatibility imports --- apps/server/src/auth/SessionStore.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index afdb61ba1af7..4db7e95e3066 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -8,7 +8,6 @@ import { type AuthEnvironmentScope, type ClientSurface, RuntimeMode, - type ServerAuthSessionMethod, } from "@t3tools/contracts"; import * as Context from "effect/Context"; From e2447ac1297835ba449538aa2998a37110ee89e9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 19:59:13 -0700 Subject: [PATCH 12/13] fix(auth): reject empty grants before token issuance --- apps/server/src/auth/EnvironmentAuth.test.ts | 10 ++++++++++ apps/server/src/auth/EnvironmentAuth.ts | 3 +++ apps/server/src/auth/http.test.ts | 19 +++++++++++++++++++ apps/server/src/auth/http.ts | 2 +- 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 988883e2d0bd..a85db0fd1492 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -219,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({ diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 715e70a8e2d7..ac90e67b6f26 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -902,6 +902,9 @@ export const make = Effect.gen(function* () { 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", 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 5362f882fa69..c0d2946813f9 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -360,7 +360,7 @@ export const layer = HttpApiBuilder.group( args.payload.scope === undefined ? undefined : (parseOAuthScope(args.payload.scope)?.filter(Schema.is(AuthGrantScope)) ?? null); - if (requestedScopes === null) { + if (requestedScopes === null || requestedScopes?.length === 0) { return yield* failEnvironmentInvalidRequest("invalid_scope"); } const proofKeyThumbprint = args.headers.dpop From fe220d767973aea190ce2808f09754b391fdbd14 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 19:59:14 -0700 Subject: [PATCH 13/13] fix(web): persist permission notice action dismissals --- .../PermissionUpdateNotice.test.tsx | 15 +++++++++++- .../src/components/PermissionUpdateNotice.tsx | 23 +++++++++++-------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/PermissionUpdateNotice.test.tsx b/apps/web/src/components/PermissionUpdateNotice.test.tsx index e72ff2a39038..f96a2d410f16 100644 --- a/apps/web/src/components/PermissionUpdateNotice.test.tsx +++ b/apps/web/src/components/PermissionUpdateNotice.test.tsx @@ -61,7 +61,6 @@ it("keeps the notice visible and persists dismissal, with a route to pairing set notice.actionProps.onClick(); expect(state.navigate).toHaveBeenCalledWith({ to: "/settings/connections" }); expect(state.close).toHaveBeenCalledWith("notice"); - notice.onClose(); expect(state.save).toHaveBeenCalledWith( `t3code:permission-update:v1:env-${state.id}`, true, @@ -96,3 +95,17 @@ it("does not warn for a granular grant", async () => { 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 index 6e27708c8ef0..6c01afe2ccfe 100644 --- a/apps/web/src/components/PermissionUpdateNotice.tsx +++ b/apps/web/src/components/PermissionUpdateNotice.tsx @@ -36,28 +36,33 @@ function EnvironmentPermissionNotice({ // 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: () => { - try { - setLocalStorageItem(key, true, Schema.Boolean); - } catch { - // The in-memory marker still prevents repeats during this launch. - } - }, + onClose: persistDismissal, actionProps: { children: "Open Connections", onClick: () => { - toastManager.close(id); + dismiss(); void navigate({ to: "/settings/connections" }); }, }, data: { actionLayout: "stacked-end", - secondaryActionProps: { children: "Dismiss", onClick: () => toastManager.close(id) }, + secondaryActionProps: { children: "Dismiss", onClick: dismiss }, secondaryActionVariant: "ghost", }, });