From 4bb9114bc1cbcbb5b66a85f1bba5ae3ab4b44a4c Mon Sep 17 00:00:00 2001 From: jjmaxwell4 Date: Mon, 23 Mar 2026 21:26:32 -0700 Subject: [PATCH] feat: add env:sandbox and env:live OAuth scopes Allows OAuth clients to request environment-specific scopes so the oauthTokenMiddleware can resolve sandbox vs live context from the token. Also hardens groupScopesByResource and groupAndFormatScopes to skip non-resource scopes gracefully instead of crashing the consent page. Made-with: Cursor --- shared/utils/scopeDefinitions.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/shared/utils/scopeDefinitions.ts b/shared/utils/scopeDefinitions.ts index 2cd412824..c71037ad7 100644 --- a/shared/utils/scopeDefinitions.ts +++ b/shared/utils/scopeDefinitions.ts @@ -27,6 +27,11 @@ export const OPENID_SCOPES = [ "offline_access", ] as const; +/** + * Environment scopes — appended by OAuth clients to select sandbox or live + */ +export const ENV_SCOPES = ["env:sandbox", "env:live"] as const; + /** * Legacy scopes (for backward compatibility with existing clients) * @deprecated Use resource:action format instead (e.g., apiKeys:read) @@ -79,6 +84,7 @@ export const RESOURCE_SCOPES: ScopeString[] = [ */ export const ALL_SCOPES = [ ...OPENID_SCOPES, + ...ENV_SCOPES, ...LEGACY_SCOPES, ...RESOURCE_SCOPES, ]; @@ -194,11 +200,12 @@ export function groupScopesByResource( const grouped = new Map(); for (const scope of scopes) { - // Skip OpenID scopes - they're not resource-based if (isOpenIdScope(scope)) continue; + if ((ENV_SCOPES as readonly string[]).includes(scope)) continue; const { resource, action } = parseScope(scope); if (!resource || !action) continue; + if (!(resource in RESOURCE_METADATA)) continue; const existingActions = grouped.get(resource) || []; if (!existingActions.includes(action)) { @@ -278,18 +285,18 @@ export function getResourceDescription(resource: ResourceType): string { /** * Check if a scope is valid */ -export function isValidScope(scope: string): scope is ScopeString { - return ALL_SCOPES.includes(scope as ScopeString); +export function isValidScope(scope: string): boolean { + return (ALL_SCOPES as readonly string[]).includes(scope); } /** * Validate an array of scopes */ export function validateScopes(scopes: string[]): { - valid: ScopeString[]; + valid: string[]; invalid: string[]; } { - const valid: ScopeString[] = []; + const valid: string[] = []; const invalid: string[] = []; for (const scope of scopes) { @@ -320,12 +327,15 @@ export function groupAndFormatScopes(scopes: string[]): GroupedPermission[] { const result: GroupedPermission[] = []; for (const [resource, actions] of grouped.entries()) { + const meta = RESOURCE_METADATA[resource]; + if (!meta) continue; + result.push({ resource, - resourceName: RESOURCE_METADATA[resource].namePlural, + resourceName: meta.namePlural, actions, formattedPermission: formatResourcePermission(resource, actions), - description: RESOURCE_METADATA[resource].description, + description: meta.description, }); }