diff --git a/packages/host/app/utils/auth-service-worker-registration.ts b/packages/host/app/utils/auth-service-worker-registration.ts index c35048c8a88..f2c3e5951a2 100644 --- a/packages/host/app/utils/auth-service-worker-registration.ts +++ b/packages/host/app/utils/auth-service-worker-registration.ts @@ -32,6 +32,23 @@ export async function registerAuthServiceWorker(): Promise { } }); + // Respond to on-miss token lookups from the SW. The SW asks here when it + // intercepts a GET to a known realm host but has no token in its in-memory + // map (SW activation race, post-upload window before per-realm sync lands, + // etc.). localStorage is the authoritative source of currently-valid + // tokens — the SW's map is a derived cache. + navigator.serviceWorker.addEventListener('message', (event) => { + if (!event.data || event.data.type !== 'request-realm-token') { + return; + } + let port = event.ports?.[0]; + if (!port) { + return; + } + let { realmURL, token } = resolveTokenForRequestURL(event.data.requestURL); + port.postMessage({ realmURL, token }); + }); + try { await navigator.serviceWorker.register('/auth-service-worker.js', { scope: '/', @@ -121,3 +138,33 @@ function readTokensFromStorage(): Record | undefined { } return undefined; } + +// Find the longest realm-URL prefix in localStorage that matches the given +// request URL. Returns `undefined` for both fields when nothing matches — +// the SW will then preserve its existing pass-through behavior for that +// request. +function resolveTokenForRequestURL(requestURL: string | undefined): { + realmURL?: string; + token?: string; +} { + if (!requestURL) { + return {}; + } + let tokens = readTokensFromStorage(); + if (!tokens) { + return {}; + } + let bestRealmURL: string | undefined; + for (let realmURL of Object.keys(tokens)) { + if ( + requestURL.startsWith(realmURL) && + (!bestRealmURL || realmURL.length > bestRealmURL.length) + ) { + bestRealmURL = realmURL; + } + } + if (!bestRealmURL) { + return {}; + } + return { realmURL: bestRealmURL, token: tokens[bestRealmURL] }; +} diff --git a/packages/host/public/auth-service-worker.js b/packages/host/public/auth-service-worker.js index 3649ec10643..03f4b293eb4 100644 --- a/packages/host/public/auth-service-worker.js +++ b/packages/host/public/auth-service-worker.js @@ -4,10 +4,31 @@ // headers. This service worker intercepts those requests and adds the JWT // Bearer token so that authenticated realm images load correctly. // -// Tokens are synced from the main thread via postMessage. +// Tokens are synced from the main thread via postMessage. If a request hits +// a known realm host but no token has been synced yet (SW activation race, +// localStorage write happening just before the SW message round-trip lands, +// etc.), the SW asks the controlling page for a token via MessageChannel and +// retries once before falling through. // Map of realm URL prefix → JWT token const realmTokens = new Map(); +// Set of origins (e.g. "https://app.boxel.ai") that we have ever seen a +// realm token for. Used to scope the on-miss MessageChannel fallback so we +// don't message the page on every cross-origin font / analytics request. +const realmHosts = new Set(); +// In-flight token requests, keyed by request URL, single-flight so a burst +// of tags doesn't trigger a burst of postMessages. +const inflightTokenRequests = new Map(); + +const TOKEN_REQUEST_TIMEOUT_MS = 200; + +function recordRealmHost(realmURL) { + try { + realmHosts.add(new URL(realmURL).origin); + } catch { + // ignore malformed input + } +} self.addEventListener('install', () => { // Activate immediately, don't wait for existing clients to close @@ -29,6 +50,7 @@ self.addEventListener('message', (event) => { case 'set-realm-token': if (data.realmURL && data.token) { realmTokens.set(data.realmURL, data.token); + recordRealmHost(data.realmURL); } break; case 'remove-realm-token': @@ -38,6 +60,9 @@ self.addEventListener('message', (event) => { break; case 'clear-tokens': realmTokens.clear(); + // Keep realmHosts: clearing tokens (e.g. logout) doesn't change which + // hosts are "realm hosts," and keeping the set means the on-miss + // fallback still asks the page after re-login. break; case 'sync-tokens': // Bulk sync: data.tokens is a {realmURL: token} object @@ -46,6 +71,7 @@ self.addEventListener('message', (event) => { for (let [realmURL, token] of Object.entries(data.tokens)) { if (token) { realmTokens.set(realmURL, token); + recordRealmHost(realmURL); } } } @@ -53,24 +79,7 @@ self.addEventListener('message', (event) => { } }); -self.addEventListener('fetch', (event) => { - let request = event.request; - - // Only inject auth for GET and HEAD requests (resource loading). - // Other methods (POST, PUT, DELETE, etc.) are handled by the app's - // fetch middleware which already adds Authorization headers. - if (request.method !== 'GET' && request.method !== 'HEAD') { - return; - } - - // Don't inject if request already has an Authorization header - if (request.headers.get('Authorization')) { - return; - } - - let url = request.url; - - // Find the matching realm token with longest-prefix match +function lookupToken(url) { let matchedRealmURL = null; let matchedToken = null; for (let [realmURL, token] of realmTokens) { @@ -81,31 +90,142 @@ self.addEventListener('fetch', (event) => { } } } + return matchedToken; +} - if (!matchedToken) { - // Not a realm URL or no token available — pass through unchanged - return; +async function pickClientToAsk(initiatingClientId) { + // Prefer the client that initiated the fetch. With skipWaiting() + + // clients.claim() multiple tabs can be controlled by this SW where + // some still run an older bundle without the request-realm-token + // listener; if we always ask the "first" window we can hang waiting + // for a client that cannot answer. + if (initiatingClientId) { + try { + let initiating = await self.clients.get(initiatingClientId); + if (initiating && initiating.type === 'window') { + return initiating; + } + } catch { + // ignore and fall through to broadcast + } } + let clientList = await self.clients.matchAll({ type: 'window' }); + return clientList[0]; +} - // Create a new request with the Authorization header injected. - // +async function requestTokenFromClient(requestURL, initiatingClientId) { + // Single-flight per request URL + let existing = inflightTokenRequests.get(requestURL); + if (existing) { + return existing; + } + let promise = (async () => { + let client = await pickClientToAsk(initiatingClientId); + if (!client) { + return undefined; + } + return new Promise((resolve) => { + let channel = new MessageChannel(); + let settled = false; + let timer = setTimeout(() => { + if (settled) return; + settled = true; + resolve(undefined); + }, TOKEN_REQUEST_TIMEOUT_MS); + channel.port1.onmessage = (event) => { + if (settled) return; + settled = true; + clearTimeout(timer); + let reply = event.data; + if (reply && reply.realmURL && reply.token) { + realmTokens.set(reply.realmURL, reply.token); + recordRealmHost(reply.realmURL); + resolve(reply.token); + } else { + resolve(undefined); + } + }; + client.postMessage({ type: 'request-realm-token', requestURL }, [ + channel.port2, + ]); + }); + })(); + inflightTokenRequests.set(requestURL, promise); + promise.finally(() => { + inflightTokenRequests.delete(requestURL); + }); + return promise; +} + +function buildAuthedRequest(request, token) { // Cross-origin and CSS background-image requests arrive with // mode: 'no-cors', which silently strips non-safelisted headers like // Authorization. We must upgrade to mode: 'cors' so the header is - // actually sent. The realm server already supports CORS with + // actually sent. The realm server supports CORS with // Access-Control-Allow-Origin: * and Authorization in allowed headers. - let headers = new Headers(request.headers); - headers.set('Authorization', `Bearer ${matchedToken}`); - + // // credentials must be explicitly set to 'same-origin' because cross-origin // requests default to 'include', and credentials: 'include' with // mode: 'cors' requires the server to send a specific origin in // Access-Control-Allow-Origin (not '*'), which the realm server doesn't do. - let authedRequest = new Request(request, { + let headers = new Headers(request.headers); + headers.set('Authorization', `Bearer ${token}`); + return new Request(request, { headers, mode: 'cors', credentials: 'same-origin', }); +} + +self.addEventListener('fetch', (event) => { + let request = event.request; - event.respondWith(fetch(authedRequest)); + // Only inject auth for GET and HEAD requests (resource loading). + // Other methods (POST, PUT, DELETE, etc.) are handled by the app's + // fetch middleware which already adds Authorization headers. + if (request.method !== 'GET' && request.method !== 'HEAD') { + return; + } + + // Don't inject if request already has an Authorization header + if (request.headers.get('Authorization')) { + return; + } + + let url = request.url; + let matchedToken = lookupToken(url); + + if (matchedToken) { + event.respondWith(fetch(buildAuthedRequest(request, matchedToken))); + return; + } + + // No token in the map. Attempt the on-miss client fallback when either + // (a) the SW has not yet learned any realm hosts (cold-start: SW just + // activated and the page hasn't synced yet — exactly when we want the + // fallback to recover from a stale empty cache), or (b) the request + // origin matches a host we have ever held a token for. Skip the + // fallback for clearly-unrelated cross-origin assets once realmHosts + // is populated. + let requestOrigin; + try { + requestOrigin = new URL(url).origin; + } catch { + return; + } + if (realmHosts.size > 0 && !realmHosts.has(requestOrigin)) { + return; + } + + event.respondWith( + (async () => { + let token = await requestTokenFromClient(url, event.clientId); + if (token) { + return fetch(buildAuthedRequest(request, token)); + } + // No token available; preserve existing behavior (let it pass through + // and 401, rather than synthesizing a response). + return fetch(request); + })(), + ); }); diff --git a/packages/host/tests/unit/auth-service-worker-test.ts b/packages/host/tests/unit/auth-service-worker-test.ts index 07aa227f760..f76a7ac9320 100644 --- a/packages/host/tests/unit/auth-service-worker-test.ts +++ b/packages/host/tests/unit/auth-service-worker-test.ts @@ -3,11 +3,35 @@ import { module, test } from 'qunit'; // Test the auth service worker's fetch interception logic by simulating // the SW environment. The actual SW is at public/auth-service-worker.js. // -// We duplicate the core logic here (token matching, fetch interception) -// to test it in a standard QUnit context where service workers aren't available. - -function createServiceWorkerEnv() { +// We duplicate the core logic here (token matching, fetch interception, +// on-miss client fallback) to test it in a standard QUnit context where +// service workers aren't available. + +function createServiceWorkerEnv( + opts: { + // Simulates the response the controlling client would send when the SW + // requests a token via MessageChannel. Returns `undefined` to indicate + // the page has no token for that request URL. + clientTokenLookup?: ( + requestURL: string, + ) => Promise<{ realmURL: string; token: string } | undefined>; + // Mirrors the SW's TOKEN_REQUEST_TIMEOUT_MS. When `clientTokenLookup` + // does not settle within this timeout, the scaffold resolves to + // `undefined` just like the real SW would. + tokenRequestTimeoutMs?: number; + } = {}, +) { const realmTokens = new Map(); + const realmHosts = new Set(); + const inflightTokenRequests = new Map>(); + + function recordRealmHost(realmURL: string) { + try { + realmHosts.add(new URL(realmURL).origin); + } catch { + /* ignore */ + } + } let processMessage = (data: any) => { if (!data || !data.type) return; @@ -15,6 +39,7 @@ function createServiceWorkerEnv() { case 'set-realm-token': if (data.realmURL && data.token) { realmTokens.set(data.realmURL, data.token); + recordRealmHost(data.realmURL); } break; case 'remove-realm-token': @@ -31,6 +56,7 @@ function createServiceWorkerEnv() { for (let [realmURL, token] of Object.entries(data.tokens)) { if (token) { realmTokens.set(realmURL, token as string); + recordRealmHost(realmURL); } } } @@ -38,39 +64,102 @@ function createServiceWorkerEnv() { } }; - let processFetch = (request: Request): Request | null => { + function lookupToken(url: string): string | undefined { + let bestRealmURL: string | undefined; + let bestToken: string | undefined; + for (let [realmURL, token] of realmTokens) { + if (url.startsWith(realmURL)) { + if (!bestRealmURL || realmURL.length > bestRealmURL.length) { + bestRealmURL = realmURL; + bestToken = token; + } + } + } + return bestToken; + } + + async function requestTokenFromClient( + requestURL: string, + ): Promise { + let existing = inflightTokenRequests.get(requestURL); + if (existing) return existing; + let promise = (async () => { + if (!opts.clientTokenLookup) return undefined; + let lookup = opts.clientTokenLookup(requestURL); + let reply: { realmURL: string; token: string } | undefined; + if (typeof opts.tokenRequestTimeoutMs === 'number') { + reply = await Promise.race([ + lookup, + new Promise((resolve) => + setTimeout(() => resolve(undefined), opts.tokenRequestTimeoutMs), + ), + ]); + } else { + reply = await lookup; + } + if (reply && reply.realmURL && reply.token) { + realmTokens.set(reply.realmURL, reply.token); + recordRealmHost(reply.realmURL); + return reply.token; + } + return undefined; + })(); + inflightTokenRequests.set(requestURL, promise); + promise.finally(() => inflightTokenRequests.delete(requestURL)); + return promise; + } + + function buildAuthedRequest(request: Request, token: string): Request { + let headers = new Headers(request.headers); + headers.set('Authorization', `Bearer ${token}`); + return new Request(request, { headers, mode: 'cors' }); + } + + // Returns: + // - Request: the SW would respondWith fetch of this authed Request + // - 'pass-through': the SW would not intercept (returns from fetch handler) + // - 'fallthrough-fetch': the SW called respondWith but with the original + // request (client had no token); will hit the network unauth'd + let processFetch = async ( + request: Request, + ): Promise => { if (request.method !== 'GET' && request.method !== 'HEAD') { - return null; // pass through + return 'pass-through'; } if (request.headers.get('Authorization')) { - return null; // pass through + return 'pass-through'; } let url = request.url; - let matchedRealmURL: string | null = null; - let matchedToken: string | null = null; - for (let [realmURL, token] of realmTokens) { - if (url.startsWith(realmURL)) { - if (!matchedRealmURL || realmURL.length > matchedRealmURL.length) { - matchedRealmURL = realmURL; - matchedToken = token; - } - } + let matchedToken = lookupToken(url); + if (matchedToken) { + return buildAuthedRequest(request, matchedToken); } - if (!matchedToken) { - return null; // pass through + let origin: string; + try { + origin = new URL(url).origin; + } catch { + return 'pass-through'; + } + if (realmHosts.size > 0 && !realmHosts.has(origin)) { + return 'pass-through'; } - // Mirror the actual SW: construct from original request, override headers - // and upgrade mode to 'cors' (cross-origin arrives as 'no-cors' - // which would strip the Authorization header). - let headers = new Headers(request.headers); - headers.set('Authorization', `Bearer ${matchedToken}`); - return new Request(request, { headers, mode: 'cors' }); + let token = await requestTokenFromClient(url); + if (token) { + return buildAuthedRequest(request, token); + } + return 'fallthrough-fetch'; }; - return { processMessage, processFetch, realmTokens }; + return { + processMessage, + processFetch, + realmTokens, + realmHosts, + inflightTokenRequests, + }; } module('Unit | auth-service-worker', function () { @@ -165,10 +254,26 @@ module('Unit | auth-service-worker', function () { sw.processMessage({ realmURL: 'http://example.com/', token: 'x' }); assert.strictEqual(sw.realmTokens.size, 0); }); + + test('realmHosts is populated on token sync', function (assert) { + let sw = createServiceWorkerEnv(); + sw.processMessage({ + type: 'set-realm-token', + realmURL: 'http://localhost:4201/user/realm/', + token: 't', + }); + assert.true(sw.realmHosts.has('http://localhost:4201')); + + sw.processMessage({ + type: 'sync-tokens', + tokens: { 'https://app.boxel.ai/user/realm/': 't2' }, + }); + assert.true(sw.realmHosts.has('https://app.boxel.ai')); + }); }); module('fetch interception', function () { - test('injects Authorization header for matching realm URL', function (assert) { + test('injects Authorization header for matching realm URL', async function (assert) { let sw = createServiceWorkerEnv(); sw.processMessage({ @@ -180,17 +285,22 @@ module('Unit | auth-service-worker', function () { let request = new Request( 'http://localhost:4201/user/realm/images/photo.png', ); - let result = sw.processFetch(request); + let result = await sw.processFetch(request); - assert.ok(result, 'request was intercepted'); + assert.ok(result instanceof Request, 'request was intercepted'); assert.strictEqual( - result!.headers.get('Authorization'), + (result as Request).headers.get('Authorization'), 'Bearer my-jwt-token', ); }); - test('passes through requests to non-realm URLs', function (assert) { - let sw = createServiceWorkerEnv(); + test('passes through requests to non-realm hosts (no message round-trip)', async function (assert) { + let sw = createServiceWorkerEnv({ + clientTokenLookup: async () => { + assert.notOk(true, 'should not ask the client for unknown hosts'); + return undefined; + }, + }); sw.processMessage({ type: 'set-realm-token', @@ -199,12 +309,12 @@ module('Unit | auth-service-worker', function () { }); let request = new Request('https://cdn.example.com/image.png'); - let result = sw.processFetch(request); + let result = await sw.processFetch(request); - assert.strictEqual(result, null, 'request was not intercepted'); + assert.strictEqual(result, 'pass-through'); }); - test('passes through POST requests even for realm URLs', function (assert) { + test('passes through POST requests even for realm URLs', async function (assert) { let sw = createServiceWorkerEnv(); sw.processMessage({ @@ -216,12 +326,12 @@ module('Unit | auth-service-worker', function () { let request = new Request('http://localhost:4201/user/realm/card.json', { method: 'POST', }); - let result = sw.processFetch(request); + let result = await sw.processFetch(request); - assert.strictEqual(result, null, 'POST request was not intercepted'); + assert.strictEqual(result, 'pass-through'); }); - test('passes through requests that already have Authorization header', function (assert) { + test('passes through requests that already have Authorization header', async function (assert) { let sw = createServiceWorkerEnv(); sw.processMessage({ @@ -233,16 +343,12 @@ module('Unit | auth-service-worker', function () { let request = new Request('http://localhost:4201/user/realm/card.json', { headers: { Authorization: 'Bearer existing-token' }, }); - let result = sw.processFetch(request); + let result = await sw.processFetch(request); - assert.strictEqual( - result, - null, - 'request with existing auth was not intercepted', - ); + assert.strictEqual(result, 'pass-through'); }); - test('uses longest-prefix match when multiple realms match', function (assert) { + test('uses longest-prefix match when multiple realms match', async function (assert) { let sw = createServiceWorkerEnv(); sw.processMessage({ @@ -259,17 +365,16 @@ module('Unit | auth-service-worker', function () { let request = new Request( 'http://localhost:4201/user/realm/images/photo.png', ); - let result = sw.processFetch(request); + let result = await sw.processFetch(request); - assert.ok(result, 'request was intercepted'); + assert.ok(result instanceof Request); assert.strictEqual( - result!.headers.get('Authorization'), + (result as Request).headers.get('Authorization'), 'Bearer realm-specific-token', - 'used the more specific realm token', ); }); - test('intercepts HEAD requests', function (assert) { + test('intercepts HEAD requests', async function (assert) { let sw = createServiceWorkerEnv(); sw.processMessage({ @@ -282,16 +387,16 @@ module('Unit | auth-service-worker', function () { 'http://localhost:4201/user/realm/images/photo.png', { method: 'HEAD' }, ); - let result = sw.processFetch(request); + let result = await sw.processFetch(request); - assert.ok(result, 'HEAD request was intercepted'); + assert.ok(result instanceof Request); assert.strictEqual( - result!.headers.get('Authorization'), + (result as Request).headers.get('Authorization'), 'Bearer my-jwt-token', ); }); - test('upgrades request mode to cors for intercepted requests', function (assert) { + test('upgrades request mode to cors for intercepted requests', async function (assert) { let sw = createServiceWorkerEnv(); sw.processMessage({ @@ -300,29 +405,177 @@ module('Unit | auth-service-worker', function () { token: 'my-jwt-token', }); - // Cross-origin elements arrive with mode: 'no-cors', which would - // silently strip the Authorization header. The SW must upgrade to 'cors'. let request = new Request( 'http://localhost:4201/user/realm/images/photo.png', { mode: 'no-cors' }, ); - let result = sw.processFetch(request); + let result = await sw.processFetch(request); - assert.ok(result, 'request was intercepted'); - assert.strictEqual(result!.mode, 'cors', 'mode was upgraded to cors'); + assert.ok(result instanceof Request); + assert.strictEqual((result as Request).mode, 'cors'); assert.strictEqual( - result!.headers.get('Authorization'), + (result as Request).headers.get('Authorization'), 'Bearer my-jwt-token', ); }); - test('returns null when no tokens are set', function (assert) { + test('falls through (does not pass-through) at cold start with no client available', async function (assert) { + // realmHosts is empty so the SW does not know which origins are realm + // hosts and must try the on-miss client lookup. With no client and no + // token, the SW lands in the unauthed-refetch path rather than + // skipping interception entirely. let sw = createServiceWorkerEnv(); let request = new Request('http://localhost:4201/user/realm/image.png'); - let result = sw.processFetch(request); + let result = await sw.processFetch(request); + + assert.strictEqual(result, 'fallthrough-fetch'); + }); + }); + + module('on-miss client fallback', function () { + test('asks the client for a token when the host is known but no token matches', async function (assert) { + let askCount = 0; + let sw = createServiceWorkerEnv({ + clientTokenLookup: async (requestURL) => { + askCount += 1; + assert.strictEqual( + requestURL, + 'http://localhost:4201/other-realm/image.png', + ); + return { + realmURL: 'http://localhost:4201/other-realm/', + token: 'late-arriving-token', + }; + }, + }); + + // Seed realmHosts via a prior token for a different realm on the same host. + sw.processMessage({ + type: 'set-realm-token', + realmURL: 'http://localhost:4201/user/realm/', + token: 'existing-token', + }); + + let request = new Request('http://localhost:4201/other-realm/image.png'); + let result = await sw.processFetch(request); + + assert.strictEqual(askCount, 1, 'client was asked exactly once'); + assert.ok(result instanceof Request, 'request was retried with auth'); + assert.strictEqual( + (result as Request).headers.get('Authorization'), + 'Bearer late-arriving-token', + ); + // Token is now cached in the SW for next time. + assert.strictEqual( + sw.realmTokens.get('http://localhost:4201/other-realm/'), + 'late-arriving-token', + ); + }); - assert.strictEqual(result, null, 'no interception without tokens'); + test('single-flights concurrent miss requests for the same URL', async function (assert) { + let askCount = 0; + let release: () => void; + let releaseSignal = new Promise((resolve) => { + release = resolve; + }); + let sw = createServiceWorkerEnv({ + clientTokenLookup: async () => { + askCount += 1; + await releaseSignal; + return { + realmURL: 'http://localhost:4201/r/', + token: 'tok', + }; + }, + }); + sw.processMessage({ + type: 'set-realm-token', + realmURL: 'http://localhost:4201/seed/', + token: 'seed', + }); + + // Fire two concurrent requests for the same URL before the first + // ask resolves. + let p1 = sw.processFetch( + new Request('http://localhost:4201/r/image.png'), + ); + let p2 = sw.processFetch( + new Request('http://localhost:4201/r/image.png'), + ); + + // The client should only have been asked once even though two requests + // are in flight. + release!(); + await Promise.all([p1, p2]); + assert.strictEqual(askCount, 1, 'client asked exactly once'); + }); + + test('falls through to unauthed fetch when client has no token', async function (assert) { + let sw = createServiceWorkerEnv({ + clientTokenLookup: async () => undefined, + }); + sw.processMessage({ + type: 'set-realm-token', + realmURL: 'http://localhost:4201/seed/', + token: 'seed', + }); + + let result = await sw.processFetch( + new Request('http://localhost:4201/unknown/image.png'), + ); + assert.strictEqual(result, 'fallthrough-fetch'); + }); + + test('asks the client at cold start when realmHosts is empty', async function (assert) { + // SW just activated, page has not synced yet: realmHosts is empty. + // The page may still hold valid tokens in localStorage, so the SW + // must reach out instead of silently passing through. + let sw = createServiceWorkerEnv({ + clientTokenLookup: async (requestURL) => { + assert.strictEqual( + requestURL, + 'http://localhost:4201/realm/image.png', + ); + return { + realmURL: 'http://localhost:4201/realm/', + token: 'late-synced-token', + }; + }, + }); + + let result = await sw.processFetch( + new Request('http://localhost:4201/realm/image.png'), + ); + + assert.ok(result instanceof Request); + assert.strictEqual( + (result as Request).headers.get('Authorization'), + 'Bearer late-synced-token', + ); + assert.true( + sw.realmHosts.has('http://localhost:4201'), + 'realmHosts is populated after the cold-start lookup', + ); + }); + + test('times out and falls through when the client never replies', async function (assert) { + // Simulates an old controlled tab that has no request-realm-token + // listener installed. The SW must not hang waiting for it. + let sw = createServiceWorkerEnv({ + tokenRequestTimeoutMs: 10, + clientTokenLookup: () => new Promise(() => {}), + }); + sw.processMessage({ + type: 'set-realm-token', + realmURL: 'http://localhost:4201/seed/', + token: 'seed', + }); + + let result = await sw.processFetch( + new Request('http://localhost:4201/r/image.png'), + ); + assert.strictEqual(result, 'fallthrough-fetch'); }); }); });