[feat](web): serve the SPA and web actions on the single listener

Add the browser edge (contract rev 8.2/9): a Preact/Vite SPA under web/,
an embedded placeholder bundle, the /web/* auth and action handlers, and
the explicit browser-action allowlist. The SPA and the API share the one
plaintext :8080 listener; there is no second port and no peer edge, so
main.go also drops the mTLS listener and the identity mounts in the debug
compose fall away. /_manifest announces the web block only while the SPA is
mounted (honesty rule), always with the single service port.

The DSNs move to the shared database's scram-sha-256 credentials, matching
rev 9's removal of the PKI layer.

Committed web/bun.lock because web/package.json and vite.config.ts are new
in this commit. src/web/dist/index.html is a hand-written placeholder, not
a generated bundle: it keeps bare `go build`/`go vet` working from a clean
checkout, while /web/dist (the real build output) stays gitignored.
This commit is contained in:
2026-10-19 09:28:59 +01:00
parent c58e193038
commit a791232f18
46 changed files with 5392 additions and 61 deletions
+239
View File
@@ -0,0 +1,239 @@
// Global fetch wrapper for the frozen cookie edge (UX §4.6).
// - same-origin relative /web/* URLs only; never sends an auth header
// - on 401 from /web/action/* or /web/auth/me: ONE shared (single-flight)
// bodyless POST /web/auth/refresh, then retry the original once
// - refresh 401 (ERR_REFRESH_*) -> session cleared -> /login?next=...
// - refresh 502 -> keep session, transient banner (no retry loop, no redirect)
// - error extraction: JSON error.message -> code map -> plain-text map ->
// "Something went wrong (HTTP <status>)"
import { signal } from '@preact/signals';
import type { User } from '../types';
import { KEYS, readJSON, writeJSON } from './storage';
// --- Session / profile signals -------------------------------------------------
export const profile = signal<{ username: string; email: string } | null>(null);
// Set when the session is cleared mid-session so the app can redirect to login.
export const sessionCleared = signal(false);
// --- Transient connection banner ------------------------------------------------
export type BannerKind = 'error' | 'warn' | 'info';
export const banner = signal<{ kind: BannerKind; text: string } | null>(null);
let bannerTimer: number | undefined;
export function showBanner(kind: BannerKind, text: string, autoClearMs = 6000): void {
banner.value = { kind, text };
if (bannerTimer) window.clearTimeout(bannerTimer);
bannerTimer = window.setTimeout(() => {
if (banner.value?.text === text) banner.value = null;
}, autoClearMs);
}
export function clearBanner(): void {
banner.value = null;
}
// --- ApiError --------------------------------------------------------------------
export class ApiError extends Error {
readonly status: number | null;
readonly code: string | null;
readonly offline: boolean;
constructor(status: number | null, message: string, code?: string | null, offline = false) {
super(message);
this.status = status;
this.code = code ?? null;
this.offline = offline;
}
}
export function isOfflineError(e: unknown): e is ApiError {
return e instanceof ApiError && e.offline;
}
const CODE_MAP: Record<string, string> = {
ERR_CSRF: 'Request blocked — reload the page and try again',
ERR_NOT_FOUND: 'Not found',
ERR_METHOD_NOT_ALLOWED: 'Something went wrong (unsupported request)',
ERR_INVALID_REQUEST: 'The server rejected the request',
ERR_INTERNAL: 'Server error — try again',
ERR_AUTH_FAILED: 'Incorrect email or password',
ERR_EMAIL_TAKEN: 'An account with this email already exists',
ERR_USERNAME_TAKEN: 'That username is taken',
ERR_TOKEN_INVALID: 'Please sign in again',
ERR_TOKEN_EXPIRED: 'Please sign in again',
};
const TEXT_MAP: Record<string, string> = {
'note not found': 'Not found',
'missing or invalid note id': 'Not found',
'invalid payload': 'The server rejected the request',
'action not implemented': 'Something went wrong (unsupported request)',
'method not allowed': 'Something went wrong (unsupported request)',
};
export function profileFromUser(user: User): { username: string; email: string } {
return { username: user.username, email: user.email };
}
export function cacheProfile(profileValue: { username: string; email: string }): void {
writeJSON(KEYS.profile, profileValue);
}
// --- Error extraction --------------------------------------------------------------
export async function toApiError(res: Response): Promise<ApiError> {
const status = res.status;
let body: unknown;
try {
body = await res.json();
} catch {
body = undefined;
}
if (body && typeof body === 'object') {
const err = (body as { error?: unknown }).error;
if (err && typeof err === 'object') {
const e = err as { code?: unknown; message?: unknown };
if (typeof e.message === 'string' && e.message.length > 0) {
return new ApiError(status, e.message, typeof e.code === 'string' ? e.code : null);
}
if (typeof e.code === 'string') {
const code = e.code;
const msg =
CODE_MAP[code] ??
(status >= 500 ? CODE_MAP.ERR_INTERNAL : 'Something went wrong');
return new ApiError(status, msg, code);
}
}
}
// plain text
let text = '';
try {
text = await res.text();
} catch {
text = '';
}
const lower = text.trim().toLowerCase();
const mapped =
TEXT_MAP[lower] ??
(status >= 500 ? CODE_MAP.ERR_INTERNAL : `Something went wrong (HTTP ${status})`);
return new ApiError(status, mapped);
}
// --- Refresh (single-flight) --------------------------------------------------------
export type RefreshResult = 'ok' | 'cleared' | 'transient';
let refreshInFlight: Promise<RefreshResult> | null = null;
async function doRefresh(): Promise<RefreshResult> {
let res: Response;
try {
res = await fetch('/web/auth/refresh', { method: 'POST' });
} catch {
// network error / offline — keep session, do not clear
return 'transient';
}
if (res.status === 200) {
try {
const data = await res.json();
if (data && data.user) {
const p = profileFromUser(data.user);
profile.value = p;
cacheProfile(p);
}
} catch {
/* ignore body parse */
}
return 'ok';
}
if (res.status === 502) {
showBanner('warn', 'Connection problem — your session is still active.');
return 'transient';
}
// 401 (ERR_REFRESH_* / ERR_UNAUTHENTICATED) or other — cookies already cleared
return 'cleared';
}
export function singleFlightRefresh(): Promise<RefreshResult> {
if (refreshInFlight) return refreshInFlight;
refreshInFlight = doRefresh().finally(() => {
refreshInFlight = null;
});
return refreshInFlight;
}
export function handleSessionCleared(nextPath: string): void {
sessionCleared.value = true;
const target = `/login?next=${encodeURIComponent(nextPath)}`;
window.history.replaceState({}, '', target);
window.dispatchEvent(new PopStateEvent('popstate'));
}
// --- Main fetch wrapper -------------------------------------------------------------
export interface FetchOpts extends RequestInit {
refreshOn401?: boolean;
timeoutMs?: number;
}
export async function apiFetch(path: string, opts: FetchOpts = {}): Promise<Response> {
const { refreshOn401 = true, timeoutMs, ...init } = opts;
if (typeof navigator !== 'undefined' && !navigator.onLine) {
throw new ApiError(null, 'Network error', null, true);
}
let controller: AbortController | undefined;
let timer: number | undefined;
if (timeoutMs) {
controller = new AbortController();
timer = window.setTimeout(() => controller?.abort(), timeoutMs);
}
let res: Response;
try {
res = await fetch(path, { ...init, signal: controller?.signal });
} catch {
if (controller?.signal.aborted) throw new ApiError(null, 'Request timed out', null, true);
throw new ApiError(null, 'Network error', null, true);
} finally {
if (timer) window.clearTimeout(timer);
}
if (res.status === 401 && refreshOn401) {
const result = await singleFlightRefresh();
if (result === 'ok') {
try {
res = await fetch(path, init);
} catch {
throw new ApiError(null, 'Network error', null, true);
}
} else if (result === 'cleared') {
handleSessionCleared(path);
throw new ApiError(401, 'Please sign in again', 'ERR_UNAUTHENTICATED');
} else {
// transient — keep session, surface banner (already shown by refresh)
throw new ApiError(null, 'Connection problem — your session is still active.', null, true);
}
}
return res;
}
/** Perform a request and throw ApiError on any non-2xx (also used for 204). */
export async function apiRequest<T = Response>(
path: string,
init: RequestInit = {},
opts: FetchOpts = {}
): Promise<T> {
const res = await apiFetch(path, { ...init, ...opts });
if (!res.ok) throw await toApiError(res);
if (res.status === 204 || res.status === 200 && res.headers.get('content-length') === '0') {
return undefined as unknown as T;
}
return (await res.json()) as T;
}
export function jsonBody(body: unknown, method = 'POST'): RequestInit {
return {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
};
}
+110
View File
@@ -0,0 +1,110 @@
// Boot / auth gate (UX §3.2).
// GET /web/auth/me (8s) -> 200 authed | 401 -> single-flight refresh -> retry
// refresh 401 -> anonymous -> /login?next= | network/502 -> "unknown" state
// (shell + cached profile + "Can't reach the server." + Retry). Never
// auto-redirect to /login on unknown.
import { signal } from '@preact/signals';
import {
apiFetch,
ApiError,
profile,
profileFromUser,
cacheProfile,
singleFlightRefresh,
} from './api';
import type { AuthState, User } from '../types';
import { KEYS, readJSON, removeKey } from './storage';
export const authState = signal<AuthState>('loading');
// Message shown in the shell banner when state === 'unknown'.
export const bootError = signal<string | null>(null);
// Bump to re-run boot (Retry).
export const bootTick = signal(0);
export function cachedProfileUsername(): string {
const p = readJSON<{ username?: string } | null>(KEYS.profile, null);
return p?.username ?? '';
}
export function setAuthed(user: User): void {
authState.value = 'authed';
const p = profileFromUser(user);
profile.value = p;
cacheProfile(p);
}
export function setAnonymous(): void {
authState.value = 'anonymous';
// web.profile is kept for the boot shell (only logout clears it).
}
export async function boot(): Promise<void> {
authState.value = 'loading';
bootError.value = null;
try {
const res = await apiFetch('/web/auth/me', { refreshOn401: false, timeoutMs: 8000 });
if (res.status === 200) {
const data = (await res.json()) as { user?: User };
if (data?.user) {
setAuthed(data.user);
return;
}
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
return;
}
if (res.status === 401) {
const result = await singleFlightRefresh();
if (result === 'ok') {
const res2 = await apiFetch('/web/auth/me', { refreshOn401: false, timeoutMs: 8000 });
if (res2.status === 200) {
const data = (await res2.json()) as { user?: User };
if (data?.user) {
setAuthed(data.user);
return;
}
}
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
return;
}
if (result === 'cleared') {
setAnonymous();
return;
}
// transient
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
return;
}
// 502 or any other non-200
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
} catch (e) {
// network error / timeout / ApiError -> unknown
if (e instanceof ApiError) {
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
return;
}
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
}
}
/** Optimistic sign-out: clear profile, go to /login, fire-and-forget logout. */
export function logout(): void {
removeKey(KEYS.profile);
profile.value = null;
authState.value = 'anonymous';
const next = '/notes';
const target = `/login?next=${encodeURIComponent(next)}`;
window.history.replaceState({}, '', target);
window.dispatchEvent(new PopStateEvent('popstate'));
// bodyless fire-and-forget (edge always answers 200)
fetch('/web/auth/logout', { method: 'POST' }).catch(() => {
/* best effort */
});
}
+27
View File
@@ -0,0 +1,27 @@
// Per-route editor drafts (UX §3.2 / O3). Keyed by route ("new" or noteId) in
// web.drafts. Debounced persistence + restore-with-notice on return.
import type { Draft } from '../types';
import { KEYS, readJSON, writeJSON, removeKey } from './storage';
type DraftsMap = Record<string, Draft>;
export function getDraft(key: string): Draft | null {
const map = readJSON<DraftsMap>(KEYS.drafts, {});
return map[key] ?? null;
}
export function saveDraft(key: string, draft: Omit<Draft, 'savedAt'>): void {
const map = readJSON<DraftsMap>(KEYS.drafts, {});
map[key] = { ...draft, savedAt: Date.now() };
writeJSON(KEYS.drafts, map);
}
export function clearDraft(key: string): void {
const map = readJSON<DraftsMap>(KEYS.drafts, {});
if (map[key]) {
delete map[key];
writeJSON(KEYS.drafts, map);
}
if (Object.keys(map).length === 0) removeKey(KEYS.drafts);
}
+22
View File
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'preact/hooks';
/** Re-render the caller on an interval (60s tick for relative times). */
export function useNow(intervalMs = 60000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
setNow(Date.now());
const id = window.setInterval(() => setNow(Date.now()), intervalMs);
const onVis = () => {
if (!document.hidden) setNow(Date.now());
};
const onFocus = () => setNow(Date.now());
document.addEventListener('visibilitychange', onVis);
window.addEventListener('focus', onFocus);
return () => {
window.clearInterval(id);
document.removeEventListener('visibilitychange', onVis);
window.removeEventListener('focus', onFocus);
};
}, [intervalMs]);
return now;
}
+48
View File
@@ -0,0 +1,48 @@
// Relative-time formatting, ported verbatim from keepnote-client
// HomeScreen.tsx:15-31: "just now", Nm ago, Nh ago, Nd ago, then "Mon d".
const MONTHS = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
export function relativeTime(iso: string | undefined, now: number): string {
if (!iso) return '';
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return '';
const diff = Math.max(0, now - t);
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
const d = new Date(t);
return `${MONTHS[d.getMonth()]} ${d.getDate()}`;
}
// Whitespace-collapse content and truncate at 140 chars + "…".
export function snippet(content: string): string {
const collapsed = content.replace(/\s+/g, ' ').trim();
if (!collapsed) return '';
if (collapsed.length <= 140) return collapsed;
return collapsed.slice(0, 140) + '…';
}
export function uid(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return 'id-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
}
+55
View File
@@ -0,0 +1,55 @@
// localStorage persistence helpers + the frozen key table (UX §3.2).
// Keys: settings.theme, notes.cache, notes.sync.pending, notes.sync.lastSynced,
// web.profile, web.drafts. No tokens are ever stored (HttpOnly cookies only).
export const KEYS = {
theme: 'settings.theme',
cache: 'notes.cache',
pending: 'notes.sync.pending',
lastSynced: 'notes.sync.lastSynced',
profile: 'web.profile',
drafts: 'web.drafts',
} as const;
export function readJSON<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
if (raw == null) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
export function writeJSON(key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
/* storage full/unavailable — keep the app functional in-memory */
}
}
export function readString(key: string, fallback: string): string {
try {
const v = localStorage.getItem(key);
return v == null ? fallback : v;
} catch {
return fallback;
}
}
export function writeString(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
/* ignore */
}
}
export function removeKey(key: string): void {
try {
localStorage.removeItem(key);
} catch {
/* ignore */
}
}
+28
View File
@@ -0,0 +1,28 @@
import { signal } from '@preact/signals';
import { KEYS, readString, writeString } from './storage';
export type Theme = 'light' | 'dark';
export const theme = signal<Theme>('light');
export function initTheme(): void {
const stored = readString(KEYS.theme, 'light');
const t: Theme = stored === 'dark' ? 'dark' : 'light';
theme.value = t;
applyTheme(t);
}
export function applyTheme(t: Theme): void {
document.documentElement.dataset.theme = t;
document.documentElement.style.colorScheme = t;
writeString(KEYS.theme, t);
}
export function setTheme(t: Theme): void {
theme.value = t;
applyTheme(t);
}
export function toggleTheme(): void {
setTheme(theme.value === 'dark' ? 'light' : 'dark');
}