diff --git a/src/commands/cloud/cloudflare-token-url.ts b/src/commands/cloud/cloudflare-token-url.ts index ba830af..0adfd69 100644 --- a/src/commands/cloud/cloudflare-token-url.ts +++ b/src/commands/cloud/cloudflare-token-url.ts @@ -213,6 +213,10 @@ const NOMINAL_PERMISSIONS: CloudflarePermission[] = [ { key: 'zone_waf', type: 'edit' }, ]; +// Polylane creates its own webhook destination, notification policies, Workers Issues +// automation and observability alerts on connect, so a read-only token still edits these two. +const ALERT_SUBSCRIPTION_KEYS = new Set(['notifications', 'workers_observability']); + // 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 @@ -224,6 +228,10 @@ function toReadOnlyPermissions(permissions: CloudflarePermission[]): CloudflareP if (permission.type !== 'read' && permission.type !== 'edit') continue; if (keys.has(permission.key)) continue; keys.add(permission.key); + if (permission.type === 'edit' && ALERT_SUBSCRIPTION_KEYS.has(permission.key)) { + result.push(permission); + continue; + } result.push({ key: permission.key, type: 'read' }); } return result; diff --git a/src/commands/cloud/connect.ts b/src/commands/cloud/connect.ts index 846111e..9845481 100644 --- a/src/commands/cloud/connect.ts +++ b/src/commands/cloud/connect.ts @@ -60,7 +60,7 @@ type Provider = export const PROVIDER_OPTIONS: Array<{ value: Provider; label: string; hint: string }> = [ { value: 'aws', label: 'AWS', hint: 'deploys a read-only CloudFormation stack (browser)' }, { value: 'gcp', label: 'Google Cloud', hint: 'runs an Infrastructure Manager setup script (gcloud)' }, - { value: 'cloudflare', label: 'Cloudflare', hint: 'read-only API token' }, + { value: 'cloudflare', label: 'Cloudflare', hint: 'account API token' }, { value: 'vercel', label: 'Vercel', hint: 'install the Vercel integration (browser)' }, { value: 'fly', label: 'Fly.io', hint: 'API token' }, { value: 'render', label: 'Render', hint: 'API key' }, @@ -544,14 +544,15 @@ async function browserConnect( 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' + + ' Cloudflare — its create-token link opens Cloudflare with the\n' + + ' permissions 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' + + ' create a custom token with Read access for the account and its zones,\n' + + ' plus Edit on Notifications and Workers Observability.\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' + + 'Create a 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 @@ -576,7 +577,9 @@ function cloudflareTokenStep( } const ctx = { nonInteractive: config.nonInteractive }; note( - 'Polylane connects to Cloudflare with a read-only account API token.\n' + + 'Polylane connects to Cloudflare with an account API token that reads\n' + + 'your account and edits only Notifications and Workers Observability,\n' + + 'so Polylane can send Workers issues and alerts to itself.\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.', diff --git a/test/cloudflare-token-url.test.ts b/test/cloudflare-token-url.test.ts index 39f2a0a..1fd9225 100644 --- a/test/cloudflare-token-url.test.ts +++ b/test/cloudflare-token-url.test.ts @@ -18,12 +18,15 @@ describe('buildCloudflareTokenUrl', () => { assert.equal(parsed.searchParams.get('name'), 'Polylane'); }); - it('read-only tokens carry only read permissions', () => { + it('read-only tokens read everything except the alert subscription grants', () => { const permissions = parsePermissions(buildCloudflareTokenUrl({ readOnly: true })); assert.ok(permissions.length > 0); for (const permission of permissions) { - assert.equal(permission.type, 'read'); + const expected = permission.key === 'notifications' || permission.key === 'workers_observability' ? 'edit' : 'read'; + assert.equal(permission.type, expected, permission.key); } + assert.ok(permissions.some((p) => p.key === 'notifications' && p.type === 'edit')); + assert.ok(permissions.some((p) => p.key === 'workers_observability' && p.type === 'edit')); }); it('read-only downgrades edit grants instead of dropping them', () => {