apps/server/src/auth/RpcAuthorization.ts
import {
CLIENT_GUARDED_RPC_SCOPES,
type DeviceListInput,
clientRpcRequiredScopes,
authScopeRequiredResponse,
AssetCreateUrlInput,
AuthAccessReadScope,
ServerSettingsPatch,
ProviderInstanceMutation,
requiredScopesForServerSettingsPatch,
requiredScopesForProjectMutation,
ProjectMutation,
AuthSettingsWriteScope,
AuthProvidersManageScope,
AuthEnvironmentMaintainScope,
AuthFilesystemReadScope,
AuthFilesystemWriteScope,
AuthDiagnosticsReadScope,
AuthOrchestrationOperateScope,
AuthOrchestrationReadScope,
AuthPreviewOperateScope,
AuthRelayReadScope,
AuthRelayWriteScope,
AuthTerminalOperateScope,
ORCHESTRATION_V2_WS_METHODS,
AuthTerminalReadScope,
type AuthEnvironmentScope,
EnvironmentAuthorizationError,
RpcScopeAuthorization,
WS_METHODS,
WsRpcGroup,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import * as Layer from "effect/Layer";
import type * as RpcGroup from "effect/rpc/RpcGroup";
type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];
export const RPC_REQUIRED_SCOPES = {
...CLIENT_GUARDED_RPC_SCOPES,
[ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,
[ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope,
[ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.searchThread]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.searchThreadStream]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.getOlderSettledThreads]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.getTurnItem]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.launchThread]: AuthOrchestrationOperateScope,
[ORCHESTRATION_V2_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.subscribeCommandOutput]: AuthOrchestrationReadScope,
[WS_METHODS.projectsMutate]: AuthOrchestrationOperateScope,
[WS_METHODS.serverProbe]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope,
[WS_METHODS.serverRefreshProviders]: AuthOrchestrationReadScope,
[WS_METHODS.serverUpdateProvider]: AuthProvidersManageScope,
[WS_METHODS.providerAuthStart]: AuthProvidersManageScope,
[WS_METHODS.providerConsumeResetCredit]: AuthProvidersManageScope,
[WS_METHODS.providerAuthComplete]: AuthProvidersManageScope,
[WS_METHODS.chatGptReconnectProfile]: AuthProvidersManageScope,
[WS_METHODS.chatGptImportProfile]: AuthProvidersManageScope,
[WS_METHODS.chatGptHandoffSubscribe]: AuthProvidersManageScope,
[WS_METHODS.providerAuthRespond]: AuthProvidersManageScope,
[WS_METHODS.providerAuthCancel]: AuthProvidersManageScope,
[WS_METHODS.providerAuthLogout]: AuthProvidersManageScope,
[WS_METHODS.providerAuthSubscribe]: AuthProvidersManageScope,
[WS_METHODS.providerInstallStart]: AuthProvidersManageScope,
[WS_METHODS.providerInstallCancel]: AuthProvidersManageScope,
[WS_METHODS.providerInstallSubscribe]: AuthOrchestrationReadScope,
[WS_METHODS.providerInstallRemove]: AuthProvidersManageScope,
[WS_METHODS.serverUpdateServer]: AuthEnvironmentMaintainScope,
[WS_METHODS.serverUpdateServerWithProgress]: AuthEnvironmentMaintainScope,
[WS_METHODS.serverCommitDesktopUpdate]: AuthEnvironmentMaintainScope,
[WS_METHODS.serverUpsertKeybinding]: AuthSettingsWriteScope,
[WS_METHODS.serverRemoveKeybinding]: AuthSettingsWriteScope,
[WS_METHODS.serverGetStorageCleanupReport]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetDatabaseStorage]: AuthOrchestrationReadScope,
[WS_METHODS.peerLinksList]: AuthAccessReadScope,
[WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope,
[WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope,
[WS_METHODS.serverPrepareAcpRegistryAgent]: AuthProvidersManageScope,
[WS_METHODS.serverUninstallAcpRegistryManagedBinary]: AuthProvidersManageScope,
[WS_METHODS.serverAcceptAcpRegistryUrlAuth]: AuthProvidersManageScope,
[WS_METHODS.serverListAcpRegistrySessions]: AuthOrchestrationReadScope,
[WS_METHODS.serverImportAcpRegistrySession]: AuthOrchestrationOperateScope,
[WS_METHODS.serverDeleteAcpRegistrySession]: AuthOrchestrationOperateScope,
[WS_METHODS.serverListAcpRegistryProviders]: AuthOrchestrationReadScope,
[WS_METHODS.serverSetAcpRegistryProvider]: AuthProvidersManageScope,
[WS_METHODS.serverDisableAcpRegistryProvider]: AuthProvidersManageScope,
[WS_METHODS.serverLogoutAcpRegistry]: AuthProvidersManageScope,
[WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetTraceDiagnostics]: AuthDiagnosticsReadScope,
[WS_METHODS.serverGetProcessDiagnostics]: AuthDiagnosticsReadScope,
// Thread load balancing reads host load, so it needs thread scope rather than diagnostics scope.
[WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetProcessResourceHistory]: AuthDiagnosticsReadScope,
[WS_METHODS.serverGetResourceTelemetryHistory]: AuthDiagnosticsReadScope,
[WS_METHODS.serverRetryResourceTelemetry]: AuthDiagnosticsReadScope,
[WS_METHODS.serverGetUsageSummary]: AuthDiagnosticsReadScope,
[WS_METHODS.serverRefreshUsageRates]: AuthDiagnosticsReadScope,
[WS_METHODS.serverSignalProcess]: AuthEnvironmentMaintainScope,
[WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope,
[WS_METHODS.scheduledTasksSubscribe]: AuthOrchestrationReadScope,
[WS_METHODS.secretsAnswerRequest]: AuthOrchestrationOperateScope,
// Delivery logs hold request bodies, so they need the same scope as the URL.
[WS_METHODS.scheduledTasksListWebhookDeliveries]: AuthOrchestrationOperateScope,
[WS_METHODS.scheduledTasksGetWebhookDelivery]: AuthOrchestrationOperateScope,
[WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope,
[WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope,
[WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsFilterOptions]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsRouting]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsRoutingIdentity]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsPreview]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsChecks]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsReviewGuide]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsFilesViewed]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsChangeBreakdown]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsReportState]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsSubscribeRefreshes]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope,
[WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope,
[WS_METHODS.sourceControlAppAccess]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope,
[WS_METHODS.projectsListEntries]: AuthFilesystemReadScope,
[WS_METHODS.projectsListIgnoredFiles]: AuthFilesystemReadScope,
[WS_METHODS.projectsReadFile]: AuthFilesystemReadScope,
[WS_METHODS.projectsSearchContents]: AuthFilesystemReadScope,
[WS_METHODS.projectsSearchEntries]: AuthFilesystemReadScope,
[WS_METHODS.projectsWriteFile]: AuthFilesystemWriteScope,
[WS_METHODS.projectsEnsureScratch]: AuthOrchestrationOperateScope,
[WS_METHODS.projectsCreateNew]: AuthOrchestrationOperateScope,
[WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
[WS_METHODS.filesystemBrowse]: AuthFilesystemReadScope,
[WS_METHODS.filesystemGetMetadata]: AuthFilesystemReadScope,
[WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope,
[WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope,
[WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,
[WS_METHODS.assetsPersistChatAttachments]: AuthOrchestrationOperateScope,
[WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope,
[WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope,
[WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope,
[WS_METHODS.mcpAppsCallTool]: AuthOrchestrationOperateScope,
[WS_METHODS.mcpAppsToolInfo]: AuthOrchestrationReadScope,
[WS_METHODS.mcpAppsReadResource]: AuthOrchestrationReadScope,
[WS_METHODS.mcpAppsUpdateModelContext]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope,
[WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribeResourceTelemetry]: AuthDiagnosticsReadScope,
[WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope,
[WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope,
[WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,
[WS_METHODS.reviewGetDiffPreview]: AuthFilesystemReadScope,
[WS_METHODS.reviewGetDiffFileContents]: AuthFilesystemReadScope,
[WS_METHODS.terminalOpen]: AuthTerminalOperateScope,
[WS_METHODS.terminalAttach]: AuthTerminalOperateScope,
[WS_METHODS.terminalObserve]: AuthTerminalReadScope,
[WS_METHODS.terminalWrite]: AuthTerminalOperateScope,
[WS_METHODS.terminalResize]: AuthTerminalOperateScope,
[WS_METHODS.terminalClear]: AuthTerminalOperateScope,
[WS_METHODS.terminalClose]: AuthTerminalOperateScope,
[WS_METHODS.subscribeTerminalMetadata]: AuthTerminalReadScope,
[WS_METHODS.previewOpen]: AuthPreviewOperateScope,
[WS_METHODS.previewResize]: AuthPreviewOperateScope,
[WS_METHODS.previewAdjust]: AuthPreviewOperateScope,
[WS_METHODS.previewClose]: AuthPreviewOperateScope,
[WS_METHODS.previewList]: AuthOrchestrationReadScope,
[WS_METHODS.previewClearProfile]: AuthPreviewOperateScope,
[WS_METHODS.previewReportProfiles]: AuthPreviewOperateScope,
[WS_METHODS.previewReportStatus]: AuthPreviewOperateScope,
[WS_METHODS.previewStopLocalServer]: AuthTerminalOperateScope,
[WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
[WS_METHODS.deviceConfigure]: AuthSettingsWriteScope,
[WS_METHODS.deviceTestHost]: AuthSettingsWriteScope,
[WS_METHODS.deviceEasAccount]: AuthSettingsWriteScope,
[WS_METHODS.deviceList]: AuthOrchestrationReadScope,
[WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceClose]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceShutdown]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceDetail]: AuthOrchestrationReadScope,
[WS_METHODS.deviceAction]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribeDeviceState]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope,
} as const satisfies Readonly<Record<WsRpcMethod, AuthEnvironmentScope>>;
export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope {
if (!Object.hasOwn(RPC_REQUIRED_SCOPES, method)) {
throw new Error(`RPC method ${method} has no declared authorization scope.`);
}
const requiredScope = RPC_REQUIRED_SCOPES[method as WsRpcMethod];
if (requiredScope === undefined) {
throw new Error(`RPC method ${method} has no declared authorization scope.`);
}
return requiredScope;
}
export const rpcAuthorizationError = (requiredScope: AuthEnvironmentScope) =>
new EnvironmentAuthorizationError({
message: `The authenticated token is missing required scope: ${requiredScope}.`,
...authScopeRequiredResponse(requiredScope),
});
const SettingsUpdate = Schema.Struct({
patch: ServerSettingsPatch,
providerInstanceMutation: Schema.optionalKey(ProviderInstanceMutation),
});
const decodeSettingsUpdate = Schema.decodeUnknownSync(SettingsUpdate);
const requiredScopesForSettingsUpdate = (payload: unknown) => {
const input = decodeSettingsUpdate(payload);
const scopes = requiredScopesForServerSettingsPatch(input.patch);
if (input.providerInstanceMutation === undefined) return scopes;
return Object.values(input.patch).every((value) => value === undefined)
? [AuthProvidersManageScope]
: [...new Set([...scopes, AuthProvidersManageScope])];
};
const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation);
const decodeAssetCreateUrlInput = Schema.decodeUnknownSync(AssetCreateUrlInput);
const requiredScopesForRpcCall = (
method: string,
payload: unknown,
): ReadonlyArray<AuthEnvironmentScope> => {
if (method === WS_METHODS.serverRetryResourceTelemetry) {
return [AuthEnvironmentMaintainScope, AuthDiagnosticsReadScope];
}
if (method === WS_METHODS.assetsCreateUrl) {
const { resource } = decodeAssetCreateUrlInput(payload);
return [
resource._tag === "workspace-file" ||
resource._tag === "media-file" ||
resource._tag === "draft-workspace-file"
? AuthFilesystemReadScope
: AuthOrchestrationReadScope,
];
}
if (method === WS_METHODS.serverUpdateSettings) return requiredScopesForSettingsUpdate(payload);
if (method === WS_METHODS.projectsMutate) {
return requiredScopesForProjectMutation(decodeProjectMutation(payload));
}
const guarded = clientRpcRequiredScopes(method, payload);
if (guarded.length > 0) return guarded;
return [requiredScopeForRpcMethod(method)];
};
/**
* `onAuthorized` runs once after an authorized request succeeds; for a stream
* RPC, that is when the stream completes. Every RPC passes through here, so it
* is the one place to observe requests without wrapping each handler.
*/
export const layer = (
scopes: ReadonlyArray<AuthEnvironmentScope>,
onAuthorized?: (method: string, payload: unknown) => Effect.Effect<void>,
) =>
Layer.succeed(RpcScopeAuthorization)((effect, { rpc, payload }) => {
const requiredScopes = requiredScopesForRpcCall(rpc._tag, payload);
const requiredScope = requiredScopes.find((scope) => !scopes.includes(scope));
if (requiredScope !== undefined) return Effect.fail(rpcAuthorizationError(requiredScope));
return onAuthorized === undefined
? effect
: Effect.tap(effect, () => onAuthorized(rpc._tag, payload));
});
/** Retrying can install or restart tools even though ordinary listing is readable. */
export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope =>
input.retryHostId || input.updateTool
? AuthOrchestrationOperateScope
: AuthOrchestrationReadScope;