User-facing docs: inline "how it works" + /personvern detail page

Two layers of explanation, addressing the gap between "I just want to
use it" and "I want to understand the crypto."

Inline:
  - Signup page: extends the existing one-liner with a "Les hvordan
    det virker" link. Opens in a new tab so the user doesn't lose the
    form (and any invite token) mid-read.
  - Public landing: gains a second muted paragraph that names the
    three visibility levels in plain language and links to the docs.

Detail page (/personvern):
  - New Svelte component Personvern.svelte with sections for the
    three visibility levels, E2E key model, password vs recovery
    code, what the server stores vs doesn't, sessions, and explicit
    limits ("what crypto does NOT protect against").
  - Written for non-technical users; still names the primitives
    (Argon2id, XChaCha20-Poly1305) and gestures at SECURITY.md for
    anyone who wants the engineering depth.

App.svelte routing:
  - New /personvern path handled in parsePath
  - applyRoute branch sets view='personvern'
  - leavePersonvern() routes back to /home or / depending on auth
  - Persistent footer link on every view so the docs are always one
    click away

Norwegian Bokmål throughout. No new dependencies. Bundle 34.2 KB →
36.8 KB gzipped (mostly the markdown-ish prose content).
This commit is contained in:
Ole-Morten Duesund 2026-05-25 14:32:14 +02:00
commit 79ce7059c1
4 changed files with 197 additions and 7 deletions

View file

