feat(app): fladen kan tale med en valgfri server, med sessionen som bearer

Tredje trin mod iOS-appen. I en browser deler fladen og API'et origin: hver
sti er relativ, og sessionen er en httpOnly-cookie. I appen ligger fladen i
appen selv på capacitor://localhost, og serveren er den, ejeren skrev ind.
Alt, der er forskelligt, bor i den nye `platform.js`, så browserens svar er
præcis, hvad det var før.

- `apiURL()` og `authorize()`: relative stier og cookie i en browser; den
  valgte server og `Authorization: Bearer` i appen, `credentials: 'omit'`.
- Login sender `client: app` og gemmer `session_token`; log ud fjerner det fra
  telefonen, også når serveren ikke svarede.
- Live-synk i appen beder om en billet og åbner socket'en på serveren med den.
- Første skærm i appen: "Hvilken Verdande vil du logge ind på?". Adressen
  prøves med /ping, før et adgangskodefelt vises, så en tastefejl, en router
  eller en server, der er nede, får sin egen sætning. Passkeys og opsætning af
  første konto vises ikke i appen; begge hører til serverens eget domæne.
- Billeder og downloads under /api/v1 hentes med tokenet og vises fra en blob,
  i appen og kun dér (`nativemedia.js`). Stien bevares i `data-src`, og
  `richtext.js` læser den først, så en note aldrig gemmer en blob-adresse.
- Service worker og Web Push er slået fra i appen: WKWebView har ingen af dem.

Serveren: `VERDANDE_APP_ORIGINS` regner flere origins som appens. Den findes
for E2E-projektet `app-origin`, der kører fladen fra app.localhost — samme
server, anden origin — med en Capacitor-stub og uden cookie. Prøven vælger
server, logger ind, ser et projekt fra "en anden enhed" dukke op over socket'en,
viser et billede fra en blob og logger ud.

Fire plantede fejl, hver fanget af prøven ved sit eget trin: ingen bearer,
socket uden billet, billeder ikke hentet, token efterladt ved log ud.

