1// The site's address. It is https://strudel.lmjtfy.fun (wrangler.json's 2// custom domain, SITE_HOST); it was the Worker's own workers.dev host until 3// 2026-10-05, and that host still reaches this Worker, which answers there 4// with nothing but the way to the new address: 5// 6// everything 301 (GET, HEAD) or 308 to the same path and query 7// on SITE_HOST 8// /sw.js a service worker that removes itself. A returning 9// visitor's browser holds the old site in its cache 10// and would never ask for a page, so never see the 11// redirect; it does ask for /sw.js, and a redirect 12// there is refused by browsers, which keeps the old 13// worker. This one unregisters, empties the caches 14// and reloads its tabs, which then meet the 301. 15// a signed-in page 302 through /jev/auth/moved on SITE_HOST with a 16// one-time token: the session cookie is this host's 17// only, and the account's passkeys were made here and 18// sign in here only (auth.ts), so without it an 19// account from before the move could never be 20// reached again. The token is taken there once, 21// within a minute, for a session on the new host, 22// and this host's session ends with it. The page 23// then asks for a passkey for the new address 24// (`passkeyHere` in /jev/auth/me). 25// 26// Nothing else is answered on the old host: no relay, no sign-in, no MCP. 27import { d1Accounts, type Accounts } from './accounts-store'; 28import type { Env } from './env'; 29import { newSessionToken, SESSION_TTL_MS, sessionCookie, sessionToken } from './session'; 30import config from '../wrangler.json'; 31 32export const SITE_HOST = config.routes[0].pattern; 33export const MOVED_PATH = '/jev/auth/moved'; 34export const HANDOFF_TTL_MS = 60 * 1000; 35 36// The host the site moved from: this Worker's workers.dev name, and any 37// preview of it. Dev (localhost) and the site's own host are neither. 38export const isOldHost = (hostname: string) => hostname.endsWith('.workers.dev'); 39 40const REMOVE_SERVICE_WORKER = `// jevstrudel moved to https://${SITE_HOST}/ 41self.addEventListener('install', () => self.skipWaiting()); 42self.addEventListener('activate', (event) => { 43 event.waitUntil( 44 (async () => { 45 await self.registration.unregister(); 46 for (const key of await caches.keys()) await caches.delete(key); 47 for (const client of await self.clients.matchAll({ type: 'window' })) client.navigate(client.url); 48 })(), 49 ); 50}); 51`; 52 53// A request to the old host: where it went. 54export async function moved(request: Request, env: Env, accounts: Accounts = d1Accounts(env.DB)): Promise<Response> { 55 const url = new URL(request.url); 56 const page = request.method === 'GET' || request.method === 'HEAD'; 57 if (page && url.pathname === '/sw.js') { 58 return new Response(REMOVE_SERVICE_WORKER, { 59 headers: { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' }, 60 }); 61 } 62 const there = `https://${SITE_HOST}`; 63 const token = page ? sessionToken(request) : null; 64 // only a navigation carries a session across: a page's own fetches would 65 // each spend a token nothing follows 66 if (token && request.headers.get('Sec-Fetch-Mode') === 'navigate') { 67 const user = await accounts.sessionUser(token); 68 if (user) { 69 const handoff = newSessionToken(); 70 await accounts.putHandoff(user.id, handoff, token, HANDOFF_TTL_MS); 71 const to = new URL(MOVED_PATH, there); 72 to.searchParams.set('token', handoff); 73 to.searchParams.set('to', url.pathname + url.search); 74 return new Response(null, { status: 302, headers: { Location: to.href, 'Cache-Control': 'no-store' } }); 75 } 76 } 77 return new Response(null, { 78 status: page ? 301 : 308, 79 headers: { Location: there + url.pathname + url.search }, 80 }); 81} 82 83// A path on this site, and nothing that leaves it: "/x", never "//host" or 84// "/\\host", which browsers read as another site. 85const sitePath = (to: string | null) => (to && /^\/(?![/\\])/.test(to) ? to : '/'); 86 87// GET /jev/auth/moved?token=…&to=…, on the site: the handoff taken for a 88// session here. A token that is unknown, used or expired signs nobody in; 89// either way the visitor lands on the page they asked for. 90export async function arrived(request: Request, env: Env, accounts: Accounts = d1Accounts(env.DB)): Promise<Response> { 91 const url = new URL(request.url); 92 const headers = new Headers({ Location: sitePath(url.searchParams.get('to')), 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }); 93 if (request.method !== 'GET') return new Response('GET only', { status: 405, headers: { Allow: 'GET' } }); 94 const handoff = url.searchParams.get('token'); 95 if (handoff && /^[A-Za-z0-9_-]{43}$/.test(handoff)) { 96 const session = newSessionToken(); 97 if (await accounts.takeHandoff(handoff, session, SESSION_TTL_MS)) headers.set('Set-Cookie', sessionCookie(session)); 98 } 99 return new Response(null, { status: 302, headers }); 100}