Name the push verification entry absolutely, from both sides
A JMAP push subscription stays silent until the client echoes back a verification code. When the code arrives with no tab open, the service worker leaves it in the cache for the next tab to collect. Both sides named that entry relatively, and a relative key is resolved against the URL of whoever is asking. The worker lives at <base>/sw.js, so it wrote under <base>/; a tab at /mail/inbox/abc looked under /mail/inbox/. They agreed only when the open page happened to be the root, which is why this survived: the case that works is the one people try first. The failure is quiet in the worst way. A subscription that never gets its code back simply never delivers, which is indistinguishable from push not working at all -- there is no error anywhere to notice. Both sides now build the key from the mount: the worker from the BASE it already derives from its own location, the page through withBase. Found while adding BASE_PATH, where the two disagree at every route rather than only at deep ones; left alone then because it was pre-existing and unrelated to that change.
This commit is contained in:
+12
-1
@@ -74,7 +74,18 @@ self.addEventListener("fetch", (event) => {
|
||||
* credentials), so it is stashed for a tab to collect and confirm.
|
||||
*/
|
||||
|
||||
const VERIFY_KEY = "ihasmail-push-verification";
|
||||
/*
|
||||
* Absolute, and anchored to the mount rather than to whatever page happens to
|
||||
* be open.
|
||||
*
|
||||
* A relative key is resolved against the URL of whoever is asking: the worker
|
||||
* lives at `<base>/sw.js`, so it stored this under `<base>/…`, while a tab at
|
||||
* `/mail/inbox/abc` looked for it under `/mail/inbox/…`. The two only ever
|
||||
* agreed when the open page was the root, so a verification code that arrived
|
||||
* with no tab open was written where the next tab would not look -- and the
|
||||
* subscription stayed silent, which is the same thing push failing looks like.
|
||||
*/
|
||||
const VERIFY_KEY = `${BASE}/ihasmail-push-verification`;
|
||||
|
||||
function textOf(email) {
|
||||
const from = email?.from?.[0];
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withBase, BASE_PATH } from "@/lib/basePath";
|
||||
|
||||
/**
|
||||
* The verification code a push subscription needs is written by the service
|
||||
* worker when no tab is open, and collected by the next tab to start. Both
|
||||
* sides have to name the same cache entry.
|
||||
*
|
||||
* A relative key does not do that. It is resolved against the URL of whoever
|
||||
* is asking: the worker lives at `<base>/sw.js`, so it wrote under `<base>/…`,
|
||||
* while a tab at `/mail/inbox/abc` looked under `/mail/inbox/…`. They agreed
|
||||
* only when the open page happened to be the root — and a subscription that
|
||||
* never gets its code back stays silent, which is indistinguishable from push
|
||||
* simply not working.
|
||||
*
|
||||
* These tests pin the shape of the key rather than the plumbing: what matters
|
||||
* is that it is absolute and anchored to the mount, so it cannot vary with the
|
||||
* route.
|
||||
*/
|
||||
|
||||
const KEY = "/ihasmail-push-verification";
|
||||
|
||||
describe("the push verification cache key", () => {
|
||||
it("is absolute, so it does not depend on which page is open", () => {
|
||||
expect(withBase(KEY).startsWith("/")).toBe(true);
|
||||
});
|
||||
|
||||
it("is the same string wherever it is asked for", () => {
|
||||
// The bug was that this was not true: the page and the worker each
|
||||
// resolved a relative key against their own URL.
|
||||
expect(withBase(KEY)).toBe(withBase(KEY));
|
||||
});
|
||||
|
||||
it("is anchored to the mount, which is what the worker anchors to", () => {
|
||||
// The worker builds `${BASE}/ihasmail-push-verification`, where BASE comes
|
||||
// from `new URL("./", self.location)` — the same mount this derives from.
|
||||
expect(withBase(KEY)).toBe(`${BASE_PATH}${KEY}`);
|
||||
});
|
||||
|
||||
it("carries no route in it", () => {
|
||||
for (const route of ["mail", "inbox", "calendar", "settings"]) {
|
||||
expect(withBase(KEY)).not.toContain(`/${route}/`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
* permission prompt, none of which exists under a test runner.
|
||||
*/
|
||||
import { CAP } from "@/jmap/client";
|
||||
import { withBase } from "./basePath";
|
||||
import { isDeviceTrusted } from "@/lib/storage";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useMail } from "@/store/mail";
|
||||
@@ -48,10 +49,13 @@ export function listenForVerification(): void {
|
||||
async function collectStoredVerification(): Promise<void> {
|
||||
try {
|
||||
const cache = await caches.open("ihasmail-v2");
|
||||
const hit = await cache.match("ihasmail-push-verification");
|
||||
// The same absolute key the worker writes. Relative would be resolved
|
||||
// against this document's URL, which is a different place on every route.
|
||||
const key = withBase("/ihasmail-push-verification");
|
||||
const hit = await cache.match(key);
|
||||
if (!hit) return;
|
||||
const { id, code } = (await hit.json()) as { id?: string; code?: string };
|
||||
await cache.delete("ihasmail-push-verification");
|
||||
await cache.delete(key);
|
||||
if (id && code) await verifySubscription(id, code);
|
||||
} catch {
|
||||
/* nothing waiting, or no cache: not a failure */
|
||||
|
||||
Reference in New Issue
Block a user