From 89667194e76d4d074b6a285a43f6e04ca9b91061 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 14 Aug 2026 15:46:57 +0100 Subject: [PATCH] fix(auth): derive scope labels from catalog names --- package-lock.json | 8 +-- package.json | 2 +- src/auth/workers-oauth-utils.ts | 116 +++++++++++--------------------- 3 files changed, 44 insertions(+), 82 deletions(-) diff --git a/package-lock.json b/package-lock.json index a669133..9bb7911 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "cloudflare-mcp", "version": "0.1.0", "dependencies": { - "@cloudflare/workers-oauth-provider": "0.10.2", + "@cloudflare/workers-oauth-provider": "0.10.3", "@modelcontextprotocol/server": "2.0.0", "hono": "^4.12.25", "zod": "^4.3.5" @@ -167,9 +167,9 @@ } }, "node_modules/@cloudflare/workers-oauth-provider": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-oauth-provider/-/workers-oauth-provider-0.10.2.tgz", - "integrity": "sha512-3yWtnPaZyNaiLuv9tWkWfEyqbILFsvfG0Q5RwZGJ2jkSkrOMM8IxKYaGCFakSGbMXmatvyrhFDCDZ70S6TVnqQ==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-oauth-provider/-/workers-oauth-provider-0.10.3.tgz", + "integrity": "sha512-25ufMONJir9PllqVpK4GwOOoSFgpYm3+bM6NBedj7ufMCIfNf5jk4OI0LPuTJBaJgLl2lGCLqFqEPJXcSuPopQ==", "license": "MIT" }, "node_modules/@cspotcode/source-map-support": { diff --git a/package.json b/package.json index 0657463..09f4546 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "seed:prod": "tsx scripts/seed-r2.ts production" }, "dependencies": { - "@cloudflare/workers-oauth-provider": "0.10.2", + "@cloudflare/workers-oauth-provider": "0.10.3", "@modelcontextprotocol/server": "2.0.0", "hono": "^4.12.25", "zod": "^4.3.5" diff --git a/src/auth/workers-oauth-utils.ts b/src/auth/workers-oauth-utils.ts index 481130c..9cbe84c 100644 --- a/src/auth/workers-oauth-utils.ts +++ b/src/auth/workers-oauth-utils.ts @@ -189,84 +189,39 @@ function isLoopbackRedirectUri(value: string): boolean { } } -/** - * Override labels for resources whose humanized form would mangle acronyms - * or brand names (e.g. `url_scanner` → "Url scanner", `cfone` → "Cfone"). - */ -const RESOURCE_LABELS: Record = { - access: 'Access', - ai: 'AI', - aig: 'AI Gateway', - aiaudit: 'AI Audit', - 'ai-search': 'AI Search', - 'account-analytics': 'Account analytics', - containers: 'Containers', - d1: 'D1', - logs: 'Logs', - offline_access: 'Offline access', - pages: 'Pages', - pipelines: 'Pipelines', - queues: 'Queues', - radar: 'Radar', - 'account-ssl-and-certificates': 'Account SSL and Certificates', - 'ssl-and-certificates': 'SSL and Certificates', - teams: 'Teams (Zero Trust)', - snippets: 'Snippets', - user: 'User', - account: 'Account', - vectorize: 'Vectorize', - zone: 'Zone' +/** Convert a scope action such as `metadata_read` to its catalog-name form. */ +function catalogActionName(action: string): string { + return action + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') } -/** - * Turn a resource key like `workers_scripts` into a human-readable label. - * Falls back to title-casing unknown keys. - */ -function humanize(key: string): string { - if (RESOURCE_LABELS[key]) return RESOURCE_LABELS[key] - - const acronyms = new Map( - [ - 'ai', - 'api', - 'bgp', - 'cds', - 'cf', - 'd1', - 'ddos', - 'dex', - 'dls', - 'dmarc', - 'dns', - 'fbm', - 'http', - 'idp', - 'iot', - 'ip', - 'l4', - 'mcp', - 'mtls', - 'pcaps', - 'pii', - 'r2', - 'saml', - 'scim', - 'sso', - 'ssh', - 'ssl', - 'tls', - 'url', - 'vpc', - 'waf', - 'wan', - 'warp' - ].map((word) => [word, word.toUpperCase()]) - ) +/** Derive a resource stem from the catalog name by removing its scope action. */ +function catalogNameStem(name: string, action: string): string { + if (action === 'grant') return name - return key - .split(/[_-]/g) - .map((word) => acronyms.get(word) ?? word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') + const escapedAction = catalogActionName(action).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const stem = name + .replace(new RegExp(`\\b${escapedAction}\\b`, 'i'), '') + .replace(/\s+/g, ' ') + .replace(/\s+([:;,])/g, '$1') + .replace(/[-:;,]\s*$/, '') + .trim() + + return stem || name +} + +/** Prefer the catalog-name stem shared by the most actions for a resource. */ +function selectCatalogNameStem(candidates: string[]): string { + const counts = new Map() + for (const candidate of candidates) { + counts.set(candidate, (counts.get(candidate) ?? 0) + 1) + } + + return [...counts.entries()].sort( + ([a, countA], [b, countB]) => countB - countA || a.length - b.length || a.localeCompare(b) + )[0]![0] } interface ScopeRow { @@ -307,6 +262,7 @@ function groupScopesByCategory( requiredScopes: Set ): CategoryGroup[] { const byResource = new Map() + const labelsByResource = new Map() for (const [scope, definition] of Object.entries(scopeDefinitions)) { const desc = definition.name @@ -325,8 +281,10 @@ function groupScopesByCategory( // (for example, Pages access versus Page Shield). Keep those rows separate. const resourceKey = `${category}\u0000${resource}` if (!byResource.has(resourceKey)) { - byResource.set(resourceKey, { resource, label: humanize(resource), category, actions: [] }) + byResource.set(resourceKey, { resource, label: '', category, actions: [] }) + labelsByResource.set(resourceKey, []) } + labelsByResource.get(resourceKey)!.push(catalogNameStem(definition.name, action)) byResource.get(resourceKey)!.actions.push({ action, scope, @@ -335,6 +293,10 @@ function groupScopesByCategory( }) } + for (const [resourceKey, row] of byResource) { + row.label = selectCatalogNameStem(labelsByResource.get(resourceKey)!) + } + const actionRank: Record = { read: 0, metadata_read: 0, @@ -431,7 +393,7 @@ export function renderApprovalDialog(request: Request, options: ApprovalDialogOp const renderRow = (row: ScopeRow): string => { const pills = row.actions .map((a) => { - const label = ACTION_LABELS[a.action] ?? humanize(a.action) + const label = ACTION_LABELS[a.action] ?? catalogActionName(a.action) const classes = ['pill'] if (a.required) classes.push('pill--required') return ``