jevstrudel.git / worker / src / moved.ts
moved.tsannotatedmoved.tssource100 lines · 5.1 KB · raw
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}