diff --git a/apps/web/README.md b/apps/web/README.md index 96a6ba76e..5a430171a 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -33,7 +33,7 @@ All data lives on the AT Protocol. Public profiles are ordinary atproto records; - A mix can also link a set a manager published with locallibrary (`locallibrary sets publish`), picked from a list on the edit form. The profile shows when and where it was played, and says so if the set changed after it was linked. - Sets with a recording play on the site, with the tracklist following along and a track's start to jump to. The server picks what plays when someone presses Play (`/sets///player.json`): the set's `recording` record first (an `fm.plyr.track`, whose file comes from plyr.fm's CDN, or plyr's own player when the track is gated; or a `computer.localnetwork.dj.recording` on the DJ's PDS, streamed through `/media/audio//`), then its links (archive.org downloads play directly, SoundCloud, Mixcloud, YouTube and plyr.fm in their own players). Audio the page plays itself always wins over a platform's player. SoundCloud's and Mixcloud's widget APIs let the tracklist follow those players too. `/sets///chapters.json` serves the tracklist as Podcasting 2.0 JSON chapters. - Private booking details (email, phone, rates, tech rider, hospitality) visible only to signed-in members. -- The project form and the booking inquiry keep unsent changes as a draft in the browser (`localStorage`, per account), so a reload or a trip to another page doesn't lose them. Files have to be picked again, and the draft goes once the form is sent, discarded, or back to its saved values. +- The project form and the booking inquiry keep unsent changes as a draft in the browser (`localStorage`, per account), so a reload or a trip to another page doesn't lose them. Files have to be picked again, and the draft goes once the form is sent, discarded, or back to its saved values, or its account signs out (the sign-out response sets an `ln_signed_out` cookie naming the account, and the next page load drops its drafts, so it works even if Sign out was pressed before the page's scripts ran). - A project page shows how its DJs play: the playing stats and recent sets each manager publishes from locallibrary (`computer.localnetwork.dj.profile` and `dj.set`), read from HappyView when the page loads and streamed in, so a slow answer never holds the page up. The app stores none of it; a few minutes' cache in memory keeps busy pages from asking again. - Press kits as records, which belong to the project, so all its managers share control of them. A project has up to 20 (`/dashboard/projects//press-kits/new`), one per kind of booking. Each is a public `computer.localnetwork.epk` record in the root account's repo, holding the mixes and photos shown to everyone, plus a space of its own under the root holding the rest: a full bio, on-request mixes and photos, press quotes, contact and rider. Managers edit at `/dashboard/projects//press-kits/` through drafts kept in the project's backstage, a space under the root that only its current managers read and write, so each sees the others' work in progress and whether it's behind the published version. A draft saves only over the published version its form was opened on (otherwise the manager is asked to reload). Publishing a draft applies only the fields it changed, shows each field another manager published since next to the version the draft started from and lets the manager pick which of theirs to publish, and copies its photos into the root's repo so they outlive a manager leaving. People ask for access from `/projects//press-kits/`, with the form at `/dashboard/access//`; the rules approve them at once, send them for review, or don't take requests, per group (people they've booked with, people any of the managers follows on Bluesky, promoters, anyone signed in), with seven presets from open to closed. Any manager answers requests and adds or removes people at `/dashboard/projects//press-kits//access`; the root writes each grant record and adds the person to the space. The editor has a press-photo checklist (3 or more photos, every orientation, 3,000 px, credited). - Press kits made before the root held them stay in their maker's repo and space until the maker next opens the dashboard, which moves each to the root with the same key: photos, private part, grants and open requests first, the public record last, then the old record and space go with the maker's session, the only one that can delete them. A move that fails part way finishes on a later visit. @@ -322,7 +322,7 @@ The services are in [`src/lib/server/token-exchange/services.json`](src/lib/serv ## Security notes - OAuth uses DPoP-bound tokens and granular scopes: a permission set ([`permissions.json`](../../lexicons/computer/localnetwork/permissions.json), requested as `include:computer.localnetwork.permissions`) with write-only access to the user's own repos in the audit, moderation, booking, and backstage spaces and a self-authority planner space, and to the shows and follows in their public repo (plus deleting press kits made before the root held them, as they move), plus blob uploads limited to PNG, JPEG, and WebP avatars and JSON tracklists, and calendar events (`repo:community.lexicon.calendar.event`, asked for on its own since a permission set can only grant its own namespace). The PDS grants the set as the scopes it lists. The shared spaces it grants writes to are the root's only: `did:web:root.invalid` in the file stands for the root, and each network's root publishes the set with its own DID there (`pnpm lexicons:publish` for production; the dev network and the integration tests publish their own). So a request for a legacy press kit, one still in its maker's space, is written by the root on the asker's behalf. Accounts without spaces support are asked for `atproto` plus the show, follow, and calendar event writes, and fall back to plain `atproto` if their PDS refuses those; they, and anyone who approves only part of the request, have the root account write their space records on their behalf. Sessions granted before the app asked for a new space or blob scope are sent back through sign-in on their next page load. Show, follow, and calendar event writes are asked for when they're needed instead, with a one-click "Sign in again"; a PDS that still won't grant them after that gets a plain "your account's server doesn't allow this yet". Posting bug reports to userinput.app is asked for separately, when someone first sends one, and only allows creating `app.userinput.discussion` records. Nothing lets the app read other users' data with a user's session; shared spaces are read with credentials issued to the root authority. -- Browser sessions are opaque random tokens stored as SHA-256 hashes; cookies are `HttpOnly`, `SameSite=Lax`, and `Secure` on HTTPS. +- Browser sessions are opaque random tokens stored as SHA-256 hashes; cookies are `HttpOnly`, `SameSite=Lax`, and `Secure` on HTTPS, except `ln_signed_out`, which holds only the DIDs whose drafts to drop and is read by the page's scripts. - CSP with per-request nonces, `frame-ancestors 'none'`, `nosniff`, strict referrer policy, HSTS on HTTPS, and SvelteKit's CSRF origin checks. - Service endpoints from DID documents are checked against private address ranges in production (SSRF guard). - Uploaded images are type- and magic-byte-checked and capped at 1 MB for avatars and 5 MB for press kit photos. Stored images are served only if their blob's type is one their lexicon accepts (PNG, JPEG or WebP), since a PDS doesn't check, with a sandboxing CSP and `nosniff`. diff --git a/apps/web/src/lib/form-draft.svelte.ts b/apps/web/src/lib/form-draft.svelte.ts index 6900e62de..5bdc2bd78 100644 --- a/apps/web/src/lib/form-draft.svelte.ts +++ b/apps/web/src/lib/form-draft.svelte.ts @@ -38,15 +38,27 @@ function write(key: string, draft: StoredDraft | null) { } } -/** Drops every draft the account `did` kept in this browser, as it signs out. */ -export function clearDrafts(did: string) { - try { - const prefix = `${PREFIX}${did}:`; +/** + * Names the accounts that signed out in this browser since it last ran the app's scripts, set by the sign-out + * response itself so it doesn't matter whether the page signed out from had loaded them. + */ +export const SIGNED_OUT_COOKIE = 'ln_signed_out'; + +/** Drops every draft kept in this browser by the accounts that signed out in it, then the cookie naming them. */ +export function clearSignedOutDrafts() { + const value = document.cookie.match(new RegExp(`(?:^|; )${SIGNED_OUT_COOKIE}=([^;]*)`))?.[1]; - for (const key of Object.keys(localStorage)) if (key.startsWith(prefix)) localStorage.removeItem(key); + if (!value) return; + const dids = decodeURIComponent(value).split(' '); + + try { + for (const key of Object.keys(localStorage)) + if (dids.some((did) => key.startsWith(`${PREFIX}${did}:`))) localStorage.removeItem(key); } catch { // Blocked storage holds no drafts. } + + document.cookie = `${SIGNED_OUT_COOKIE}=; Max-Age=0; Path=/`; } /** Whether a draft is waiting under `key`. */ diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index f427118c6..0aa84d294 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -4,7 +4,7 @@ import { afterNavigate, goto } from '$app/navigation'; import { page } from '$app/state'; import favicon from '#lib/assets/favicon.svg'; - import { clearDrafts } from '#lib/form-draft.svelte.ts'; + import { clearSignedOutDrafts } from '#lib/form-draft.svelte.ts'; import { type NavLink, siteNav } from '#lib/nav.ts'; import SiteHeader from '#lib/ui/SiteHeader.svelte'; import { hotkeys, hotkeyTarget, loadHotkeyPreference, setHotkeysEnabled } from '#lib/hotkeys.svelte.ts'; @@ -12,8 +12,12 @@ let { children, data }: LayoutProps = $props(); - // Says the page is interactive, so the end-to-end tests can wait for it instead of guessing. - onMount(() => document.documentElement.setAttribute('data-hydrated', '')); + onMount(() => { + // A sign-out is a full page load, landing here whether or not the page it came from had hydrated. + clearSignedOutDrafts(); + // Says the page is interactive, so the end-to-end tests can wait for it instead of guessing. + document.documentElement.setAttribute('data-hydrated', ''); + }); /** A key for aria-keyshortcuts, only while keyboard shortcuts are on. */ const shortcut = (key: string) => (hotkeys.enabled ? key : undefined); @@ -45,15 +49,6 @@ loadHotkeyPreference(); }); - // Signing out, from the header or anywhere else that posts to the sign-out action, drops the account's - // unsent form drafts from this browser. - function onsubmit(event: SubmitEvent) { - const form = event.target; - - if (data.viewer && nav.account && form instanceof HTMLFormElement && new URL(form.action).pathname === nav.account.signOut) - clearDrafts(data.viewer.did); - } - function onkeydown(event: KeyboardEvent) { const href = hotkeyTarget(event, shortcuts); @@ -70,8 +65,6 @@ - - Skip to content ({ - render: () => - '
' -})); +const children = createRawSnippet(() => ({ render: () => '
' })); const mine = draftKey(viewer, 'project:new')!; const theirs = draftKey({ did: 'did:plc:neon' }, 'project:new')!; -// The browser would leave the page on submit; the layout's handler has run by the time this one does. -const stay = (event: SubmitEvent) => event.preventDefault(); - -const submit = (id: string) => document.querySelector(`#${id}`)!.requestSubmit(); +const signedOut = () => document.cookie.includes(`${SIGNED_OUT_COOKIE}=`); beforeEach(() => { - window.addEventListener('submit', stay); - for (const key of [mine, theirs]) localStorage.setItem(key, JSON.stringify({ savedAt: '', base: '', values: {} })); }); afterEach(() => { - window.removeEventListener('submit', stay); - for (const key of [mine, theirs]) localStorage.removeItem(key); + document.cookie = `${SIGNED_OUT_COOKIE}=; Max-Age=0; Path=/`; }); -it("drops the signed-in account's drafts when it signs out, and only then", async () => { - await render(Layout, { params: {}, data: { viewer, pendingRequests: 0 }, children }); - - submit('save'); - expect([localStorage.getItem(mine), localStorage.getItem(theirs)]).not.toContain(null); +it('drops the drafts of the accounts the sign-out response names on the next page load, and only theirs', async () => { + // As /logout sets it: encoded, and naming each account signed out since the app's scripts last ran. + document.cookie = `${SIGNED_OUT_COOKIE}=${encodeURIComponent(`did:plc:other ${viewer.did}`)}; Path=/`; + await render(Layout, { params: {}, data: { viewer: null, pendingRequests: 0 }, children }); - submit('sign-out'); expect(localStorage.getItem(mine)).toBeNull(); expect(localStorage.getItem(theirs)).not.toBeNull(); + expect(signedOut()).toBe(false); +}); + +it('keeps drafts when nobody signed out, like a session that needs signing in again', async () => { + await render(Layout, { params: {}, data: { viewer: null, pendingRequests: 0 }, children }); + + expect([localStorage.getItem(mine), localStorage.getItem(theirs)]).not.toContain(null); }); diff --git a/apps/web/src/routes/logout/+server.ts b/apps/web/src/routes/logout/+server.ts index 135279436..bd284ef67 100644 --- a/apps/web/src/routes/logout/+server.ts +++ b/apps/web/src/routes/logout/+server.ts @@ -1,4 +1,5 @@ import { redirect } from '@sveltejs/kit'; +import { SIGNED_OUT_COOKIE } from '#lib/form-draft.svelte.ts'; import { SESSION_COOKIE } from '#lib/server/auth/session.ts'; import type { RequestHandler } from './$types'; @@ -21,6 +22,14 @@ export const POST: RequestHandler = async ({ locals, cookies }) => { await Promise.race([recorded, new Promise((resolve) => setTimeout(resolve, 5_000))]); await app.oauth.revoke(viewer.did); + // The next page this browser loads drops the account's form drafts. Its scripts read it, and it holds only DIDs. + cookies.set(SIGNED_OUT_COOKIE, [cookies.get(SIGNED_OUT_COOKIE), viewer.did].filter(Boolean).join(' '), { + path: '/', + httpOnly: false, + sameSite: 'lax', + secure: app.config.publicUrl.startsWith('https:'), + maxAge: 365 * 86_400 + }); } await app.sessions.destroy(sessionToken); diff --git a/apps/web/tests/e2e/app.e2e.ts b/apps/web/tests/e2e/app.e2e.ts index 709444d09..798a5aaac 100644 --- a/apps/web/tests/e2e/app.e2e.ts +++ b/apps/web/tests/e2e/app.e2e.ts @@ -1,4 +1,4 @@ -import { expect, type Page, test } from '@playwright/test'; +import { expect, type Page, type Route, test } from '@playwright/test'; import headline from '../../src/lib/headline.json' with { type: 'json' }; import { z } from 'zod'; import { expectAccessible } from './axe.ts'; @@ -568,9 +568,20 @@ test.describe('keeps what people entered', () => { await expect(page.getByRole('status').filter({ hasText: 'unsent changes' })).toBeVisible(); await expectAccessible(page); - // Signing out drops it, so the next person on this browser doesn't find it. + // Signing out drops it, so the next person on this browser doesn't find it, even when Sign out is + // pressed before the dashboard's scripts have run. const inquiry = page.url(); - await signOut(page); + + const holdScripts = (route: Route) => + route.request().resourceType() === 'script' ? route.abort() : route.fallback(); + + await page.route('**/*', holdScripts); + await page.goto('/dashboard'); + await page.unroute('**/*', holdScripts); + await expect(page.locator('html')).not.toHaveAttribute('data-hydrated'); + await page.getByRole('button', { name: 'Sign out' }).click(); + await page.waitForURL((url) => url.pathname === '/'); + await expect(page.locator('html')).toHaveAttribute('data-hydrated', ''); const drafts = () => Object.keys(localStorage).filter((key) => key.startsWith('ln:draft:')); expect(await page.evaluate(drafts)).toEqual([]); await signIn(page, 'pat.test', 'pat-pass', new URL(inquiry).pathname + new URL(inquiry).search);