From 6c58fcd9cd9ebc8283f685b1573a3af575d0b0ea Mon Sep 17 00:00:00 2001 From: Andrew Miller Date: Sun, 3 May 2026 14:25:52 -0400 Subject: [PATCH] extension: add fcm/autopush host_permissions so VAPID push fetch isn't CORS-blocked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Send test push" button failed with a generic "Failed to fetch" because the SW's fetch to https://fcm.googleapis.com/... was getting CORS-blocked. The manifest grants *.google.com but NOT *.googleapis.com, and FCM lives at fcm.googleapis.com. Adds the three major web-push service hosts to host_permissions: - https://fcm.googleapis.com/* (Chrome / Edge) - https://updates.push.services.mozilla.com/* (Firefox) - https://web.push.apple.com/* (Safari) Also improves error reporting in sendWebPush: - On fetch throw: surfaces the message + a hint about host_permission - On non-2xx: includes the status code AND truncated response body so the dashboard shows what FCM actually said instead of just "failed" Adds a Playwright regression test that injects a fake FCM endpoint and clicks "Send test push", then asserts the dashboard does NOT show "fetch failed" / "Failed to fetch" — only a real push-service status. Reload via Load unpacked (full remove + re-add) since host_permissions changed. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/lib.js | 23 +++++++++++++++-------- extension/manifest.json | 3 +++ test/extension-only.spec.ts | 25 +++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/extension/lib.js b/extension/lib.js index 94447a7..f2b5ddc 100644 --- a/extension/lib.js +++ b/extension/lib.js @@ -111,14 +111,21 @@ async function vapidJwt(privateJwk, audience, sub) { export async function sendWebPush(subscription, vapid, ttl = 60) { const audience = new URL(subscription.endpoint).origin; const jwt = await vapidJwt(vapid.privateJwk, audience); - const r = await fetch(subscription.endpoint, { - method: "POST", - headers: { - "Authorization": `vapid t=${jwt}, k=${vapid.publicB64}`, - "TTL": String(ttl), - }, - }); - return { ok: r.ok, status: r.status }; + let r; + try { + r = await fetch(subscription.endpoint, { + method: "POST", + headers: { + "Authorization": `vapid t=${jwt}, k=${vapid.publicB64}`, + "TTL": String(ttl), + }, + }); + } catch (e) { + return { ok: false, error: `fetch failed: ${e.message || e} (host_permission missing for ${audience}?)` }; + } + if (r.ok) return { ok: true, status: r.status }; + const body = await r.text().catch(() => ""); + return { ok: false, status: r.status, error: `push service ${r.status}: ${body.slice(0, 200) || r.statusText}` }; } // Polls an ntfy topic once. Returns parsed JSON body (the subscription diff --git a/extension/manifest.json b/extension/manifest.json index 6a55297..3a42adc 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -12,6 +12,9 @@ "host_permissions": [ "https://*.youtube.com/*", "https://*.google.com/*", + "https://fcm.googleapis.com/*", + "https://updates.push.services.mozilla.com/*", + "https://web.push.apple.com/*", "http://localhost/*", "http://127.0.0.1/*" ], diff --git a/test/extension-only.spec.ts b/test/extension-only.spec.ts index fbcb780..02cb2da 100644 --- a/test/extension-only.spec.ts +++ b/test/extension-only.spec.ts @@ -137,6 +137,31 @@ test("share-to-phone: ntfy round-trip — extension picks up a posted subscripti await context.close(); }); +test("share-to-phone: test-phone-push reaches FCM (regression for missing fcm.googleapis.com host permission)", async () => { + const { context, extensionId } = await bootContext(); + const dash = await context.newPage(); + await dash.goto(`chrome-extension://${extensionId}/dashboard.html`); + await dash.evaluate(() => chrome.storage.local.set({ + phoneSub: { + // Real FCM origin so the SW's fetch hits the actual endpoint. Path is + // bogus so FCM will return 404 — that's fine; we only care that the + // request *reaches* FCM (no CORS / host_permission failure). + endpoint: "https://fcm.googleapis.com/fcm/send/host-perm-regression-fake-id", + keys: { p256dh: "BHello-test-p256dh-key", auth: "test-auth-secret" }, + }, + })); + await dash.reload(); + await expect(dash.locator("#phoneStatus")).toContainText("phone subscribed", { timeout: 10_000 }); + await dash.click("#testPhone"); + await expect(dash.locator("#phoneStatus")).not.toHaveText("sending…", { timeout: 15_000 }); + const status = await dash.locator("#phoneStatus").innerText(); + // Acceptable: either ✓ sent (impossible with fake) or ✗ push service <2xx-4xx>. + // Forbidden: ✗ fetch failed (means CORS / host_permission missing). + expect(status, "must reach FCM, not CORS-block").not.toMatch(/fetch failed|Failed to fetch/i); + expect(status, "should report a real push-service status").toMatch(/sent|push service \d{3}/); + await context.close(); +}); + test("share-to-phone: paired state shows test/forget; forget clears storage", async () => { const { context, extensionId } = await bootContext(); const dash = await context.newPage();