From 48fa04a2b9ee09c56f707acd1c0f3e83ddafe82c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 18:54:46 +0000 Subject: [PATCH 1/2] feat(cloud): open pre-filled Cloudflare token URL in browser Cloudflare connect no longer routes through docs.polylane.com to create the API token. The CLI now builds the same pre-filled read-only token URL the console and docs use (duplicated from nominal's buildCloudflareTokenUrl, with a keep-in-sync note) and opens it directly in the browser; the ~13KB URL is never printed. Without a browser (--no-browser or declined), the flow prints tight in-terminal steps: the console's connect screen or the short dash.cloudflare.com deep link. Non-interactive runs keep requiring --token, with the hint now pointing at the console instead of the docs page. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B7zxs3oUdsbbjhUnotue3H --- src/commands/cloud/cloudflare-token-url.ts | 246 +++++++++++++++++++++ src/commands/cloud/connect.ts | 99 +++++++-- test/cloudflare-token-url.test.ts | 56 +++++ 3 files changed, 378 insertions(+), 23 deletions(-) create mode 100644 src/commands/cloud/cloudflare-token-url.ts create mode 100644 test/cloudflare-token-url.test.ts diff --git a/src/commands/cloud/cloudflare-token-url.ts b/src/commands/cloud/cloudflare-token-url.ts new file mode 100644 index 0000000..ba830af --- /dev/null +++ b/src/commands/cloud/cloudflare-token-url.ts @@ -0,0 +1,246 @@ +// Duplicated from coreplanelabs/nominal +// apps/console/app/utils/cloudflare-token-url.ts (buildCloudflareTokenUrl) — +// the source of truth the console connect flow and the docs codegen both use. +// The Polylane API does not serve this URL yet, so the CLI carries its own +// copy; keep it in sync with the nominal file when the permission set changes. +// The generated URL is ~13KB (the permission list rides in a query param): +// fine for a browser to open, never print it to a terminal. +type CloudflarePermissionType = 'read' | 'edit' | 'run' | 'send' | 'purge'; + +type CloudflarePermission = { + key: string; + type: CloudflarePermissionType; +}; + +// Account-owned token permission keys, derived from the dashboard permission-group +// labels (key = label minus its trailing type segment). Zero Trust is read-only; +// billing/token-mint/SSO/SCIM/OAuth writes and user-scoped groups are deliberately omitted. +const NOMINAL_PERMISSIONS: CloudflarePermission[] = [ + { key: 'access', type: 'read' }, + { key: 'access_acct', type: 'read' }, + { key: 'access_app', type: 'read' }, + { key: 'access_audit_log', type: 'read' }, + { key: 'access_certificate', type: 'read' }, + { key: 'access_custom_page', type: 'read' }, + { key: 'access_device_posture', type: 'read' }, + { key: 'access_group', type: 'read' }, + { key: 'access_idp', type: 'read' }, + { key: 'access_key', type: 'read' }, + { key: 'access_org', type: 'read' }, + { key: 'access_policy', type: 'read' }, + { key: 'access_policy_test', type: 'read' }, + { key: 'access_population', type: 'read' }, + { key: 'access_saml_certificate', type: 'read' }, + { key: 'access_scim_log', type: 'read' }, + { key: 'access_service_token', type: 'read' }, + { key: 'access_ssh_auditing', type: 'read' }, + { key: 'access_tag', type: 'read' }, + { key: 'access_users', type: 'read' }, + { key: 'account_abuse_protection_pii', type: 'read' }, + { key: 'account_analytics', type: 'read' }, + { key: 'account_api_gateway', type: 'edit' }, + { key: 'account_api_tokens', type: 'read' }, + { key: 'account_custom_asset', type: 'edit' }, + { key: 'account_custom_error_rules', type: 'edit' }, + { key: 'account_custom_pages', type: 'edit' }, + { key: 'account_disable_esc', type: 'edit' }, + { key: 'account_dns_settings', type: 'edit' }, + { key: 'account_firewall_access_rules', type: 'edit' }, + { key: 'account_logs', type: 'edit' }, + { key: 'account_rule_lists', type: 'edit' }, + { key: 'account_rule_policies', type: 'edit' }, + { key: 'account_rulesets', type: 'edit' }, + { key: 'account_security_center_insights', type: 'edit' }, + { key: 'account_settings', type: 'edit' }, + { key: 'account_ssl_and_certificates', type: 'edit' }, + { key: 'account_waf', type: 'edit' }, + { key: 'account_waiting_rooms', type: 'read' }, + { key: 'address_maps', type: 'edit' }, + { key: 'agent-memory', type: 'edit' }, + { key: 'agw', type: 'edit' }, + { key: 'agw', type: 'run' }, + { key: 'ai', type: 'edit' }, + { key: 'ai_search', type: 'edit' }, + { key: 'ai_search', type: 'run' }, + { key: 'ai_search_index', type: 'edit' }, + { key: 'aiaudit', type: 'edit' }, + { key: 'aig', type: 'edit' }, + { key: 'aig', type: 'run' }, + { key: 'analytics', type: 'read' }, + { key: 'api_gateway', type: 'edit' }, + { key: 'apps', type: 'edit' }, + { key: 'argotunnel', type: 'read' }, + { key: 'artifacts', type: 'edit' }, + { key: 'billing', type: 'read' }, + { key: 'bot_management', type: 'edit' }, + { key: 'bot_management_feedback', type: 'edit' }, + { key: 'browser_rendering', type: 'edit' }, + { key: 'cache', type: 'purge' }, + { key: 'cache_settings', type: 'edit' }, + { key: 'calls', type: 'edit' }, + { key: 'casb', type: 'read' }, + { key: 'cf_agents', type: 'edit' }, + { key: 'challenge_widgets', type: 'edit' }, + { key: 'chinanetwork_steering', type: 'edit' }, + { key: 'cloud_connector', type: 'edit' }, + { key: 'cloud_email_security', type: 'edit' }, + { key: 'cloudchamber', type: 'edit' }, + { key: 'cloudforce_one', type: 'edit' }, + { key: 'config_settings', type: 'edit' }, + { key: 'connectivity_directory', type: 'edit' }, + { key: 'constellation', type: 'edit' }, + { key: 'containers', type: 'edit' }, + { key: 'custom_errors', type: 'edit' }, + { key: 'custom_pages', type: 'edit' }, + { key: 'd1', type: 'edit' }, + { key: 'd1_metadata', type: 'read' }, + { key: 'ddos_botnet_feed', type: 'edit' }, + { key: 'ddos_protection', type: 'edit' }, + { key: 'dls', type: 'read' }, + { key: 'dns', type: 'edit' }, + { key: 'dns_firewall', type: 'edit' }, + { key: 'dns_view', type: 'edit' }, + { key: 'domain_page_shield', type: 'edit' }, + { key: 'dynamic_redirect', type: 'edit' }, + { key: 'email_routing_address', type: 'edit' }, + { key: 'email_routing_rule', type: 'edit' }, + { key: 'email_routing_suppression', type: 'edit' }, + { key: 'email_security_dmarcreports', type: 'edit' }, + { key: 'email_sending', type: 'edit' }, + { key: 'fbm', type: 'edit' }, + { key: 'firewall_for_ai', type: 'edit' }, + { key: 'firewall_services', type: 'edit' }, + { key: 'flagship', type: 'edit' }, + { key: 'flagship', type: 'run' }, + { key: 'fraud_detection', type: 'edit' }, + { key: 'fraud_events', type: 'edit' }, + { key: 'granular_workers_scripts', type: 'read' }, + { key: 'healthcheck', type: 'edit' }, + { key: 'http_applications', type: 'edit' }, + { key: 'http_ddos_managed_ruleset', type: 'edit' }, + { key: 'images', type: 'edit' }, + { key: 'images_metadata', type: 'read' }, + { key: 'integration', type: 'edit' }, + { key: 'intel', type: 'edit' }, + { key: 'iot', type: 'edit' }, + { key: 'ip_prefix', type: 'edit' }, + { key: 'ip_prefix_bgp_on_demand', type: 'edit' }, + { key: 'l4_ddos_managed_ruleset', type: 'edit' }, + { key: 'load_balancers', type: 'edit' }, + { key: 'load_balancers_account', type: 'edit' }, + { key: 'load_balancing_monitors_and_pools', type: 'edit' }, + { key: 'logs', type: 'edit' }, + { key: 'magic_firewall', type: 'edit' }, + { key: 'magic_transit', type: 'edit' }, + { key: 'magic_wan', type: 'edit' }, + { key: 'managed_headers', type: 'edit' }, + { key: 'mass_url_redirects', type: 'edit' }, + { key: 'mcp_portals', type: 'edit' }, + { key: 'moq', type: 'edit' }, + { key: 'notifications', type: 'edit' }, + { key: 'oauth_client', type: 'read' }, + { key: 'origin', type: 'edit' }, + { key: 'page', type: 'edit' }, + { key: 'page_rules', type: 'edit' }, + { key: 'page_shield', type: 'edit' }, + { key: 'pcaps_api', type: 'edit' }, + { key: 'pipelines', type: 'edit' }, + { key: 'pipelines', type: 'send' }, + { key: 'pubsub', type: 'edit' }, + { key: 'query_cache', type: 'edit' }, + { key: 'queues', type: 'edit' }, + { key: 'queues_metadata', type: 'read' }, + { key: 'r2_catalog', type: 'edit' }, + { key: 'r2_catalog_sql', type: 'read' }, + { key: 'radar', type: 'read' }, + { key: 'rag', type: 'edit' }, + { key: 'rag', type: 'run' }, + { key: 'realtime', type: 'edit' }, + { key: 'registrar_domains', type: 'edit' }, + { key: 'reports_application_security_report', type: 'read' }, + { key: 'request_tracer', type: 'read' }, + { key: 'resource_library', type: 'edit' }, + { key: 'resource_sharing', type: 'read' }, + { key: 'response_compression', type: 'edit' }, + { key: 'sanitize', type: 'edit' }, + { key: 'secrets_store', type: 'edit' }, + { key: 'select_configuration', type: 'edit' }, + { key: 'snippets', type: 'edit' }, + { key: 'ssl_and_certificates', type: 'edit' }, + { key: 'sso_connector', type: 'read' }, + { key: 'stream', type: 'edit' }, + { key: 'stream_metadata', type: 'read' }, + { key: 'tag', type: 'edit' }, + { key: 'teams', type: 'read' }, + { key: 'teams_cds_compute_account', type: 'read' }, + { key: 'teams_connector_cloudflared', type: 'read' }, + { key: 'teams_connector_warp', type: 'read' }, + { key: 'teams_connectors', type: 'read' }, + { key: 'teams_dex', type: 'edit' }, + { key: 'teams_networks', type: 'read' }, + { key: 'teams_pii', type: 'read' }, + { key: 'teams_resilience', type: 'read' }, + { key: 'transform_rules', type: 'edit' }, + { key: 'trust_and_safety', type: 'edit' }, + { key: 'url_scanner', type: 'edit' }, + { key: 'vectorize', type: 'edit' }, + { key: 'waiting_rooms', type: 'edit' }, + { key: 'web3_hostnames', type: 'edit' }, + { key: 'websearch', type: 'run' }, + { key: 'workers_ci', type: 'edit' }, + { key: 'workers_kv_storage', type: 'edit' }, + { key: 'workers_kv_storage_metadata', type: 'read' }, + { key: 'workers_observability', type: 'edit' }, + { key: 'workers_observability_telemetry', type: 'edit' }, + { key: 'workers_r2', type: 'edit' }, + { key: 'workers_r2_bucket_item', type: 'edit' }, + { key: 'workers_r2_metadata', type: 'read' }, + { key: 'workers_routes', type: 'edit' }, + { key: 'workers_scripts', type: 'edit' }, + { key: 'workers_tail', type: 'read' }, + { key: 'workers_websearch', type: 'edit' }, + { key: 'zaraz', type: 'edit' }, + { key: 'zaraz_edit', type: 'edit' }, + { key: 'zone', type: 'edit' }, + { key: 'zone_access', type: 'read' }, + { key: 'zone_custom_asset', type: 'edit' }, + { key: 'zone_disable_esc', type: 'edit' }, + { key: 'zone_dns_settings', type: 'edit' }, + { key: 'zone_security_center_insights', type: 'edit' }, + { key: 'zone_settings', type: 'edit' }, + { key: 'zone_transform_rules', type: 'edit' }, + { key: 'zone_versioning', type: 'edit' }, + { key: 'zone_waf', type: 'edit' }, +]; + +// A read-only token keeps every readable resource but downgrades each 'edit' grant to +// 'read' and drops the action-only grants (run/send/purge), which have no read equivalent. +// Keys are de-duplicated so a resource that appears as both edit and run collapses to one +// read entry. +function toReadOnlyPermissions(permissions: CloudflarePermission[]): CloudflarePermission[] { + const keys = new Set(); + const result: CloudflarePermission[] = []; + for (const permission of permissions) { + if (permission.type !== 'read' && permission.type !== 'edit') continue; + if (keys.has(permission.key)) continue; + keys.add(permission.key); + result.push({ key: permission.key, type: 'read' }); + } + return result; +} + +export function buildCloudflareTokenUrl(options?: { + permissions?: CloudflarePermission[]; + name?: string; + readOnly?: boolean; +}): string { + const base = options?.permissions ?? NOMINAL_PERMISSIONS; + const permissions = options?.readOnly ? toReadOnlyPermissions(base) : base; + const name = options?.name ?? 'Polylane'; + const params = new URLSearchParams({ + to: '/:account/api-tokens', + permissionGroupKeys: JSON.stringify(permissions), + name, + }); + return `https://dash.cloudflare.com/?${params.toString()}`; +} diff --git a/src/commands/cloud/connect.ts b/src/commands/cloud/connect.ts index 244227f..b97fe8d 100644 --- a/src/commands/cloud/connect.ts +++ b/src/commands/cloud/connect.ts @@ -15,7 +15,9 @@ import { choiceStep, secretStep, SKIPPED, + type WizardStep, } from '../helpers'; +import { buildCloudflareTokenUrl } from './cloudflare-token-url'; import type { CloudAccount } from '../../generated/types'; import { CLIError } from '../../errors/base'; import { ExitCode } from '../../errors/codes'; @@ -27,6 +29,7 @@ import { note, promptSelectOrBack, promptConfirmOrBack, + promptPasswordOrBack, } from '../../utils/prompt'; type ConnectBody = Parameters[0]; @@ -202,6 +205,70 @@ async function browserConnect( return confirmBrowserConnect(config, check, `${label} to connect`); } +// Where to create the token when this terminal cannot open a browser. The +// pre-filled creation URL is ~13KB (the permission list rides in a query +// param), so it is never printed — the console's connect screen carries the +// same link, and the short dashboard URL covers doing it fully by hand. +const CLOUDFLARE_MANUAL_STEPS = + 'Create the token from any machine with a browser:\n' + + '- Easiest: open your Polylane console > Settings > Clouds > Connect >\n' + + ' Cloudflare — its create-token link opens Cloudflare with the read-only\n' + + ' token pre-filled. Create it as-is.\n' + + '- By hand: open https://dash.cloudflare.com/?to=/:account/api-tokens and\n' + + ' create a custom token with Read access for the account and its zones.\n' + + 'Then come back and paste the token here.'; + +const CLOUDFLARE_HEADLESS_HINT = + 'Create a read-only token from your Polylane console (Settings > Clouds > Connect > Cloudflare — the create-token link comes pre-filled), then re-run:\n' + + 'polylane cloud connect --provider cloudflare --token '; + +// Token creation happens in-flow: the CLI opens Cloudflare's account API +// token screen pre-filled with the read-only token, waits for the paste, and +// never routes through the docs site. secretStep is not reusable here because +// it prints its link everywhere it appears — this URL must only ever be +// handed to a browser. +function cloudflareTokenStep( + config: Config, + args: Record, + noBrowser: boolean, + set: (value: string) => void +): WizardStep { + return async () => { + const fromFlag = getArgString(args, 'token'); + if (fromFlag !== undefined) { + set(fromFlag); + return SKIPPED; + } + if (!isInteractive(config.nonInteractive)) { + throw new CLIError('Missing required flag: --token', ExitCode.USAGE, CLOUDFLARE_HEADLESS_HINT); + } + const ctx = { nonInteractive: config.nonInteractive }; + note( + 'Polylane connects to Cloudflare with a read-only account API token.\n' + + 'The CLI can open Cloudflare with the token pre-filled: create it as-is\n' + + '(nothing to edit) and paste it here.\n' + + 'You must be a Super Administrator on the Cloudflare account.', + 'Cloudflare API token' + ); + let openIt = !noBrowser; + if (!noBrowser) { + const answer = await promptConfirmOrBack(ctx, 'Open Cloudflare in your browser to create the token?', true); + if (answer === BACK) return BACK; + openIt = answer; + } + if (openIt) { + process.stderr.write('Opening your browser to create the token… paste it here when done.\n'); + openBrowser(buildCloudflareTokenUrl({ readOnly: true })); + } else { + note(CLOUDFLARE_MANUAL_STEPS, 'No browser on this machine'); + } + const value = await promptPasswordOrBack(ctx, 'Cloudflare API token (paste it here)'); + if (value === BACK) return BACK; + set(value); + return; + }; +} + // Each wizard step can go back to the previous one; backing out of the first // returns BACK to re-open provider selection. async function connectProvider( @@ -329,30 +396,16 @@ async function connectProvider( ...(subscribeToAlarms ? { subscribeToAlarms } : {}), }; } else if (provider === 'cloudflare') { - // Always read-only. The docs page offers two pre-filled tokens (read+write - // first, read-only second), so the copy has to name the read-only one by - // its button label: a token minted from the other link and pasted here - // would be stored under a read-only label it does not have, and every - // write for the account would then be refused with no way to re-enable it. - // Read-only is also what both console connect surfaces send. + // Always read-only: that is what both console connect surfaces send, and + // the pre-filled URL mints a token with every write downgraded to read. A + // broader token pasted here would be stored under a read-only label it + // does not have, and every write for the account would then be refused + // with no way to re-enable it. let token = ''; const ok = await runSteps([ - secretStep( - config, - args, - 'token', - '--token', - { - message: 'Cloudflare API token', - instructions: - 'On the docs page, use the "Create read-only token" link: it opens Cloudflare\'s account API token screen with a pre-filled, read-only token. Create it as-is and paste it here. You must be a Super Administrator on the account.', - link: 'https://docs.polylane.com/integrations/cloudflare', - linkLabel: 'Create the token (use the read-only link)', - }, - (v) => { - token = v; - } - ), + cloudflareTokenStep(config, args, noBrowser, (v) => { + token = v; + }), ]); if (!ok) return BACK; body = { workspaceId, provider: 'cloudflare', token, readOnly: true }; @@ -491,7 +544,7 @@ export const cloudConnectCommand: Command = { { flag: '--organization ', description: 'PlanetScale organization', type: 'string' }, // Render { flag: '--api-key ', description: 'Render API key', type: 'string' }, - { flag: '--no-browser', description: 'AWS / Vercel / PlanetScale / Supabase: print the URL instead of opening it', type: 'boolean' }, + { flag: '--no-browser', description: 'AWS / Vercel / PlanetScale / Supabase: print the URL instead of opening it; Cloudflare: show manual token-creation steps instead of opening the browser', type: 'boolean' }, { flag: '--reconnect', description: 'AWS / Vercel / PlanetScale / Supabase / Kubernetes: run the connect flow even when the provider is already connected', type: 'boolean' }, ], examples: [ diff --git a/test/cloudflare-token-url.test.ts b/test/cloudflare-token-url.test.ts new file mode 100644 index 0000000..39f2a0a --- /dev/null +++ b/test/cloudflare-token-url.test.ts @@ -0,0 +1,56 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildCloudflareTokenUrl } from '../src/commands/cloud/cloudflare-token-url'; + +type Permission = { key: string; type: string }; + +function parsePermissions(url: string): Permission[] { + const params = new URL(url).searchParams; + return JSON.parse(params.get('permissionGroupKeys') ?? '[]') as Permission[]; +} + +describe('buildCloudflareTokenUrl', () => { + it('deep-links into the account API token screen with the Polylane name', () => { + const url = buildCloudflareTokenUrl(); + const parsed = new URL(url); + assert.equal(parsed.origin, 'https://dash.cloudflare.com'); + assert.equal(parsed.searchParams.get('to'), '/:account/api-tokens'); + assert.equal(parsed.searchParams.get('name'), 'Polylane'); + }); + + it('read-only tokens carry only read permissions', () => { + const permissions = parsePermissions(buildCloudflareTokenUrl({ readOnly: true })); + assert.ok(permissions.length > 0); + for (const permission of permissions) { + assert.equal(permission.type, 'read'); + } + }); + + it('read-only downgrades edit grants instead of dropping them', () => { + const readOnly = parsePermissions(buildCloudflareTokenUrl({ readOnly: true })); + const full = parsePermissions(buildCloudflareTokenUrl()); + assert.ok(full.some((p) => p.key === 'dns' && p.type === 'edit')); + assert.ok(readOnly.some((p) => p.key === 'dns' && p.type === 'read')); + }); + + it('read-only drops action-only grants (run/send/purge) that have no read equivalent', () => { + const readOnly = parsePermissions(buildCloudflareTokenUrl({ readOnly: true })); + const full = parsePermissions(buildCloudflareTokenUrl()); + assert.ok(full.some((p) => p.key === 'cache' && p.type === 'purge')); + assert.ok(!readOnly.some((p) => p.key === 'cache')); + assert.ok(full.some((p) => p.key === 'websearch' && p.type === 'run')); + assert.ok(!readOnly.some((p) => p.key === 'websearch')); + }); + + it('read-only de-duplicates keys that appear with several grant types', () => { + const readOnly = parsePermissions(buildCloudflareTokenUrl({ readOnly: true })); + const keys = readOnly.map((p) => p.key); + assert.equal(new Set(keys).size, keys.length); + assert.equal(keys.filter((k) => k === 'ai_search').length, 1); + }); + + it('the URL is browser-only sized — callers must never print it to a terminal', () => { + const url = buildCloudflareTokenUrl({ readOnly: true }); + assert.ok(url.length > 2000); + }); +}); From 6fe3d085c6869ba0c4889f252b1c1ddb9b16b0d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 19:52:34 +0000 Subject: [PATCH 2/2] fix(cloud): show manual token steps if browser fails to open openBrowser is fire-and-forget and the pre-filled Cloudflare token URL must never be printed, so a silent spawn failure left the user at the paste prompt with no fallback. Always show the manual-steps note after the open attempt. Co-Authored-By: Claude --- src/commands/cloud/connect.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/commands/cloud/connect.ts b/src/commands/cloud/connect.ts index b97fe8d..0e9608c 100644 --- a/src/commands/cloud/connect.ts +++ b/src/commands/cloud/connect.ts @@ -259,6 +259,10 @@ function cloudflareTokenStep( if (openIt) { process.stderr.write('Opening your browser to create the token… paste it here when done.\n'); openBrowser(buildCloudflareTokenUrl({ readOnly: true })); + // openBrowser is best-effort and the pre-filled URL must never be + // printed, so a silent spawn failure would leave the paste prompt with + // no way forward — always show the manual fallback too. + note(CLOUDFLARE_MANUAL_STEPS, "If the browser didn't open"); } else { note(CLOUDFLARE_MANUAL_STEPS, 'No browser on this machine'); }