fix: harden Bulwark Lite login, deep-link replay and settings gating

This commit is contained in:
Linus Rath
2026-09-18 22:16:57 +02:00
parent 5de98bc04b
commit 2d4a562432
43 changed files with 414 additions and 74 deletions
+1 -1
View File
@@ -106,7 +106,7 @@ npm run lite:serve # http://localhost:4173/
`out/` then holds one shell per locale and surface plus the deployer files: `config.json` (server URL, app name, login branding; editable without a rebuild), `policy.json`, `manifest.webmanifest`, `_redirects` and `_headers` (Netlify/Cloudflare), `nginx.conf.example`, `Caddyfile.example`, a `404.html` that replays deep links on hosts without rewrite rules, and `LITE-README.md` with the three-step setup. Build inputs: `LITE_JMAP_SERVER_URL`, `LITE_APP_NAME`, `LITE_ALLOW_CUSTOM_ENDPOINT`, `LITE_REMEMBER_ME`, `LITE_DEMO_MODE`, `LITE_LOCALES` (e.g. `en,de`), `NEXT_PUBLIC_BASE_PATH`.
Lite keeps everything that runs in the browser (multi-account, password and TOTP login, "remember me" via Stalwart refresh tokens, themes, deep links, demo mode). Server-backed features are off: the admin console and setup wizard, plugins and sidebar apps, settings sync, OAuth/SSO, the account security tab, ICS URL subscriptions and CalDAV discovery, sender favicons, office editing, web push, the update banner. `scripts/lite/verify.mjs` fails the build if a client chunk references a server endpoint that is not documented in `scripts/lite/lib.mjs`, and `npm run test:lite-smoke` drives a demo export with Playwright.
Lite keeps everything that runs in the browser (multi-account, password and TOTP login, "remember me" via Stalwart refresh tokens, themes, deep links, demo mode). Server-backed features are off: the admin console and setup wizard, plugins and sidebar apps, settings sync, OAuth/SSO, the account security tab, ICS URL subscriptions and CalDAV discovery, sender favicons, office editing, web push, the update banner, the "Default apps" protocol-handler registration. Password logins go through Stalwart's token endpoints in the browser (`/api/auth` + `/auth/token`, so those need CORS as well); without "remember me" the refresh token lives in sessionStorage and the session ends with the tab. `scripts/lite/verify.mjs` fails the build if a client chunk references a server endpoint that is not documented in `scripts/lite/lib.mjs`, and `npm run test:lite-smoke` drives a demo export with Playwright.
### Development
+7 -3
View File
@@ -9,6 +9,7 @@ import { useTour } from '@/components/tour/tour-provider';
import { Button } from '@/components/ui/button';
import { PlayCircle } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
import { IS_LITE } from '@/lib/lite';
import { MessageListOrderSettings } from './message-list-order-settings';
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
@@ -129,9 +130,12 @@ export function AppearanceSettings() {
</SettingItem>
)}
<SettingItem label={tAdvanced('sender_favicons.label')} description={tAdvanced('sender_favicons.description')}>
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
</SettingItem>
{/* Sender favicons come from the /api/favicon proxy, absent in the static Lite build (components/ui/avatar.tsx). */}
{!IS_LITE && (
<SettingItem label={tAdvanced('sender_favicons.label')} description={tAdvanced('sender_favicons.description')}>
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
</SettingItem>
)}
<SettingItem label={tAdvanced('show_avatars_in_junk.label')} description={tAdvanced('show_avatars_in_junk.description')}>
<ToggleSwitch checked={showAvatarsInJunk} onChange={(checked) => updateSetting('showAvatarsInJunk', checked)} />
@@ -22,6 +22,7 @@ import {
revokePushDevice,
} from '@/lib/web-push';
import type { PushDevice } from '@/lib/web-push';
import { IS_LITE } from '@/lib/lite';
import {
resolveActiveRelayUrl,
resolvePushRelayOptions,
@@ -104,8 +105,10 @@ export function NotificationSettings() {
const busy = pushStatus.kind === 'busy';
const pushEnabled = pushStatus.kind === 'enabled';
// In the static Lite build push is off by design (no service worker, see
// components/service-worker-registration.tsx), not a browser limitation.
const statusDescription = pushStatus.kind === 'unsupported'
? `${t('push.status_unsupported')} ${t('push.ios_hint')}`
? (IS_LITE ? t('push.status_lite') : `${t('push.status_unsupported')} ${t('push.ios_hint')}`)
: busy
? t('push.status_busy')
: pushEnabled
+3 -1
View File
@@ -89,6 +89,7 @@ import { useIsEmbedded } from '@/hooks/use-is-embedded';
import { useIsFocusedProTab } from '@/hooks/use-pane-context';
import { ResizeHandle } from '@/components/layout/resize-handle';
import { useConfig } from '@/hooks/use-config';
import { IS_LITE } from '@/lib/lite';
import { usePolicyStore } from '@/stores/policy-store';
import { cn } from '@/lib/utils';
import {
@@ -535,7 +536,8 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
{ id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' },
// The handler URLs point at /protocol/*, server routes the static Lite export does not ship.
...(IS_LITE ? [] : [{ id: 'protocol_handlers' as Tab, label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' as TabGroup }]),
// Appearance
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
+34 -1
View File
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { render, renderHook, screen, waitFor } from '@testing-library/react';
import { Suspense, createElement } from 'react';
const liteState = vi.hoisted(() => ({ IS_LITE: false }));
@@ -53,6 +54,38 @@ describe('useLiteLinkSegments', () => {
expect(sessionStorage.getItem(LITE_PENDING_PATH_KEY)).toBeNull();
});
it('survives a discarded first render (a sibling suspending on useSearchParams)', async () => {
liteState.IS_LITE = true;
window.history.replaceState({}, '', '/en/contacts/');
sessionStorage.setItem(LITE_PENDING_PATH_KEY, '/en/contacts/c1/edit');
// React throws the first render attempt away when something below the
// Suspense boundary suspends, then renders again from scratch. The hook's
// state initializer runs twice; the parked link must still be there.
let suspended = false;
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
const seen: Array<string[] | undefined> = [];
function Surface() {
const segments = useLiteLinkSegments('contacts', []);
seen.push(segments);
if (!suspended) {
suspended = true;
throw gate;
}
return createElement('span', { 'data-testid': 'segments' }, JSON.stringify(segments));
}
render(createElement(Suspense, { fallback: null }, createElement(Surface)));
release();
await waitFor(() => expect(screen.getByTestId('segments').textContent).toBe('["c1","edit"]'));
// The initializer ran again for the retry and still found the parked link
// (consuming it in the first attempt would have left the retry with []).
expect(seen.length).toBeGreaterThanOrEqual(2);
expect(seen.every((s) => JSON.stringify(s) === '["c1","edit"]')).toBe(true);
expect(`${window.location.pathname}`).toBe('/en/contacts/c1/edit');
expect(sessionStorage.getItem(LITE_PENDING_PATH_KEY)).toBeNull();
});
it('leaves a link parked for another surface untouched', () => {
liteState.IS_LITE = true;
window.history.replaceState({}, '', '/en/mail/');
+10 -5
View File
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from 'react';
import { IS_LITE } from '@/lib/lite';
import { getPathPrefix } from '@/lib/browser-navigation';
import { liteSegmentsFromPath, takePendingLitePath } from '@/lib/lite-link-segments';
import { clearPendingLitePath, liteSegmentsFromPath, peekPendingLitePath } from '@/lib/lite-link-segments';
import type { AppSurface } from '@/lib/deep-links';
/**
@@ -24,7 +24,10 @@ export function useLiteLinkSegments(surface: AppSurface, linkSegments?: string[]
const [segments] = useState<string[] | undefined>(() => {
if (!IS_LITE || typeof window === 'undefined') return linkSegments;
const prefix = getPathPrefix();
const pending = takePendingLitePath(surface, prefix);
// Peek, don't consume: this initializer may run more than once when the
// first render attempt is thrown away (a sibling suspending on
// useSearchParams), and the parked link must still be there for the retry.
const pending = peekPendingLitePath(surface, prefix);
if (pending) {
restoreUrl.current = pending;
return liteSegmentsFromPath(pending, surface, prefix);
@@ -32,13 +35,15 @@ export function useLiteLinkSegments(surface: AppSurface, linkSegments?: string[]
return liteSegmentsFromPath(window.location.pathname, surface, prefix);
});
// Restore the parked URL in an effect: Next's router writes its own
// canonical URL (the surface root it hydrated) from an insertion effect
// during hydration, which would undo a replaceState made while rendering.
// Consume the parked link and restore its URL in an effect, i.e. once this
// render has committed. Next's router writes its own canonical URL (the
// surface root it hydrated) from an insertion effect during hydration, which
// would undo a replaceState made while rendering.
useEffect(() => {
const url = restoreUrl.current;
if (!url) return;
restoreUrl.current = null;
clearPendingLitePath();
try {
window.history.replaceState(window.history.state, '', url);
} catch {
+39 -2
View File
@@ -1,15 +1,18 @@
import { describe, expect, it } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
LITE_API_STRING_ALLOWLIST,
LITE_PENDING_PATH_KEY,
LOCALE_STORAGE_KEY,
buildCaddyExample,
buildHeaders,
buildLiteConfig,
buildLitePolicy,
buildManifest,
buildNginxExample,
buildNotFoundShim,
buildReadme,
buildRedirects,
buildRootRedirect,
@@ -22,6 +25,7 @@ import {
import { collectTestPaths, planRemovals, runPrepare } from '../../scripts/lite/prepare.mjs';
import { runPostbuild } from '../../scripts/lite/postbuild.mjs';
import { verifyExport } from '../../scripts/lite/verify.mjs';
import { LITE_PENDING_PATH_KEY as APP_LITE_PENDING_PATH_KEY } from '../lite';
/** The scripts take `process.env`-shaped input; tests pass plain objects. */
function env(values: Record<string, string> = {}): NodeJS.ProcessEnv {
@@ -95,6 +99,9 @@ describe('lite build helpers', () => {
expect(html).toContain('var fallback = "en"');
expect(html).toContain('var base = "/webmail"');
expect(html).toContain('href="/webmail/en/"');
// The language picked in Settings (zustand persist key of stores/locale-store.ts) wins.
expect(html).toContain(`localStorage.getItem("${LOCALE_STORAGE_KEY}")`);
expect(LOCALE_STORAGE_KEY).toBe('locale-storage');
// An unknown default falls back to the first built locale.
expect(buildRootRedirect({ locales: ['de'], defaultLocale: 'fr' })).toContain('var fallback = "de"');
});
@@ -108,10 +115,33 @@ describe('lite build helpers', () => {
expect(buildHeaders({ basePath: '/w', connectSrc: '*' })).toMatch(/^\/w\/\*/);
});
it('writes a 404 shim that parks deep links for known locale/surface pairs only', () => {
const html = buildNotFoundShim({ basePath: '/webmail', locales: ['de', 'en'] });
expect(html).toContain('var base = "/webmail"');
expect(html).toContain('var locales = ["de","en"]');
expect(html).toContain('var surfaces = ["mail","calendar","contacts","files","settings"]');
expect(html).toContain(`sessionStorage.setItem("${LITE_PENDING_PATH_KEY}"`);
// Only allowlisted segments are ever assembled into the redirect target.
expect(html).toContain('var target = base + "/" + parts[0] + "/" + parts[1] + "/"');
expect(html).toContain('location.replace(target)');
expect(html).toContain('href="/webmail/"');
// The key the shim writes is the one the app reads.
expect(LITE_PENDING_PATH_KEY).toBe(APP_LITE_PENDING_PATH_KEY);
});
it('renders host snippets and the README for the built variant', () => {
expect(buildNginxExample({ basePath: '' })).toContain('(?<surface>mail|calendar|contacts|files|settings)');
expect(buildNginxExample({ basePath: '/w' })).toContain('alias /var/www/bulwark-lite/');
// Root-relative everywhere: a sub-path mount means the files live in <root><basePath>
// (alias does not work inside a regex location and produced a redirect cycle).
const nginxSub = buildNginxExample({ basePath: '/w' });
expect(nginxSub).not.toContain('alias ');
expect(nginxSub).toContain('Unzip the archive into /var/www/bulwark-lite/w');
expect(nginxSub).toContain('try_files $uri $uri/ $uri/index.html /w/$locale/$surface/index.html');
expect(nginxSub).toContain('error_page 404 /w/404.html');
expect(nginxSub).toContain('default_type application/manifest+json');
expect(buildCaddyExample({ basePath: '' })).toContain('try_files {path} {path}/ {path}/index.html /{re.surface.1}/{re.surface.2}/index.html');
expect(buildCaddyExample({ basePath: '/w' })).toContain('rewrite @notfound /w/404.html');
expect(buildReadme({ version: '1.10.0', commit: 'abc1234', basePath: '/w', locales: ['en'] })).toContain('unzip into `<web root>/w`');
const readme = buildReadme({ version: '1.10.0', commit: 'abc1234', basePath: '/w', locales: ['en'], jmapServerUrl: 'https://m.example', demoMode: true });
expect(readme).toContain('# Bulwark Lite 1.10.0 (abc1234)');
expect(readme).toContain('Demo mode is ON');
@@ -203,11 +233,18 @@ describe('prepare / postbuild / verify against a fake checkout', () => {
expect(existsSync(join(out, file)), file).toBe(true);
}
expect(collectApiStrings(join(out, '_next', 'static'))).toEqual(['/api/config', '/api/plugins']);
// Next's default not-found page was replaced by the park-and-replay shim.
expect(readFileSync(join(out, '404.html'), 'utf8')).toContain(LITE_PENDING_PATH_KEY);
const verdict = verifyExport({ root });
expect(verdict.problems).toEqual([]);
expect(verdict.locales).toEqual(['de', 'en']);
// An export whose 404.html is still Next's default fails verification.
writeFileSync(join(out, '404.html'), '<html>404: This page could not be found.</html>');
expect(verifyExport({ root }).problems.join('\n')).toContain('404.html is not the Lite shim');
writeFileSync(join(out, '404.html'), buildNotFoundShim({ basePath: '/w', locales: ['de', 'en'] }));
// A new, undocumented endpoint in a chunk is a verification failure.
writeFileSync(join(out, '_next', 'static', 'chunks', 'new.js'), 'apiFetch("/api/brand-new")');
expect(verifyExport({ root }).problems.join('\n')).toContain('/api/brand-new');
+27 -6
View File
@@ -81,18 +81,39 @@ export function stashPendingLitePath(pathWithSearch: string): boolean {
}
/**
* Consumes a parked deep link if it belongs to `surface`. Anything parked for
* another surface is left alone so its own shell can pick it up.
* Reads a parked deep link if it belongs to `surface`, leaving it in place.
* Anything parked for another surface is ignored so its own shell can pick it
* up. Safe to call from a render (or a `useState` initializer): React may
* discard that render attempt when a sibling suspends (contacts reads
* `useSearchParams` under a Suspense boundary) and run it again, so the entry
* must survive until `clearPendingLitePath` runs from a committed effect.
*/
export function takePendingLitePath(surface: AppSurface, prefix = ''): string | null {
export function peekPendingLitePath(surface: AppSurface, prefix = ''): string | null {
try {
const pending = sessionStorage.getItem(LITE_PENDING_PATH_KEY);
if (!pending) return null;
const parsed = parseLitePath(pending, prefix);
if (parsed.surface !== surface) return null;
sessionStorage.removeItem(LITE_PENDING_PATH_KEY);
return pending;
return parsed.surface === surface ? pending : null;
} catch {
return null;
}
}
/** Removes the parked deep link once the surface has committed to it. */
export function clearPendingLitePath(): void {
try {
sessionStorage.removeItem(LITE_PENDING_PATH_KEY);
} catch {
// ignore
}
}
/**
* Consumes a parked deep link if it belongs to `surface` (peek + clear in one
* step, for callers that run outside a React render).
*/
export function takePendingLitePath(surface: AppSurface, prefix = ''): string | null {
const pending = peekPendingLitePath(surface, prefix);
if (pending) clearPendingLitePath();
return pending;
}
+1
View File
@@ -1157,6 +1157,7 @@
"status_active": "نشط على هذا الجهاز",
"status_inactive": "غير مفعّل على هذا الجهاز",
"status_unsupported": "هذا المتصفح لا يدعم Web Push",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"status_busy": "جارٍ العمل…",
"enable": "تفعيل",
"reenable": "إعادة التسجيل",
+1
View File
@@ -1157,6 +1157,7 @@
"status_active": "Actiu en aquest dispositiu",
"status_inactive": "No activat en aquest dispositiu",
"status_unsupported": "Aquest navegador no admet Web Push",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"status_busy": "Treballant…",
"enable": "Activa",
"reenable": "Torna a registrar",
+1
View File
@@ -1185,6 +1185,7 @@
"status_busy": "Pracuji…",
"status_inactive": "Není povoleno na tomto zařízení",
"status_unsupported": "Tento prohlížeč nepodporuje Web Push",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"title": "Oznámení na pozadí",
"devices_title": "Registrovaná zařízení",
"devices_desc": "Všechna zařízení registrovaná pro příjem oznamení na pozadí u tohoto účtu. Odvolání okamžitě zastaví jejich příjem.",
+1
View File
@@ -1157,6 +1157,7 @@
"status_active": "Aktiv på denne enhed",
"status_inactive": "Ikke aktiveret på denne enhed",
"status_unsupported": "Denne browser understøtter ikke Web Push",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"status_busy": "Arbejder…",
"enable": "Aktivér",
"reenable": "Genregistrér",
+1
View File
@@ -1185,6 +1185,7 @@
"status_busy": "Wird verarbeitet…",
"status_inactive": "Auf diesem Gerät nicht aktiviert",
"status_unsupported": "Dieser Browser unterstützt Web Push nicht",
"status_lite": "Web Push ist in Bulwark Lite nicht verfügbar; es benötigt den Bulwark-Server.",
"title": "Hintergrundbenachrichtigungen",
"devices_title": "Registrierte Geräte",
"devices_desc": "Alle Geräte, die Hintergrundbenachrichtigungen für dieses Konto erhalten. Wird eines widerrufen, empfängt es sofort keine mehr.",
+1
View File
@@ -1157,6 +1157,7 @@
"status_active": "Active on this device",
"status_inactive": "Not enabled on this device",
"status_unsupported": "This browser does not support Web Push",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"status_busy": "Working…",
"enable": "Enable",
"reenable": "Re-register",
+1
View File
@@ -1185,6 +1185,7 @@
"status_busy": "Trabajando…",
"status_inactive": "No habilitado en este dispositivo",
"status_unsupported": "Este navegador no admite Web Push",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"title": "Notificaciones en segundo plano",
"devices_title": "Dispositivos registrados",
"devices_desc": "Todos los dispositivos registrados para recibir notificaciones en segundo plano de esta cuenta. Al revocar uno, deja de recibirlas de inmediato.",
+1
View File
@@ -1157,6 +1157,7 @@
"status_active": "فعال روی این دستگاه",
"status_inactive": "فعال نشده",
"status_unsupported": "مرورگر پشتیبانی نمی‌کند",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"status_busy": "در حال کار…",
"enable": "فعال کردن",
"reenable": "ثبت مجدد",
+1
View File
@@ -1185,6 +1185,7 @@
"status_busy": "Traitement en cours…",
"status_inactive": "Non activé sur cet appareil",
"status_unsupported": "Ce navigateur ne prend pas en charge Web Push",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"title": "Notifications en arrière-plan",
"devices_title": "Appareils enregistrés",
"devices_desc": "Tous les appareils enregistrés pour recevoir les notifications en arrière-plan de ce compte. Révoquer un appareil l’empêche immédiatement d’en recevoir.",
+1
View File
@@ -1143,6 +1143,7 @@
"status_active": "פעיל בהתקן זה",
"status_inactive": "לא מופעל בהתקן זה",
"status_unsupported": "דפדפן זה אינו תומך בדחיפה ברשת",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"status_busy": "עובד…",
"enable": "הפעל",
"reenable": "רשום מחדש",
+1
View File
@@ -1157,6 +1157,7 @@
"status_active": "Aktív ezen az eszközön",
"status_inactive": "Nincs engedélyezve ezen az eszközön",
"status_unsupported": "Ez a böngésző nem támogatja a Web Push-t",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"status_busy": "Munka...",
"enable": "Engedélyezés",
"reenable": "Újraregisztráció",
+1
View File
@@ -1185,6 +1185,7 @@
"status_busy": "In elaborazione…",
"status_inactive": "Non abilitato su questo dispositivo",
"status_unsupported": "Questo browser non supporta Web Push",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"title": "Notifiche in background",
"devices_title": "Dispositivi registrati",
"devices_desc": "Tutti i dispositivi registrati per ricevere le notifiche in background di questo account. Revocandone uno, smette subito di riceverle.",
+1
View File
@@ -1185,6 +1185,7 @@
"status_busy": "処理中…",
"status_inactive": "このデバイスでは有効になっていません",
"status_unsupported": "このブラウザは Web Push をサポートしていません",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"title": "バックグラウンド通知",
"devices_title": "登録済みのデバイス",
"devices_desc": "このアカウントのバックグラウンド通知を受け取るよう登録されているデバイスの一覧です。取り消すと、そのデバイスはすぐに受信を停止します。",
+1
View File
@@ -1185,6 +1185,7 @@
"status_busy": "처리 중…",
"status_inactive": "이 기기에서 활성화되지 않음",
"status_unsupported": "이 브라우저는 Web Push를 지원하지 않습니다",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"title": "백그라운드 알림",
"devices_title": "등록된 기기",
"devices_desc": "이 계정의 백그라운드 알림을 받도록 등록된 모든 기기입니다. 해지하면 해당 기기는 즉시 수신을 멈추니다.",
+1
View File
@@ -1185,6 +1185,7 @@
"status_busy": "Notiek darbs…",
"status_inactive": "Nav iespējots šajā ierīcē",
"status_unsupported": "Šī pārlūkprogramma neatbalsta Web Push",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"title": "Fona paziņojumi",
"devices_title": "Reģistrētās ierīces",
"devices_desc": "Visas ierīces, kas reģistrētas šī konta fona paziņojumu saņemšanai. Atsaucot ierīci, tā nekavējoties pārstāj tās saņemt.",
+1
View File
@@ -1157,6 +1157,7 @@
"status_active": "Энэ төхөөрөмжид идэвхтэй байна",
"status_inactive": "Энэ төхөөрөмж дээр идэвхжээгүй",
"status_unsupported": "Энэ хөтөч Web Push-ийг дэмждэггүй",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"status_busy": "Ажиллаж байна…",
"enable": "Идэвхжүүлэх",
"reenable": "Дахин бүртгүүлэх",
+1
View File
@@ -1157,6 +1157,7 @@
"status_active": "Aktiv på denne enheten",
"status_inactive": "Ikke aktivert på denne enheten",
"status_unsupported": "Denne nettleseren støtter ikke Web Push",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"status_busy": "Behandler...",
"enable": "Aktiver",
"reenable": "Registrer på nytt",
+1
View File
@@ -1185,6 +1185,7 @@
"status_busy": "Bezig…",
"status_inactive": "Niet ingeschakeld op dit apparaat",
"status_unsupported": "Deze browser ondersteunt geen Web Push",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"title": "Achtergrondmeldingen",
"devices_title": "Geregistreerde apparaten",
"devices_desc": "Alle apparaten die achtergrondmeldingen voor dit account ontvangen. Trek je er een in, dan stopt de ontvangst meteen.",
+1
View File
@@ -1185,6 +1185,7 @@
"status_busy": "Przetwarzanie…",
"status_inactive": "Niewłączone na tym urządzeniu",
"status_unsupported": "Ta przeglądarka nie obsługuje Web Push",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"title": "Powiadomienia w tle",
"devices_title": "Zarejestrowane urządzenia",
"devices_desc": "Wszystkie urządzenia zarejestrowane do odbioru powiadomień w tle dla tego konta. Unieważnienie natychmiast wstrzymuje ich odbiór.",
+1
View File
@@ -1185,6 +1185,7 @@
"status_busy": "Processando…",
"status_inactive": "Não ativado neste dispositivo",
"status_unsupported": "Este navegador não oferece suporte a Web Push",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"title": "Notificações em segundo plano",
"devices_title": "Dispositivos registrados",
"devices_desc": "Todos os dispositivos registrados para receber notificações em segundo plano desta conta. Revogar um deles interrompe o recebimento imediatamente.",
+1
View File
@@ -1157,6 +1157,7 @@
"status_active": "Activ pe acest dispozitiv",
"status_inactive": "Nu este activat pe acest dispozitiv",
"status_unsupported": "Acest browser nu acceptă Web Push",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"status_busy": "Se lucrează…",
"enable": "Activați",
"reenable": "Reînregistrează-te",
+1
View File
@@ -1185,6 +1185,7 @@
"status_busy": "Выполняется…",
"status_inactive": "Не включено на этом устройстве",
"status_unsupported": "Этот браузер не поддерживает Web Push",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"title": "Фоновые уведомления",
"devices_title": "Зарегистрированные устройства",
"devices_desc": "Все устройства, зарегистрированные для получения фоновых уведомлений этой учётной записи. После отзыва устройство сразу перестаёт их получать.",
+1
View File
@@ -1181,6 +1181,7 @@
"status_active": "Aktívne na tomto zariadení",
"status_inactive": "Nie je povolené na tomto zariadení",
"status_unsupported": "Tento prehliadač nepodporuje Web Push",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"status_busy": "Pracujem…",
"enable": "Povoliť",
"reenable": "Znovu zaregistrovať",
+1
View File
@@ -1185,6 +1185,7 @@
"status_busy": "Çalışıyor…",
"status_inactive": "Bu cihazda etkinleştirilmedi",
"status_unsupported": "Bu tarayıcı Web Push'u desteklemiyor",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"title": "Arka plan bildirimleri",
"devices_title": "Kayıtlı cihazlar",
"devices_desc": "Bu hesap için arka plan bildirimleri almak üzere kayıtlı tüm cihazlar. Birini iptal ettiğinizde o cihaz bildirimleri hemen almayı bırakır.",
+1
View File
@@ -1185,6 +1185,7 @@
"status_busy": "Виконується…",
"status_inactive": "Не ввімкнено на цьому пристрої",
"status_unsupported": "Цей браузер не підтримує Web Push",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"title": "Фонові сповіщення",
"devices_title": "Зареєстровані пристрої",
"devices_desc": "Усі пристрої, зареєстровані для отримання фонових сповіщень цього облікового запису. Після відкликання пристрій одразу перестає їх отримувати.",
+1
View File
@@ -1157,6 +1157,7 @@
"status_active": "已在此裝置上啟用",
"status_inactive": "在此裝置上未啟用",
"status_unsupported": "此瀏覽器不支援 Web Push",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"status_busy": "工作中…",
"enable": "啟用",
"reenable": "重新註冊",
+1
View File
@@ -1185,6 +1185,7 @@
"status_busy": "工作中…",
"status_inactive": "在此设备上未启用",
"status_unsupported": "此浏览器不支持 Web Push",
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
"title": "后台通知",
"devices_title": "已注册的设备",
"devices_desc": "已注册接收此账户后台通知的所有设备。撤销后,该设备会立即停止接收。",
+126 -13
View File
@@ -53,6 +53,16 @@ export const LITE_SURFACES = ["mail", "calendar", "contacts", "files", "settings
/** Locales written right-to-left; the root shim sets `dir` for them. */
export const RTL_LOCALES = ["ar", "fa", "he"];
/**
* sessionStorage key the 404 shim parks a deep link under. Mirrors
* LITE_PENDING_PATH_KEY in lib/lite.ts (a TS module this script cannot
* import); lib/__tests__/lite-scripts.test.ts pins the two together.
*/
export const LITE_PENDING_PATH_KEY = "bulwark-lite:pending-path";
/** zustand persist key of stores/locale-store.ts (the user's language choice). */
export const LOCALE_STORAGE_KEY = "locale-storage";
/**
* `/api/` strings that legitimately survive in the Lite client chunks. Each
* one is either gated at call time (`IS_LITE`, a policy flag, a config flag
@@ -197,7 +207,11 @@ export function buildRootRedirect({ basePath = "", locales, defaultLocale = "en"
return null;
}
var chosen = null;
try { chosen = pick(localStorage.getItem("NEXT_LOCALE")); } catch (e) {}
// The language picked in Settings (stores/locale-store.ts) wins over the browser's list.
try {
var stored = JSON.parse(localStorage.getItem(${JSON.stringify(LOCALE_STORAGE_KEY)}) || "null");
chosen = pick(stored && stored.state && stored.state.locale);
} catch (e) {}
var langs = navigator.languages || [navigator.language];
for (var j = 0; !chosen && j < langs.length; j++) chosen = pick(langs[j]);
location.replace(base + "/" + (chosen || fallback) + "/" + location.search + location.hash);
@@ -209,6 +223,66 @@ export function buildRootRedirect({ basePath = "", locales, defaultLocale = "en"
`;
}
/**
* 404.html for hosts without rewrite rules (GitHub Pages, S3 website hosting).
*
* `next build` exports Next's default not-found page as 404.html: the
* `[...rest]` catch-all that renders app/(main)/not-found.tsx in the server
* build is deleted for the export, and a route group's not-found.tsx only
* serves `notFound()` calls inside that group. So the park-and-replay logic
* lives in this dependency-free shim instead: a deep link below a known
* surface is parked in sessionStorage and the surface shell is loaded, which
* hands the link over (hooks/use-lite-link-segments.ts). Only `<locale>` and
* `<surface>` from the allowlists ever reach `location.replace`, so a crafted
* URL cannot turn this into an open redirect.
*/
export function buildNotFoundShim({ basePath = "", locales, surfaces = LITE_SURFACES }) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>404</title>
<script>
(function () {
var base = ${JSON.stringify(basePath)};
var locales = ${JSON.stringify(locales)};
var surfaces = ${JSON.stringify(surfaces)};
var path = location.pathname;
if (base) {
if (path !== base && path.indexOf(base + "/") !== 0) return;
path = path.slice(base.length) || "/";
}
var parts = path.split("/").filter(Boolean);
if (parts.length < 2 || locales.indexOf(parts[0]) === -1 || surfaces.indexOf(parts[1]) === -1) return;
var target = base + "/" + parts[0] + "/" + parts[1] + "/";
// The shell itself is missing (host misconfigured): show the 404 instead of looping.
if (location.pathname === target) return;
try { sessionStorage.setItem(${JSON.stringify(LITE_PENDING_PATH_KEY)}, location.pathname + location.search); } catch (e) {}
location.replace(target);
})();
</script>
<style>
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; font-family: system-ui, sans-serif; background: #fff; color: #111; }
@media (prefers-color-scheme: dark) { body { background: #0a0a0a; color: #ededed; } }
main { text-align: center; padding: 1rem; }
h1 { font-size: 2.25rem; margin: 0 0 .5rem; }
p { margin: 0 0 1.5rem; opacity: .7; }
a { color: inherit; }
</style>
</head>
<body>
<main>
<h1>404</h1>
<p>This page could not be found.</p>
<a href="${basePath}/">Go home</a>
</main>
</body>
</html>
`;
}
/** Netlify / Cloudflare Pages rewrite rules. */
export function buildRedirects({ basePath = "", locales, surfaces = LITE_SURFACES }) {
const lines = [
@@ -252,24 +326,46 @@ ${basePath || ""}/_next/static/*
`;
}
/**
* Where the unzipped folder has to live for the nginx/Caddy examples: every
* rule below is root-relative, so a sub-path mount means the files sit in a
* folder named after the mount path inside the web root (no `alias` needed,
* which does not work inside a regex `location`).
*/
export function exampleDocRoot(basePath = "") {
return { root: "/var/www/bulwark-lite", files: `/var/www/bulwark-lite${basePath}` };
}
export function buildNginxExample({ basePath = "", surfaces = LITE_SURFACES }) {
const location = basePath ? `${basePath}/` : "/";
const { root, files } = exampleDocRoot(basePath);
const surfaceAlternation = surfaces.join("|");
return `# Bulwark Lite - nginx example. Serve the unzipped folder at ${location}
# and rewrite deep links to the owning surface's shell.
return `# Bulwark Lite - nginx example. Unzip the archive into ${files}
# so that ${basePath}/index.html is served at ${location}; deep links below a surface
# are rewritten to that surface's shell.
server {
listen 80;
server_name webmail.example.com;
root /var/www/bulwark-lite;
root ${root};
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# See _headers for a Content-Security-Policy that matches your JMAP server.
# Hashed assets never change. (add_header inside a location replaces the
# inherited set, so the security headers are repeated here.)
location ${basePath}/_next/static/ {
${basePath ? `alias /var/www/bulwark-lite/_next/static/;\n ` : ""}expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
}
# Older mime.types files do not know the PWA manifest extension.
location = ${basePath}/manifest.webmanifest {
types { }
default_type application/manifest+json;
}
# /<locale>/<surface>/anything -> /<locale>/<surface>/index.html
@@ -278,7 +374,7 @@ server {
}
location ${location} {
${basePath ? `alias /var/www/bulwark-lite/;\n ` : ""}try_files $uri $uri/ $uri/index.html =404;
try_files $uri $uri/ $uri/index.html =404;
error_page 404 ${basePath}/404.html;
}
}
@@ -286,10 +382,12 @@ server {
}
export function buildCaddyExample({ basePath = "", surfaces = LITE_SURFACES }) {
const { root, files } = exampleDocRoot(basePath);
const surfaceAlternation = surfaces.join("|");
return `# Bulwark Lite - Caddy example.
return `# Bulwark Lite - Caddy example. Unzip the archive into ${files}
# so that ${basePath}/index.html is served at ${basePath || ""}/.
webmail.example.com {
root * /var/www/bulwark-lite
root * ${root}
encode gzip
header {
@@ -298,16 +396,27 @@ webmail.example.com {
Referrer-Policy strict-origin-when-cross-origin
}
@manifest path ${basePath}/manifest.webmanifest
header @manifest Content-Type application/manifest+json
# /<locale>/<surface>/anything -> /<locale>/<surface>/index.html
@surface path_regexp surface ^${basePath}/([a-zA-Z-]+)/(${surfaceAlternation})(/.*)?$
handle @surface {
try_files {path} {path}/ {path}/index.html ${basePath}/{re.surface.1}/{re.surface.2}/index.html
}
handle {
try_files {path} {path}/ {path}/index.html ${basePath}/404.html
try_files {path} {path}/ {path}/index.html
}
file_server
# Anything else is a real 404, answered by the shim that replays parked deep links.
handle_errors {
@notfound expression {http.error.status_code} == 404
rewrite @notfound ${basePath}/404.html
file_server
}
}
`;
}
@@ -351,7 +460,7 @@ Deep links such as \`${basePath}/en/mail/thread/abc\` are served by the shell at
- Netlify / Cloudflare Pages: the shipped \`_redirects\` and \`_headers\` files do this.
- nginx: see \`nginx.conf.example\`.
- Caddy: see \`Caddyfile.example\`.
- GitHub Pages and other hosts without rewrites: \`404.html\` replays the link
${basePath ? ` Both examples serve root-relative paths, so unzip into \`<web root>${basePath}\`\n (e.g. \`${exampleDocRoot(basePath).files}\`), not into the web root itself.\n` : ""}- GitHub Pages and other hosts without rewrites: \`404.html\` replays the link
in the browser. It works, with one extra page load.
\`_headers\` also carries the recommended security headers. Adjust
@@ -367,7 +476,8 @@ Not available in Lite (they need the Node.js server): the admin console and
setup wizard, plugins and sidebar apps, settings sync across devices, OAuth /
SSO login, the account security tab (app passwords, 2FA setup), ICS URL
subscriptions and CalDAV discovery, sender favicons, office (WOPI) editing,
web push notifications, the update banner, device pairing.
web push notifications, the update banner, device pairing, and the
"Default apps" page (registering Lite as the mailto:/webcal: handler).
## Security notes
@@ -380,7 +490,10 @@ web push notifications, the update banner, device pairing.
control and keep the Content-Security-Policy from \`_headers\`.
- Servers without Stalwart's token login (\`/api/auth\`) fall back to Basic
auth. "Remember me" is then hidden; the credentials stay in sessionStorage
for the lifetime of the tab so a reload does not sign you out.
for the lifetime of the tab so a reload does not sign you out. The same
fallback applies when a reverse proxy in front of Stalwart answers
\`/api/auth\` without CORS headers: the login still works, but "remember me"
then only lasts for the tab, so add the CORS rule from step 3 there too.
- Because the export contains inline scripts, \`script-src\` must allow
\`'unsafe-inline'\`. Email HTML is still rendered sanitised in a sandboxed
frame, exactly as in the full build.
+4 -1
View File
@@ -5,7 +5,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
buildCaddyExample, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, buildReadme, buildRedirects, buildRootRedirect, discoverBuiltLocales, isMainModule, normalizeBasePath, resolveRepoRoot,
buildCaddyExample, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, buildNotFoundShim, buildReadme, buildRedirects, buildRootRedirect, discoverBuiltLocales, isMainModule, normalizeBasePath, resolveRepoRoot,
} from "./lib.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
@@ -35,6 +35,9 @@ export function runPostbuild({ root = repoRoot, env = process.env, log = console
"policy.json": JSON.stringify(buildLitePolicy(), null, 2) + "\n",
"manifest.webmanifest": JSON.stringify(buildManifest({ appName: config.appName, basePath }), null, 2) + "\n",
"index.html": buildRootRedirect({ basePath, locales, defaultLocale }),
// Replaces Next's default not-found page: only this shim replays deep links
// on hosts without rewrite rules (see buildNotFoundShim).
"404.html": buildNotFoundShim({ basePath, locales }),
"_redirects": buildRedirects({ basePath, locales }),
"_headers": buildHeaders({ basePath, connectSrc }),
"nginx.conf.example": buildNginxExample({ basePath }),
+13 -6
View File
@@ -3,15 +3,22 @@
// fallback the shipped nginx/Caddy snippets do. Used by the Playwright smoke
// test and handy for a local look at out/:
//
// node scripts/lite/serve.mjs [outDir=out] [port=4173] [basePath=]
// node scripts/lite/serve.mjs [outDir=out] [port=4173] [basePath=] [--plain]
//
// `--plain` switches the SPA fallback off and answers unknown paths with
// 404.html (status 404), the way GitHub Pages or an S3 bucket would; that is
// the path the 404 shim's park-and-replay exists for.
import { createServer } from "node:http";
import { createReadStream, existsSync, statSync } from "node:fs";
import { extname, join, normalize, resolve } from "node:path";
import { LITE_SURFACES } from "./lib.mjs";
const root = resolve(process.argv[2] || "out");
const port = Number(process.argv[3] || 4173);
const basePath = (process.argv[4] || "").replace(/\/+$/, "");
const flags = process.argv.slice(2).filter((a) => a.startsWith("--"));
const args = process.argv.slice(2).filter((a) => !a.startsWith("--"));
const plain = flags.includes("--plain");
const root = resolve(args[0] || "out");
const port = Number(args[1] || 4173);
const basePath = (args[2] || "").replace(/\/+$/, "");
const TYPES = {
".html": "text/html; charset=utf-8",
@@ -56,7 +63,7 @@ export function resolveRequest(pathname) {
}
const file = fileFor(pathname);
if (file) return { status: 200, file };
const match = pathname.match(surfacePattern);
const match = plain ? null : pathname.match(surfacePattern);
if (match) {
const shell = join(root, match[1], match[2], "index.html");
if (existsSync(shell)) return { status: 200, file: shell };
@@ -71,5 +78,5 @@ createServer((req, res) => {
res.writeHead(404, { "content-type": "text/plain" });
res.end("not found");
}).listen(port, () => {
console.log(`[lite] serving ${root} at http://localhost:${port}${basePath}/`);
console.log(`[lite] serving ${root} at http://localhost:${port}${basePath}/${plain ? " (plain: no SPA fallback, 404.html for unknown paths)" : ""}`);
});
+41 -12
View File
@@ -1,4 +1,5 @@
import { test, expect, type Page } from "@playwright/test";
import { plainPort } from "./playwright.config";
/**
* Drives a Lite export built with LITE_DEMO_MODE=true. Everything runs in the
@@ -10,16 +11,20 @@ import { test, expect, type Page } from "@playwright/test";
* - the demo account signs in and the mail list renders,
* - top-level navigation between static shells works client-side,
* - a deep link reload keeps its URL and the shell still hydrates,
* - a deep link parked by the 404 shim is replayed by the surface,
* - on a host without rewrites, 404.html parks the deep link and the
* surface replays it (the exported 404.html must be the Lite shim, not
* Next's default not-found page),
* - no request ever targets a server endpoint on the Lite origin.
*/
async function loginAsDemo(page: Page) {
await page.goto("/en/login/");
const PLAIN_ORIGIN = `http://localhost:${plainPort}`;
async function loginAsDemo(page: Page, origin = "") {
await page.goto(`${origin}/en/login/`);
const demoButton = page.getByRole("button", { name: /demo/i }).first();
await expect(demoButton).toBeVisible({ timeout: 30_000 });
await demoButton.click();
await page.waitForURL(/\/en\/?(mail\/?)?(\?.*)?$/, { timeout: 30_000 });
await page.waitForURL(/\/en\/?(mail\/?.*)?(\?.*)?$/, { timeout: 30_000 });
}
test.describe("Bulwark Lite demo export", () => {
@@ -45,6 +50,15 @@ test.describe("Bulwark Lite demo export", () => {
expect(apiRequests).toEqual([]);
});
test("the root shim honours the language chosen in Settings", async ({ page }) => {
await page.goto("/en/login/");
await page.evaluate(() => {
localStorage.setItem("locale-storage", JSON.stringify({ state: { locale: "de" }, version: 0 }));
});
await page.goto("/");
await page.waitForURL(/\/de\/$/);
});
test("demo login, surface switch and deep-link reload", async ({ page }) => {
await loginAsDemo(page);
@@ -68,16 +82,31 @@ test.describe("Bulwark Lite demo export", () => {
expect(apiRequests).toEqual([]);
});
test("a deep link parked by the 404 shim is replayed on the surface", async ({ page }) => {
await loginAsDemo(page);
// What app/(main)/not-found.tsx does on a host without rewrite rules:
// park the link, load the surface root.
await page.evaluate(() => {
sessionStorage.setItem("bulwark-lite:pending-path", "/en/mail/folder/inbox");
});
await page.goto("/en/mail/");
test("404.html is the Lite shim, not Next's default not-found page", async ({ page }) => {
const res = await page.request.get("/404.html");
expect(res.ok()).toBe(true);
const html = await res.text();
expect(html).toContain("bulwark-lite:pending-path");
expect(html).not.toContain("__next_f");
});
test("on a host without rewrites a deep link is parked by 404.html and replayed", async ({ page }) => {
// The plain server answers /en/mail/folder/inbox with 404.html (status
// 404), exactly like GitHub Pages. The shim parks the link, loads
// /en/mail/, and the surface restores the URL.
await loginAsDemo(page, PLAIN_ORIGIN);
const response = await page.goto(`${PLAIN_ORIGIN}/en/mail/folder/inbox`);
expect(response?.status()).toBe(404);
await page.waitForFunction(() => window.location.pathname === "/en/mail/folder/inbox", null, { timeout: 30_000 });
await expect(page.locator("body")).toContainText(/inbox/i, { timeout: 30_000 });
expect(await page.evaluate(() => sessionStorage.getItem("bulwark-lite:pending-path"))).toBeNull();
// A path outside the known surfaces stays a plain 404 page.
const dead = await page.goto(`${PLAIN_ORIGIN}/en/nope`);
expect(dead?.status()).toBe(404);
await page.waitForTimeout(500);
expect(new URL(page.url()).pathname).toBe("/en/nope");
await expect(page.locator("body")).toContainText(/404/);
expect(apiRequests).toEqual([]);
});
});
+19 -8
View File
@@ -1,9 +1,12 @@
import { defineConfig } from "@playwright/test";
// Smoke test for a finished Lite export (`npm run build:lite` with
// LITE_DEMO_MODE=true). Serves out/ with the dependency-free static server
// that applies the same SPA fallback the shipped host snippets do.
// LITE_DEMO_MODE=true). Serves out/ twice with the dependency-free static
// server: once with the SPA fallback the shipped host snippets apply, and
// once "plain" (unknown paths answer 404.html with status 404, like GitHub
// Pages or an S3 bucket) to exercise the 404 shim's park-and-replay path.
const port = Number(process.env.LITE_SMOKE_PORT || 4173);
export const plainPort = Number(process.env.LITE_SMOKE_PLAIN_PORT || port + 1);
export default defineConfig({
testDir: ".",
@@ -17,10 +20,18 @@ export default defineConfig({
trace: "retain-on-failure",
},
projects: [{ name: "chromium", use: { browserName: "chromium" } }],
webServer: {
command: `node scripts/lite/serve.mjs out ${port}`,
port,
reuseExistingServer: !process.env.CI,
cwd: process.cwd(),
},
webServer: [
{
command: `node scripts/lite/serve.mjs out ${port}`,
port,
reuseExistingServer: !process.env.CI,
cwd: process.cwd(),
},
{
command: `node scripts/lite/serve.mjs out ${plainPort} "" --plain`,
port: plainPort,
reuseExistingServer: !process.env.CI,
cwd: process.cwd(),
},
],
});
+9 -2
View File
@@ -2,10 +2,10 @@
// Guards for a finished Lite export: the files a deployer relies on exist,
// and the client chunks reference no server endpoint outside the documented
// allowlist (lib.mjs). Exit 1 with a readable list otherwise.
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import {
collectApiStrings, discoverBuiltLocales, isMainModule, resolveRepoRoot, unexpectedApiStrings,
LITE_PENDING_PATH_KEY, collectApiStrings, discoverBuiltLocales, isMainModule, resolveRepoRoot, unexpectedApiStrings,
} from "./lib.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
@@ -31,6 +31,13 @@ export function verifyExport({ root = repoRoot } = {}) {
if (!existsSync(join(outDir, file))) problems.push(`missing out/${file}`);
}
// Next writes its own default not-found page as 404.html; postbuild must
// have replaced it with the shim that parks and replays deep links.
const notFound = join(outDir, "404.html");
if (existsSync(notFound) && !readFileSync(notFound, "utf8").includes(LITE_PENDING_PATH_KEY)) {
problems.push("out/404.html is not the Lite shim (deep links on hosts without rewrites would dead-end); run postbuild");
}
const apiStrings = collectApiStrings(join(outDir, "_next", "static"));
const unexpected = unexpectedApiStrings(apiStrings);
for (const s of unexpected) problems.push(`client chunk references undocumented server endpoint ${s} (add an IS_LITE gate or document it in scripts/lite/lib.mjs)`);
+34 -4
View File
@@ -146,17 +146,47 @@ describe('auth-store in the static Lite build', () => {
expect(JSON.parse(sessionStorage.getItem('bulwark-lite:refresh:0')!).refreshToken).toBe('RT-1');
});
it('a plain password login without "remember me" connects with Basic auth and stores nothing', async () => {
it('a plain password login without "remember me" still uses token login and keeps the refresh token with the tab', async () => {
const { calls } = stalwartFetch();
const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw');
expect(ok).toBe(true);
expect(calls).toEqual([]);
expect(calls).toEqual([`POST ${SERVER}/api/auth`, `POST ${SERVER}/auth/token`]);
expect(connectSpy).toHaveBeenCalledTimes(1);
expect(useAuthStore.getState().authMode).toBe('oauth');
expect(useAuthStore.getState().client?.getAuthHeader()).toBe('Bearer AT-1');
// Tab-scoped: sessionStorage only, so a reload keeps the session and closing the tab ends it.
expect(sessionStorage.getItem('bulwark-lite:refresh:0')).toContain('"refreshToken":"RT-1"');
expect(localStorage.getItem('bulwark-lite:refresh:0')).toBeNull();
expect(readLiteBasicSession(0)).toBeNull();
});
it('without token login and without "remember me" the Basic session is still kept for the tab', async () => {
const { calls } = stalwartFetch({ loginStatus: 404 });
const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw');
expect(ok).toBe(true);
expect(calls).toEqual([`POST ${SERVER}/api/auth`]);
expect(useAuthStore.getState().authMode).toBe('basic');
expect(useAuthStore.getState().client?.getAuthHeader()).toBe(`Basic ${btoa('alice:pw')}`);
expect(liteStorageKeys()).toEqual([]);
expect(readLiteBasicSession(0)).toEqual({ serverUrl: SERVER, username: 'alice', password: 'pw' });
expect(localStorage.getItem('bulwark-lite:basic:0')).toBeNull();
expect(liteStorageKeys().filter((k) => k.startsWith('bulwark-lite:refresh:'))).toEqual([]);
});
it('a Basic account without "remember me" is restored from the tab session instead of being evicted', async () => {
saveLiteBasicSession(0, { serverUrl: SERVER, username: 'alice', password: 'pw' });
const id = registerAccount('basic');
useAccountStore.getState().updateAccount(id, { rememberMe: false });
stalwartFetch();
await useAuthStore.getState().checkAuth();
const state = useAuthStore.getState();
expect(state.isAuthenticated).toBe(true);
expect(state.client?.getAuthHeader()).toBe(`Basic ${btoa('alice:pw')}`);
expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual([id]);
});
it('surfaces a missing MFA code as totp_required', async () => {
+16 -8
View File
@@ -848,10 +848,12 @@ export const useAuthStore = create<AuthState>()(
let oauthAccessToken: string | null = null;
let oauthExpiresIn = 0;
// Lite has no session cookie to remember a password in, so "remember
// me" also goes through the token flow and only a refresh token is
// kept (lib/auth/lite-tokens.ts).
const useTokenLogin = !!totp || (IS_LITE && !!rememberMe);
// Lite has no session cookie to remember a password in, so every
// password login goes through Stalwart's token flow and only a
// refresh token is kept: localStorage with "remember me", the tab's
// sessionStorage without it (lib/auth/lite-tokens.ts). Servers
// without the endpoint fall back to Basic auth below.
const useTokenLogin = !!totp || IS_LITE;
if (useTokenLogin) {
// Stalwart 0.16+ dropped the `password$totp` basic-auth convention;
// the MFA code must be exchanged for tokens via the structured login
@@ -904,8 +906,9 @@ export const useAuthStore = create<AuthState>()(
client.enableTotpReauth(password, () => useTotpReauthStore.getState().requestTotp());
debug.log('auth', 'TOTP re-auth enabled (legacy basic-auth path)');
} else {
// Lite "remember me" on a server without token login: plain Basic
// auth; the session is then kept with the tab only.
// Lite on a server without token login (or one that blocks the
// browser's /api/auth call): plain Basic auth; the session is
// then kept with the tab only, see persistBasicSession.
client = new JMAPClient(serverUrl, username, password);
await client.connect();
}
@@ -934,7 +937,10 @@ export const useAuthStore = create<AuthState>()(
// write and stalwart-context write are best-effort persistence; the
// outer login still succeeds even if they log a warning. Errors are
// caught locally so Promise.all doesn't reject on either.
const sessionWrite: Promise<unknown> = (rememberMe && !upgradedToOAuth)
// In Lite a Basic session is always kept for the tab (sessionStorage),
// so a reload does not sign the user out; the regular build only
// writes the cookie when the user asked to be remembered.
const sessionWrite: Promise<unknown> = ((rememberMe || IS_LITE) && !upgradedToOAuth)
? persistBasicSession(cookieSlot, serverUrl, username, password)
: Promise.resolve();
@@ -1936,7 +1942,9 @@ export const useAuthStore = create<AuthState>()(
// Basic auth without rememberMe leaves nothing to restore - the
// user logged in without persisting credentials. Evict silently
// so the login screen is shown without flagging a fake error.
if (account.authMode === 'basic' && !account.rememberMe) {
// (Lite keeps a tab-scoped Basic session either way; a missing one
// surfaces as a 401 from fetchSlotSession below and evicts too.)
if (account.authMode === 'basic' && !account.rememberMe && !IS_LITE) {
evictAccount(account.id);
accountStore.removeAccount(account.id);
return;