Dokumenterer også `VERDANDE_APP_SESSION_TTL` fra trin 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-10-23 14:16:33 +02:00
co-authored by Claude Opus 5.5
parent a71af41d9e
commit b9028e8f7a
20 changed files with 728 additions and 23 deletions
+33 -6
View File
@@ -24,6 +24,7 @@ export class ApiError extends Error {
import { t } from './i18n.svelte.js';
import { connectivity, sleepOrOnline } from './connectivity.svelte.js';
import { apiURL, appSession, authorize, deviceName, isNativeApp } from './platform.js';
/**
* How long a request keeps retrying a network failure before giving up.
@@ -152,19 +153,21 @@ async function request(method, path, body, options = {}) {
const init = {
method,
headers: {},
// The session cookie is httpOnly and same-origin; nothing here handles a token.
credentials: 'same-origin',
signal: options.signal
};
if (body !== undefined) {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
// In a browser: the httpOnly session cookie, same-origin, and nothing here
// handles a token. In the iOS app: its session token as a bearer. See
// platform.js.
authorize(init);
// Retries a network failure for a few seconds so a short drop does not lose the
// call; `withRetry` turns an exhausted window into the same `offline` error the
// app already handles. `options.noRetry` opts a call out — used by nothing yet.
const res = await withRetry(() => fetch(`/api/v1${path}`, init), options.noRetry);
const res = await withRetry(() => fetch(apiURL(path), init), options.noRetry);
if (res.status === 204) return null;
@@ -205,7 +208,7 @@ async function upload(path, file, field = 'file') {
form.append(field, file);
const res = await withRetry(() =>
fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' })
fetch(apiURL(path), authorize({ method: 'POST', body: form }))
);
const text = await res.text();
@@ -232,9 +235,30 @@ export const api = {
// --- auth
setupState: () => get('/auth/setup'),
setup: (data) => post('/auth/setup', data),
login: (email, password) => post('/auth/login', { email, password }),
login: async (email, password) => {
if (!isNativeApp()) return post('/auth/login', { email, password });
// The app gets its session in the answer instead of a cookie, and keeps
// it — also while a code is still owed, because the second step is sent
// with it.
const result = await post('/auth/login', {
email,
password,
client: 'app',
device_name: deviceName()
});
if (result?.session_token) appSession.setToken(result.session_token);
return result;
},
loginTOTP: (code) => post('/auth/login/totp', { code }),
logout: () => post('/auth/logout'),
logout: async () => {
try {
return await post('/auth/logout');
} finally {
// Gone from the phone whether or not the server heard: a sign-out that
// left the token behind because the network was down is not one.
if (isNativeApp()) appSession.signOut();
}
},
me: () => get('/auth/me'),
updateProfile: (data) => patch('/auth/me', data),
// Its own route rather than a field on the profile: that one backs a form with
@@ -421,6 +445,9 @@ export const api = {
post('/tokens', { name, expires_in_days: expiresInDays ?? 0 }),
deleteToken: (id) => del(`/tokens/${id}`),
// --- live sync, for the app (see stores.svelte.js)
wsTicket: () => post('/ws-ticket'),
// --- notifications and push
listNotifications: () => get('/notifications'),
markNotificationsRead: (id) => post(id ? `/notifications/${id}/read` : '/notifications/read'),
+114 -5
View File
@@ -21,9 +21,56 @@
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
import { available, signIn } from '$lib/passkey.js';
import { appSession, isNativeApp, normaliseServer } from '$lib/platform.js';
// loading | login | setup | totp | forgot | sent | invite | reset | done
// loading | server | login | setup | totp | forgot | sent | invite | reset | done
let mode = $state('loading');
// --- which server (the iOS app only) ------------------------------------------
const native = isNativeApp();
let server = $state(native ? appSession.server() : '');
let serverHost = $derived(server ? server.replace(/^https?:\/\//, '') : '');
/**
* The app has no server of its own. Before a password field is shown for an
* address somebody typed, the address has to be a Verdande: asked with /ping,
* which answers without a login and names the product. A router's login page,
* a typo that lands on somebody's blog, a server that is down — each gets its
* own sentence here instead of a sign-in form that cannot succeed.
*/
async function chooseServer() {
const url = normaliseServer(server);
if (!url) {
error = t('app.serverInvalid');
return;
}
let answer;
try {
const res = await fetch(`${url}/api/v1/ping`, { credentials: 'omit' });
answer = res.ok ? await res.json() : null;
} catch {
error = t('app.serverUnreachable', { host: url.replace(/^https?:\/\//, '') });
return;
}
if (answer?.product !== 'verdande') {
error = t('app.notVerdande');
return;
}
appSession.setServer(url);
server = url;
await enterServer();
}
/** Asks the chosen server what it wants: sign-in, or — in a browser — setup. */
async function enterServer() {
try {
const state = await api.setupState();
mode = state.needs_setup ? 'setup' : 'login';
} catch {
mode = 'login';
}
}
let email = $state('');
let password = $state('');
let name = $state('');
@@ -37,6 +84,9 @@
let passkeyReady = $state(false);
$effect(() => {
// Not in the app: a passkey is bound to the server's own domain, and the
// app's pages are not on it. Offered there it would fail every time.
if (native) return;
available().then((ok) => (passkeyReady = ok));
});
@@ -94,10 +144,11 @@
mode = linkMode;
return;
}
api
.setupState()
.then((state) => (mode = state.needs_setup ? 'setup' : 'login'))
.catch(() => (mode = 'login'));
if (native && !appSession.server()) {
mode = 'server';
return;
}
enterServer();
});
async function run(fn) {
@@ -120,6 +171,10 @@
run(async () => {
switch (mode) {
case 'server':
await chooseServer();
break;
case 'setup':
await api.setup({
email,
@@ -190,6 +245,34 @@
{#if mode === 'loading'}
<p class="lede">&nbsp;</p>
{:else if mode === 'server'}
<p class="lede">{t('app.serverPrompt')}</p>
<label>
{t('app.server')}
<input
bind:value={server}
use:focusOnMount
type="url"
inputmode="url"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
placeholder="verdande.example.dk"
required
/>
<span class="hint">{t('app.serverHint')}</span>
</label>
<button type="submit" disabled={busy}>{t('app.connect')}</button>
{:else if mode === 'setup' && native}
<!-- The first account starts a browser session, and the app has no use
for a cookie. An instance this new is being set up by whoever runs
it, at a desk; they do it there, and the app signs in after. -->
<p class="lede">{t('app.setupInBrowser', { host: serverHost })}</p>
<button type="button" class="link" onclick={() => (mode = 'server')}>
{t('app.changeServer')}
</button>
{:else if mode === 'setup'}
<p class="lede">{t('auth.firstAccount')}</p>
@@ -303,6 +386,15 @@
{:else}
<p class="lede">{t('auth.signInPrompt')}</p>
{#if native}
<p class="server-line">
<span class="mono">{serverHost}</span>
<button type="button" class="link" onclick={() => (mode = 'server')}>
{t('app.changeServer')}
</button>
</p>
{/if}
<label>
{t('auth.email')}
<input bind:value={email} type="email" autocomplete="username" required />
@@ -463,6 +555,23 @@
color: var(--ink);
}
/* Which server the app is signing in to, and the way back to choosing. */
.server-line {
display: flex;
gap: var(--s2);
align-items: baseline;
justify-content: center;
margin: 0;
font-size: var(--text-sm);
color: var(--ink-faint);
}
.server-line .mono {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.error {
margin: 0;
padding: var(--s3);
+9
View File
@@ -736,6 +736,15 @@ export const da = {
'auth.savePassword': 'Gem adgangskoden',
'auth.passwordChanged': 'Adgangskoden er skiftet. Log ind med den nye.',
'auth.resetSent': 'Hvis adressen har en konto, er der et link på vej. Tjek også spamfilteret.',
'app.serverPrompt': 'Hvilken Verdande vil du logge ind på?',
'app.server': 'Serveradresse',
'app.serverHint': 'Den adresse, du åbner Verdande på i en browser.',
'app.connect': 'Fortsæt',
'app.serverInvalid': 'Det ligner ikke en adresse.',
'app.serverUnreachable': 'Kunne ikke nå {host}. Tjek adressen og din forbindelse.',
'app.notVerdande': 'Der svarer noget på den adresse, men det er ikke en Verdande.',
'app.changeServer': 'Skift server',
'app.setupInBrowser': '{host} har ingen konti endnu. Opret den første i en browser, og log så ind her.',
'auth.signInPrompt': 'Log ind for at fortsætte.',
'auth.signIn': 'Log ind',
'auth.forgot': 'Glemt adgangskode?',
+9
View File
@@ -730,6 +730,15 @@ export const en = {
'auth.passwordChanged': 'The password has been changed. Sign in with the new one.',
'auth.resetSent':
'If that address has an account, a link is on its way. Check the spam filter too.',
'app.serverPrompt': 'Which Verdande do you want to sign in to?',
'app.server': 'Server address',
'app.serverHint': 'The address you open Verdande at in a browser.',
'app.connect': 'Continue',
'app.serverInvalid': 'That does not look like an address.',
'app.serverUnreachable': 'Could not reach {host}. Check the address and your connection.',
'app.notVerdande': 'Something answers at that address, but it is not a Verdande.',
'app.changeServer': 'Change server',
'app.setupInBrowser': '{host} has no accounts yet. Create the first one in a browser, then sign in here.',
'auth.signInPrompt': 'Sign in to continue.',
'auth.signIn': 'Sign in',
'auth.forgot': 'Forgotten your password?',
+92
View File
@@ -0,0 +1,92 @@
/**
* Pictures and files from the server, inside the iOS app.
*
* Attachments are addressed as `/api/v1/attachments/<id>` — in a note's text, in
* a task's file list, in the export links — and in a browser that is the whole
* story: the path is on the page's own origin and the cookie goes with it. In the
* app neither holds. The path would resolve against capacitor://localhost, and an
* `<img>` or a link cannot carry the bearer token even when pointed at the right
* server.
*
* So in the app, and only there, this watches the page: an image whose source is
* an API path is fetched with the token and shown from a blob, and a tap on a
* link to an API path is answered the same way. Done once for the whole document
* rather than in each component, because the paths come from many places — a
* note's own markdown among them — and a component that forgot would show a
* broken picture only in the app, where nobody runs the tests.
*
* The original path is kept in `data-src`. The note editor turns the page back
* into markdown, and a blob address saved into a note is a picture that is gone
* the next time anybody opens it; `richtext.js` reads `data-src` first.
*/
import { apiURL, authorize, isNativeApp } from './platform.js';
const API = '/api/v1/';
const blobs = new Map();
async function blobFor(path) {
if (!blobs.has(path)) {
const promise = fetch(apiURL(path.slice(API.length - 1)), authorize({ method: 'GET' })).then(
async (res) => {
if (!res.ok) throw new Error(`${res.status}`);
return URL.createObjectURL(await res.blob());
}
);
// A failure is not cached: the next sight of the picture tries again.
promise.catch(() => blobs.delete(path));
blobs.set(path, promise);
}
return blobs.get(path);
}
function adopt(img) {
const path = img.getAttribute('src');
// Our own blob address coming back through the observer is not an API path,
// so it stops here; a new API path on an image already adopted is taken up.
if (!path?.startsWith(API) || img.dataset.src === path) return;
img.dataset.src = path;
// Nothing until the picture is here, rather than a broken-image glyph.
img.removeAttribute('src');
blobFor(path)
.then((url) => {
if (img.dataset.src === path) img.src = url;
})
.catch(() => {});
}
function scan(root) {
if (root.nodeType !== 1) return;
if (root.tagName === 'IMG') adopt(root);
for (const img of root.querySelectorAll?.('img[src^="/api/v1/"]') ?? []) adopt(img);
}
async function openLink(event) {
const a = event.target.closest?.('a[href]');
const path = a?.getAttribute('href');
if (!path?.startsWith(API)) return;
event.preventDefault();
try {
window.open(await blobFor(path), '_blank');
} catch {
// Nothing to open; the request's own failure is what there is to say,
// and there is no screen here to say it on.
}
}
/** Called once by the layout. A no-op in a browser. */
export function installNativeMedia() {
if (!isNativeApp() || typeof document === 'undefined') return;
scan(document.body);
new MutationObserver((records) => {
for (const record of records) {
if (record.type === 'attributes') adopt(record.target);
else for (const node of record.addedNodes) scan(node);
}
}).observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src']
});
document.addEventListener('click', openLink, true);
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Where the interface is running, and what that changes about reaching the server.
*
* In a browser the interface and the API share an origin: every path is relative
* and the session is an httpOnly cookie no script can see. In the iOS app the
* interface is bundled into the app and served from capacitor://localhost, and
* the server is whichever one its owner typed in. So the app needs three things
* a browser never does: the server's address, its own session token, and that
* token on every request instead of a cookie.
*
* Everything that differs between the two lives here, so `api.js` and the socket
* ask one question — `apiURL(path)`, `authorize(init)` — and the browser's answer
* is exactly what it was before this file existed.
*/
/** True inside the iOS app. Capacitor puts its bridge on `window` before any of
* our code runs. */
export function isNativeApp() {
return !!globalThis.Capacitor?.isNativePlatform?.();
}
/**
* The app's stored settings: which server, and the session on it.
*
* localStorage for now, behind this one object, so the Keychain plugin can take
* its place without anything else changing. The token is a session — whoever
* holds it is signed in — and the Keychain is where a phone keeps those; until it
* is wired in, this is a WKWebView's own storage, readable only by the app.
*/
const SERVER_KEY = 'verdande.app.server';
const TOKEN_KEY = 'verdande.app.session';
function read(key) {
try {
return localStorage.getItem(key) ?? '';
} catch {
return '';
}
}
function write(key, value) {
try {
if (value) localStorage.setItem(key, value);
else localStorage.removeItem(key);
} catch {
// Storage refused (private mode, quota). The app then asks again next
// launch, which is the right failure: a token that seemed saved and was
// not would look like being signed out for no reason.
}
}
export const appSession = {
server: () => read(SERVER_KEY),
setServer: (url) => write(SERVER_KEY, url),
token: () => read(TOKEN_KEY),
setToken: (token) => write(TOKEN_KEY, token),
/** Signed out: the token goes, the server stays — signing in again is to the
* same place nearly every time. */
signOut: () => write(TOKEN_KEY, '')
};
/** The address of an API path: relative in a browser, on the chosen server in
* the app. */
export function apiURL(path) {
const base = isNativeApp() ? appSession.server() : '';
return `${base}/api/v1${path}`;
}
/**
* Fills in how a request proves who is asking.
*
* A browser sends its cookie, same-origin only. The app sends no cookie at all —
* `omit`, so nothing the WebView picked up from somewhere rides along — and its
* token as a bearer.
*/
export function authorize(init) {
if (!isNativeApp()) {
init.credentials = 'same-origin';
return init;
}
init.credentials = 'omit';
const token = appSession.token();
if (token) {
init.headers = { ...(init.headers ?? {}), Authorization: `Bearer ${token}` };
}
return init;
}
/** What the app calls itself in the list of signed-in devices. */
export function deviceName() {
const ua = navigator.userAgent;
if (/iPad/.test(ua)) return 'iPad';
if (/iPhone/.test(ua)) return 'iPhone';
return 'Verdande-appen';
}
/**
* Normalises what somebody typed as their server's address.
*
* "verdande.example.dk" becomes "https://verdande.example.dk"; a trailing slash
* and a pasted "/api/v1" are dropped. Plain http is kept when typed on purpose,
* because a self-hoster on their own network may have no certificate — iOS will
* refuse it unless the app allows it, and that refusal should be the one they see.
*/
export function normaliseServer(input) {
let s = (input ?? '').trim();
if (!s) return '';
if (!/^https?:\/\//i.test(s)) s = `https://${s}`;
try {
const u = new URL(s);
return `${u.protocol}//${u.host}`;
} catch {
return '';
}
}
+4
View File
@@ -9,12 +9,16 @@
import { api } from './api.js';
import { t } from './i18n.svelte.js';
import { isNativeApp } from './platform.js';
/** Whether this browser can do it at all. Safari before 16.4 cannot, and neither
* can any browser on a page served over plain HTTP from a non-localhost host. */
export function supported() {
return (
typeof window !== 'undefined' &&
// The iOS app gets its notifications from APNs, not Web Push: a WKWebView
// has no push service, and a subscription made there would go nowhere.
!isNativeApp() &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
+4 -1
View File
@@ -267,7 +267,10 @@ function inlineToMarkdown(node) {
// — og den første gemning ville tage hvert billede ud af noten uden at
// nogen havde rørt det.
case 'IMG':
return `![${node.getAttribute('alt') ?? ''}](${node.getAttribute('src') ?? ''})`;
// data-src first: in the iOS app the shown source is a blob made on this
// device, and the path it was made from is what the note keeps. See
// nativemedia.js.
return `![${node.getAttribute('alt') ?? ''}](${node.dataset?.src || node.getAttribute('src') || ''})`;
case 'STRONG':
case 'B':
return inner ? `**${inner}**` : '';
+41 -3
View File
@@ -13,6 +13,23 @@
import { api, ApiError, humanMessage } from './api.js';
import { i18n, t } from './i18n.svelte.js';
import { appSession, isNativeApp } from './platform.js';
/**
* Where the live-sync socket is. A browser opens it on its own origin and the
* cookie goes with the handshake. The app cannot put its token on a handshake,
* so it asks for a one-use ticket first and opens the socket on its server with
* that.
*/
async function socketURL() {
if (!isNativeApp()) {
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
return `${scheme}://${location.host}/api/v1/ws`;
}
const { ticket } = await api.wsTicket();
const base = appSession.server().replace(/^http/, 'ws');
return `${base}/api/v1/ws?ticket=${encodeURIComponent(ticket)}`;
}
class AppState {
user = $state(null);
@@ -229,6 +246,14 @@ class AppState {
async load() {
this.loading = true;
// The app before it knows which server it belongs to: nothing to ask, and
// the sign-in screen is where the address is asked for.
if (isNativeApp() && !appSession.server()) {
this.user = null;
this.loading = false;
return;
}
// Døren, hvis dette vindue allerede er gået gennem den. Genindlæser nogen på
// /noter inde i urd-appen, kommer de ikke forbi /urd igen, og uden den her
// linje ville ansigtet falde af ved hvert F5.
@@ -281,10 +306,23 @@ class AppState {
// --- realtime ---------------------------------------------------------------
connect() {
async connect() {
if (this.#socket) return;
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
const socket = new WebSocket(`${scheme}://${location.host}/api/v1/ws`);
// Claimed before the await below, so two calls in the same moment cannot
// both open one.
this.#socket = 'opening';
let url;
try {
url = await socketURL();
} catch {
// No ticket — offline, or signed out. Try again the way a dropped
// socket does.
this.#socket = null;
setTimeout(() => this.connect(), this.#reconnectDelay);
this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, 30000);
return;
}
const socket = new WebSocket(url);
this.#socket = socket;
socket.onopen = () => {
+7
View File
@@ -15,6 +15,7 @@
import { setBadge } from '$lib/badge.js';
import { open as openInChosenBrowser, stored as storedOpener } from '$lib/linkopen.js';
import BeaconNotice from '$lib/components/BeaconNotice.svelte';
import { installNativeMedia } from '$lib/nativemedia.js';
let { children } = $props();
@@ -58,6 +59,12 @@
app.load();
});
// Pictures and downloads from the server, in the iOS app. A no-op in a
// browser. See nativemedia.js.
$effect(() => {
installNativeMedia();
});
/**
* Hvilken dør dette vindue er inde ad.
*