@ -13,6 +13,7 @@
import PublicList from './components/PublicList.svelte';
import Admin from './components/Admin.svelte';
import ActivityPermalink from './components/ActivityPermalink.svelte';
import Personvern from './components/Personvern.svelte';
/**
* URL contract:
@ -20,6 +21,7 @@
* /home — authenticated dashboard (private+semi+public)
* /a/:id — permalink to a single activity (any visibility)
* /<username>/list — opt-in public list for that user
* /personvern — privacy + how-it-works long-form page
*
* Anything else (signup, login, recovery, profile, feedback, admin) is an
* in-app view state, not a URL. We update window.history on view changes
@ -32,7 +34,8 @@
| 'home' // "/home" — full authenticated dashboard
| 'profile' | 'feedback' | 'admin'
| 'public-list' // "/<username>/list"
| 'permalink'; // "/a/:id"
| 'permalink' // "/a/:id"
| 'personvern'; // "/personvern"
let view: View = $state('loading');
let publicListUsername = $state('');
@ -42,7 +45,7 @@
let selfRegistryEnabled = $state(true);
interface Route {
view: 'public-home' | 'home' | 'public-list' | 'permalink' | 'invite';
view: 'public-home' | 'home' | 'public-list' | 'permalink' | 'invite' | 'personvern';
payload?: string;
}
@ -50,6 +53,7 @@
const path = window.location.pathname;
if (path === '/' || path === '') return { view: 'public-home' };
if (path === '/home' || path === '/home/') return { view: 'home' };
if (path === '/personvern' || path === '/personvern/') return { view: 'personvern' };
const userList = path.match(/^\/([a-z0-9_-]{2,31})\/list\/?$/);
if (userList) return { view: 'public-list', payload: userList[1] };
const perma = path.match(/^\/a\/([A-Za-z0-9-]+)\/?$/);
@ -122,9 +126,23 @@
// the token prefilled. If they're already logged in we still show the
// landing — they can't claim an invite on top of an existing account.
view = session.user ? 'public-home' : 'signup';
} else if (route.view === 'personvern') {
view = 'personvern';
}
}
function goPersonvern() {
pushUrl('/personvern');
view = 'personvern';
}
function leavePersonvern() {
// Send the visitor wherever they "would have been" — landing if logged out,
// dashboard if logged in. Either is more useful than staying on the doc page.
if (session.user) goHome();
else goPublicHome();
}
function onAuthed() {
pushUrl('/home');
view = 'home';
@ -210,7 +228,17 @@
<Feedback onDone={goHome} />
{:else if view === 'admin'}
<Admin onDone={goHome} />
{:else if view === 'personvern'}
<Personvern onBack={leavePersonvern} />
{:else}
<Home publicOnly={false} />
{/if}
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--border); text-align: center;">
<a href="/personvern"
onclick={(e) => { e.preventDefault(); goPersonvern(); }}
class="muted" style="font-size: 0.9rem;">
Personvern og hvordan det virker
</a>
</footer>
</main>

View file

@ -121,11 +121,18 @@
<section aria-label="Aktiviteter">
<div class="row" style="justify-content: space-between; margin-bottom: 1rem;">
{#if publicOnly}
<p class="muted" style="margin: 0;">
Her kan du lage og dele lister over ting å gjøre om vinteren for å
holde vinterdepresjonen unna. Og selvfølgelig kan du også lage lister
for andre formål — noen liker jo ikke sommeren heller.
</p>
<div style="margin: 0;">
<p class="muted" style="margin: 0 0 0.5rem;">
Her kan du lage og dele lister over ting å gjøre om vinteren for å
holde vinterdepresjonen unna. Og selvfølgelig kan du også lage
lister for andre formål — noen liker jo ikke sommeren heller.
</p>
<p class="muted" style="margin: 0; font-size: 0.9rem;">
Du velger selv om hver oppføring er privat (kryptert i nettleseren
din), anonym (synlig uten navn), eller offentlig.
<a href="/personvern">Mer om personvern og hvordan det virker.</a>
</p>
</div>
{:else if session.user}
<p class="muted" style="margin: 0;">
{#if session.user.display_name?.trim()}

View file

@ -0,0 +1,154 @@
<script lang="ts">
interface Props {
onBack?: () => void;
}
let { onBack }: Props = $props();
</script>
<section aria-label="Personvern og hvordan det virker">
{#if onBack}
<div class="row" style="margin-bottom: 1rem;">
<button type="button" onclick={onBack}> Tilbake</button>
</div>
{/if}
<h2 style="font-size: 1.75rem; margin-top: 0;">Personvern og hvordan det virker</h2>
<p>
Vinterliste lar deg lage og dele lister over ting å gjøre om vinteren —
eller hva enn du vil bruke det til. Du velger selv om hver oppføring er
privat, anonym eller offentlig. Her er hva det betyr i praksis, og hva
serveren faktisk ser av det du skriver.
</p>
<h3>De tre synlighetsnivåene</h3>
<ul>
<li>
<strong>Privat:</strong> oppføringen krypteres i nettleseren din før den
sendes. Verken vi som driver tjenesten, eller noen som måtte få tak i
databasen, kan lese innholdet. Bare du kan låse den opp — med passordet
ditt eller gjenopprettingskoden.
</li>
<li>
<strong>Anonym (halv-offentlig):</strong> innholdet er synlig for alle,
men navnet ditt vises ikke ved siden av. Vi lagrer en intern referanse
til hvem som eier oppføringen (slik at du kan redigere eller slette den),
men API-et returnerer aldri den referansen til andre brukere.
</li>
<li>
<strong>Offentlig:</strong> innholdet er synlig for alle <em>og</em>
kreditert med visningsnavnet ditt (eller brukernavnet, hvis du har
satt det). Setter du ingen av delene, vises ingen attribusjon.
</li>
</ul>
<h3>Ende-til-ende-kryptering, kort forklart</h3>
<p>
For private oppføringer skjer all kryptering i nettleseren din. Når du
oppretter en konto, lager nettleseren en tilfeldig nøkkel som vi kaller
DEK (Data Encryption Key). Denne nøkkelen pakkes inn — krypteres — på
to måter:
</p>
<ul>
<li>
Én pakke åpnes med passordet ditt: nettleseren utleder en
passordavledet nøkkel (KEK_pw) ved hjelp av <strong>Argon2id</strong>,
en moderne nøkkelutledningsfunksjon som er treg med vilje for å gjøre
brute-force-angrep upraktiske.
</li>
<li>
Én pakke åpnes med gjenopprettingskoden: en høyentropi-kode som vises
én gang ved registrering og som du må ta vare på selv. Vi lagrer den
<strong>aldri</strong> på serveren.
</li>
</ul>
<p>
Selve innholdet krypteres med <strong>XChaCha20-Poly1305</strong>, en
autentisert krypteringsalgoritme — som betyr at ciphertekst som er tuklet
med, ikke kan dekrypteres uten at det oppdages.
</p>
<h3>Passordet ditt forlater aldri nettleseren</h3>
<p>
Når du logger inn, sender vi <strong>ikke</strong> passordet til serveren.
Nettleseren utleder en separat verifikator fra passordet (Argon2id med
et eget salt, distinkt fra det som brukes til å pakke DEK-en) og sender
bare denne. Serveren kjører <code>Bun.password.verify</code> mot en hash
vi har lagret. Om databasen lekker, må en angriper både brute-force-knekke
verifikatoren <em>og</em> så gjøre samme treghet om igjen for å utlede
nøkkelen som åpner pakka — to separate angrep, ikke ett.
</p>
<h3>Å bytte passord eller bruke gjenopprettingskoden</h3>
<p>
Bytter du passord, blir innholdet i den private listen din <em>aldri</em>
kryptert på nytt. Nettleseren låser bare opp DEK-en med det gamle
passordet og pakker den inn igjen med det nye. Det går raskt og er
sikkert: selve dataene rører vi aldri. Gjenopprettingskoden virker etter
samme prinsipp; den åpner sin egen pakke og lar deg sette et nytt
passord uten å miste noe.
</p>
<p>
Når noen fullfører gjenoppretting, sjekker serveren først at avsenderen
faktisk kan gjenopprettingskoden (via en egen verifikator). Det betyr at
noen som bare kjenner eposten din, ikke kan låse deg ute fra kontoen.
</p>
<h3>Hva serveren ser, og ikke ser</h3>
<p>Vi lagrer per bruker:</p>
<ul>
<li>Eposten din (brukt som innloggings-id).</li>
<li>
Salter og innpakkede nøkler — disse er ikke hemmelige i seg selv; uten
passordet eller gjenopprettingskoden låser de ingenting opp.
</li>
<li>Hash av autentiseringsverifikatoren (ikke passordet — verifikatoren).</li>
<li>Visningsnavn, brukernavn og rolleflagg, om du har satt dem.</li>
</ul>
<p>For private oppføringer lagrer vi <strong>kun</strong>:</p>
<ul>
<li>Ciphertekst (ulesbar uten DEK-en).</li>
<li>En tilfeldig nonce per oppføring.</li>
<li>Tidspunkt for opprettelse og siste endring.</li>
</ul>
<p>
Vi lagrer aldri: råpassordet, gjenopprettingskoden, DEK-en i lesbar form,
eller klartekst-innholdet i private oppføringer.
</p>
<h3>Sesjoner og innlogging</h3>
<p>
Innlogging gir deg en httpOnly-cookie med en tilfeldig sesjonsverdi som
lagres i databasen vår. Vi bruker bevisst <em>ikke</em> JWT — ingen
kompliserte tokens som vi ikke kan trekke tilbake. Logger du ut, eller
fullfører gjenoppretting, slettes sesjonene umiddelbart.
</p>
<h3>Hva som <em>ikke</em> er beskyttet</h3>
<p>
Krypteringen beskytter <strong>innholdet</strong> i de private
oppføringene dine. Den beskytter <strong>ikke</strong> mot:
</p>
<ul>
<li>
At noen får tilgang til nettleseren din mens du er innlogget. Da har de
DEK-en i minnet og kan lese alt. Logg ut når du forlater enheten.
</li>
<li>
Trafikkanalyse — antallet og størrelsen på dine private oppføringer er
synlige for noen som overvåker forbindelsen, selv om innholdet ikke er
det.
</li>
<li>
Skadelige nettleserutvidelser eller sårbarheter i nettleseren. Ingen
ende-til-ende-app kan beskytte mot at klienten kompromitteres.
</li>
</ul>
<h3>Vil du grave dypere?</h3>
<p>
Den fulle tekniske beskrivelsen ligger i prosjektets <code>SECURITY.md</code>.
Den dekker eksakt hvilke biter som lagres hvor, hvilke Argon2id-parametre
vi bruker, og hvordan vi tenker rundt avveiningene.
</p>
</section>

View file

@ -81,6 +81,7 @@
<p class="muted">
Vi krypterer alt du markerer som <em>privat</em> i nettleseren din. Serveren
ser aldri passordet ditt eller innholdet i private oppføringer.
<a href="/personvern" target="_blank" rel="noopener noreferrer">Les hvordan det virker.</a>
</p>
<label for="signup-email">Epost</label>