feat: add Bulwark Lite static export build

This commit is contained in:
Linus Rath
2026-09-17 23:54:33 +02:00
parent 321188f08a
commit 5de98bc04b
59 changed files with 3397 additions and 108 deletions
+139
View File
@@ -0,0 +1,139 @@
name: Build Static Lite
# Bulwark Lite: the static export of the webmail (see docs in README.md,
# "Static Lite build"). Runs the build on every push as a regression check,
# attaches the zip to releases, and lets maintainers build a custom variant
# (fixed server URL, sub-path mount, locale subset, demo) on demand.
#
# scripts/lite/prepare.mjs deletes the server-only trees in place; that is
# safe here because the Actions checkout is disposable (CI=true is the guard).
on:
release:
types: [published]
push:
branches: [main]
pull_request:
workflow_dispatch:
inputs:
jmap_server_url:
description: "Default JMAP server URL baked into config.json (editable after download)"
required: false
app_name:
description: "App name"
required: false
default: "Bulwark Webmail"
base_path:
description: "Sub-path mount, e.g. /webmail (baked into asset URLs)"
required: false
locales:
description: "Comma-separated locale subset, e.g. en,de,fr (default: all)"
required: false
demo:
description: "Build the static demo (built-in demo account, no server)"
type: boolean
default: false
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build static export
env:
CI: "true"
GIT_COMMIT: ${{ github.sha }}
NEXT_PUBLIC_BASE_PATH: ${{ inputs.base_path }}
LITE_JMAP_SERVER_URL: ${{ inputs.jmap_server_url }}
LITE_APP_NAME: ${{ inputs.app_name }}
LITE_LOCALES: ${{ inputs.locales }}
LITE_DEMO_MODE: ${{ inputs.demo }}
run: npm run build:lite
- name: Package zip
env:
REF_NAME: ${{ github.ref_name }}
run: |
if [ "${{ github.event_name }}" = "release" ]; then
VERSION="${REF_NAME#v}"
else
VERSION="$(cat VERSION)-${GITHUB_SHA::7}"
fi
ZIP="bulwark-lite-${VERSION}.zip"
(cd out && zip -qr "../${ZIP}" .)
echo "ZIP=${ZIP}" >> "$GITHUB_ENV"
ls -la "${ZIP}"
- name: Upload release asset
if: github.event_name == 'release'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG_NAME" "$ZIP" --clobber
- name: Upload artifact
if: github.event_name != 'release'
uses: actions/upload-artifact@v4
with:
name: bulwark-lite
path: ${{ env.ZIP }}
retention-days: 7
smoke:
# Boots the demo export on a plain static server and drives it with
# Playwright: login, open a thread, switch surfaces, reload a deep link.
# No JMAP server involved, so it is cheap and deterministic.
runs-on: ubuntu-latest
timeout-minutes: 30
if: github.event_name == 'push' || github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
# prepare.mjs prunes every test tree in the checkout; the smoke suite
# lives under scripts/lite/smoke, which it leaves alone.
- name: Build demo export
env:
CI: "true"
GIT_COMMIT: ${{ github.sha }}
LITE_DEMO_MODE: "true"
LITE_LOCALES: "en,de"
run: npm run build:lite
- name: Run smoke test
run: npm run test:lite-smoke
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: lite-smoke-report
path: playwright-report
retention-days: 7
+1
View File
@@ -157,5 +157,6 @@
- Structured logging (`text` or `json`) with per-category levels
- Anonymous instance telemetry, off unless you enable it through the admin UI, the installer, or `BULWARK_TELEMETRY=on`. It reports version, platform, bucketed account counts, and feature toggles.
- Docker images on GHCR, for release (`main`) and development (`dev`)
- Bulwark Lite: a static export of the client (`npm run build:lite`, `bulwark-lite-<version>.zip` on every release) for plain static hosts; runtime `config.json`, cookie-free token login against Stalwart, no admin/plugin/sync features
- `NEXT_PUBLIC_BASE_PATH` mounts the app at a subpath behind a reverse proxy
- Demo mode runs on fixture data, no mail server required
+17
View File
@@ -91,6 +91,23 @@ npm run build && npm start
# Then open http://localhost:3000 to run the setup wizard
```
### Static Lite build
Bulwark Lite is the same client exported as static files: no Node.js process, upload the folder to any static host (nginx, Caddy, Netlify, Cloudflare Pages, GitHub Pages, an S3 bucket). Mail, calendar, contacts and files talk to the JMAP server straight from the browser, so the only server-side requirement is CORS on the mail server (`http.permissive-cors = true` in Stalwart).
Every release ships `bulwark-lite-<version>.zip`, and the **Build Static Lite** workflow can be dispatched with a fixed server URL, app name, sub-path mount, locale subset or the demo flag. To build locally:
```bash
git worktree add ../bulwark-lite HEAD # disposable checkout: the build deletes server-only trees
cd ../bulwark-lite && npm ci
LITE_JMAP_SERVER_URL=https://mail.example.com npm run build:lite -- --in-place
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.
### Development
```bash
@@ -1,10 +1,16 @@
import { CalendarApp } from "@/components/calendar/calendar-app";
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
// Calendar permalinks (#733):
// /calendar the stored view, today
// /calendar/<view>/<YYYY-MM-DD> a view anchored on a date
// /calendar/event/<eventId> one event, with ?calendar=<id> to
// disambiguate across accounts
// Static Lite export: only the bare surface is prerendered; deep-link segments
// are read from the address bar in the browser (hooks/use-lite-link-segments.ts).
// `undefined` in the server build keeps the route dynamic (lib/lite-static-params.ts).
export const generateStaticParams = generateLiteSegmentParams;
export default async function CalendarRoute({
params,
}: {
@@ -1,8 +1,14 @@
import { ContactsApp } from "@/components/contacts/contacts-app";
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
// Contact permalinks (#733): /contacts/<contactId>[/edit], plus /contacts/new
// for the "add this sender" flow. The older query form (?contactId=, ?addEmail=)
// still works - see parseContactsPath.
// Static Lite export: only the bare surface is prerendered; deep-link segments
// are read from the address bar in the browser (hooks/use-lite-link-segments.ts).
// `undefined` in the server build keeps the route dynamic (lib/lite-static-params.ts).
export const generateStaticParams = generateLiteSegmentParams;
export default async function ContactsRoute({
params,
}: {
@@ -1,7 +1,13 @@
import { FilesApp } from "@/components/files/files-app";
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
// Files permalinks (#733): /files/<folder>/<subfolder>, one path segment per
// level, with ?preview=<name> to open a file straight into the preview modal.
// Static Lite export: only the bare surface is prerendered; deep-link segments
// are read from the address bar in the browser (hooks/use-lite-link-segments.ts).
// `undefined` in the server build keeps the route dynamic (lib/lite-static-params.ts).
export const generateStaticParams = generateLiteSegmentParams;
export default async function FilesRoute({
params,
}: {
+10
View File
@@ -13,7 +13,14 @@ import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog"
import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth-callback-listener";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
import { setRequestLocale } from "next-intl/server";
import { locales } from "@/i18n/routing";
import { generateLiteLocaleParams } from "@/lib/lite-static-params";
// The static Lite export enumerates every locale here. In the server build the
// export is `undefined`, which Next treats as absent, so the routes stay
// dynamic (lib/lite-static-params.ts).
export const generateStaticParams = generateLiteLocaleParams;
export default async function LocaleLayout({
children,
@@ -25,6 +32,9 @@ export default async function LocaleLayout({
const { locale } = await params;
if (!(locales as readonly string[]).includes(locale)) notFound();
// Pins the request locale for next-intl's server helpers, which is what lets
// the tree render without request headers (static export).
setRequestLocale(locale);
let messages;
try {
+38 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { Suspense, useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "@/i18n/navigation";
import { useParams, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
@@ -19,6 +19,8 @@ import { type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { useUpdateStore, selectBanner } from "@/stores/update-store";
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
import { IS_LITE } from "@/lib/lite";
import { probeLiteTokenLogin } from "@/lib/auth/lite-tokens";
function findServerByDomain(servers: PublicJmapServerEntry[], email: string | undefined): PublicJmapServerEntry | undefined {
if (!email || !email.includes("@")) return undefined;
@@ -114,7 +116,7 @@ function VersionBadge() {
// that funnels password and token material to any caller-supplied URL.
const MOBILE_REDIRECT_SCHEME = "bulwarkmobile://";
export default function LoginPage() {
function LoginPageContent() {
const router = useRouter();
const t = useTranslations("login");
const params = useParams();
@@ -168,6 +170,10 @@ export default function LoginPage() {
const [totpCode, setTotpCode] = useState("");
const [showTotpField, setShowTotpField] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
// Lite (no server session): "remember me" needs Stalwart's token login on the
// target server. Probe it once per URL and hide the box when it is missing;
// those sessions then only last as long as the tab (lib/auth/lite-tokens.ts).
const [liteTokenLoginSupported, setLiteTokenLoginSupported] = useState<boolean | null>(null);
const [sessionExpired, setSessionExpired] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [shakeError, setShakeError] = useState(false);
@@ -203,6 +209,25 @@ export default function LoginPage() {
initializeTheme();
}, [initializeTheme]);
const probeTarget = hasServerList
? selectedServer?.url ?? ""
: (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl) || "";
useEffect(() => {
if (!IS_LITE || !probeTarget) return;
let cancelled = false;
// Debounced: the custom endpoint field fires per keystroke.
const timer = setTimeout(() => {
probeLiteTokenLogin(probeTarget).then((supported) => {
if (!cancelled) setLiteTokenLoginSupported(supported);
});
}, 500);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [probeTarget]);
const showRememberMe = rememberMeEnabled && (!IS_LITE || liteTokenLoginSupported !== false);
useEffect(() => {
if (serverUrl) {
document.title = appName;
@@ -1235,7 +1260,7 @@ export default function LoginPage() {
)}
{/* Remember me */}
{rememberMeEnabled && (
{showRememberMe && (
<label className="flex items-center gap-2.5 cursor-pointer group select-none pt-1">
<span className="relative flex items-center justify-center">
<input
@@ -1410,3 +1435,13 @@ export default function LoginPage() {
</div>
);
}
// useSearchParams() above must sit under a Suspense boundary for the route to
// prerender (the static Lite build); at runtime the boundary never suspends.
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginPageContent />
</Suspense>
);
}
@@ -1,4 +1,5 @@
import { MailApp } from "@/components/mail/mail-app";
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
// Permalinks into the mail client (#733):
// /mail the list
@@ -10,6 +11,11 @@ import { MailApp } from "@/components/mail/mail-app";
// an intent the shell applies once its JMAP session is up, not a different
// page. Unknown segments are ignored and the shell falls back to the list, so
// stale links degrade instead of 404ing.
// Static Lite export: only the bare surface is prerendered; deep-link segments
// are read from the address bar in the browser (hooks/use-lite-link-segments.ts).
// `undefined` in the server build keeps the route dynamic (lib/lite-static-params.ts).
export const generateStaticParams = generateLiteSegmentParams;
export default async function MailDeepLinkRoute({
params,
}: {
@@ -142,10 +142,19 @@ function PluginOAuthCallbackInner() {
);
}
export default function PluginOAuthCallbackPage() {
function PluginOAuthCallbackContent() {
return (
<Suspense fallback={<OAuthStatusCard title="" />}>
<PluginOAuthCallbackInner />
</Suspense>
);
}
// useSearchParams() needs a Suspense boundary for prerendering (static Lite build).
export default function PluginOAuthCallbackPage() {
return (
<Suspense fallback={null}>
<PluginOAuthCallbackContent />
</Suspense>
);
}
@@ -1,7 +1,13 @@
import { SettingsApp } from "@/components/settings/settings-app";
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
// Settings permalinks (#733): /settings/<tabId>. Without a tab the page falls
// back to the last tab the user had open, as before.
// Static Lite export: only the bare surface is prerendered; deep-link segments
// are read from the address bar in the browser (hooks/use-lite-link-segments.ts).
// `undefined` in the server build keeps the route dynamic (lib/lite-static-params.ts).
export const generateStaticParams = generateLiteSegmentParams;
export default async function SettingsRoute({
params,
}: {
+75 -6
View File
@@ -14,7 +14,8 @@ import {
type BrandingOverrideKey,
} from "@/lib/admin/domain-branding";
import { withBasePath } from "@/lib/browser-navigation";
import { locales } from "@/i18n/routing";
import { locales, defaultLocale } from "@/i18n/routing";
import { IS_LITE } from "@/lib/lite";
import "../globals.css";
// This layout renders <html> and sits ABOVE the [locale] segment, so
@@ -102,6 +103,7 @@ async function requestOrigin(): Promise<URL | null> {
/** App name, resolved exactly like app/manifest.ts so <head> and manifest agree. */
async function brandedAppName(): Promise<string> {
if (IS_LITE) return process.env.NEXT_PUBLIC_APP_NAME || "Webmail";
return (
(await brandedValue("appName", "")) ||
process.env.NEXT_PUBLIC_APP_NAME ||
@@ -110,6 +112,10 @@ async function brandedAppName(): Promise<string> {
}
export async function generateViewport(): Promise<Viewport> {
if (IS_LITE) {
// No admin config in the static build; <ThemeColorSync /> tracks the theme.
return { width: "device-width", initialScale: 1, viewportFit: "cover", themeColor: "#ffffff" };
}
await configManager.ensureLoaded();
// Chromium uses <meta name="theme-color"> in preference to the manifest's
// theme_color when coloring an installed desktop PWA's title bar, so a
@@ -123,7 +129,35 @@ export async function generateViewport(): Promise<Viewport> {
};
}
/**
* Static-build metadata: no request to read a host or locale from, so the
* <head> carries the build-time app name, the default locale's description
* and the manifest postbuild writes next to index.html. The inline bootstrap
* script below corrects <html lang/dir> per page in the browser.
*/
async function liteMetadata(): Promise<Metadata> {
const appName = await brandedAppName();
const t = await getTranslations({ locale: defaultLocale });
return {
title: appName,
description: t("meta_description"),
applicationName: appName,
robots: { index: false, follow: false },
manifest: withBasePath("/manifest.webmanifest"),
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: appName,
},
formatDetection: {
telephone: false,
},
icons: { icon: withBasePath("/branding/Bulwark_Favicon.svg") },
};
}
export async function generateMetadata(): Promise<Metadata> {
if (IS_LITE) return liteMetadata();
await configManager.ensureLoaded();
// The <head> favicon must honor per-domain branding, exactly like
// /api/config, app/manifest.ts, and /api/pwa-icon already do. Resolve the
@@ -192,16 +226,51 @@ export async function generateMetadata(): Promise<Metadata> {
};
}
/**
* Lite only: the exported HTML is one document per locale, but this layout
* sits above the [locale] segment and cannot know which one it is rendering.
* Fix <html lang/dir> from the URL before first paint, in the same inline
* script that applies the theme.
*/
function liteLocaleBootstrap(): string {
const basePath = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
const rtl = locales.filter((l) => getLocaleDirection(l) === "rtl");
return `
try {
var locales = ${JSON.stringify([...locales])};
var rtl = ${JSON.stringify(rtl)};
var base = ${JSON.stringify(basePath)};
var path = location.pathname;
if (base && path.indexOf(base) === 0) path = path.slice(base.length);
var seg = path.split('/').filter(Boolean)[0];
if (seg && locales.indexOf(seg) !== -1) {
document.documentElement.lang = seg;
document.documentElement.dir = rtl.indexOf(seg) !== -1 ? 'rtl' : 'ltr';
}
} catch (e) {}`;
}
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
await configManager.ensureLoaded();
const locale = await resolveRequestLocale();
const nonce = (await headers()).get("x-nonce") ?? "";
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
const themeColorConfigured = await hasExplicitThemeColor();
let locale: string;
let nonce: string | undefined;
let themeColorConfigured: boolean;
if (IS_LITE) {
// Static export: no request, no nonce, no admin config.
locale = defaultLocale;
nonce = undefined;
themeColorConfigured = false;
} else {
await configManager.ensureLoaded();
locale = await resolveRequestLocale();
nonce = (await headers()).get("x-nonce") ?? "";
themeColorConfigured = await hasExplicitThemeColor();
}
const appName = await brandedAppName();
return (
@@ -219,7 +288,7 @@ export default async function RootLayout({
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `
(function() {
(function() {${IS_LITE ? liteLocaleBootstrap() : ""}
try {
const stored = localStorage.getItem('theme-storage');
const theme = stored ? JSON.parse(stored).state.theme : 'system';
+13
View File
@@ -3,11 +3,24 @@
import { useEffect } from "react";
import { useAuthStore } from "@/stores/auth-store";
import { getPathPrefix } from "@/lib/browser-navigation";
import { IS_LITE } from "@/lib/lite";
import { liteSurfaceRootFor, stashPendingLitePath } from "@/lib/lite-link-segments";
export default function NotFound() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
useEffect(() => {
// Static Lite build on a host without rewrite rules (GitHub Pages): a deep
// link such as /en/mail/thread/abc lands here as 404.html. Park the link
// and load the owning surface, which replays it (hooks/use-lite-link-segments.ts).
if (IS_LITE) {
const target = liteSurfaceRootFor(window.location.pathname, getPathPrefix());
if (target) {
stashPendingLitePath(`${window.location.pathname}${window.location.search}`);
window.location.replace(target);
return;
}
}
if (!isAuthenticated) {
const prefix = getPathPrefix();
// Don't redirect admin routes to the webmail login page. Admin paths
+4 -1
View File
@@ -80,6 +80,7 @@ import {
scrollWindowContains, type CalendarFocus, type ScrollViewMode, type ScrollWindowOptions,
type ScrollWindowState, type ScrollWindowViewProps,
} from "@/lib/calendar-scroll-window";
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
type PendingScopeAction =
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean }
@@ -94,7 +95,9 @@ export interface CalendarAppProps {
linkSegments?: string[];
}
export function CalendarApp({ linkSegments }: CalendarAppProps = {}) {
export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = {}) {
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('calendar', routeSegments);
const router = useRouter();
const t = useTranslations("calendar");
const tWebcalAction = useTranslations("calendar.webcal_action");
+15 -11
View File
@@ -12,6 +12,7 @@ import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import { apiFetch } from "@/lib/browser-navigation";
import { IS_LITE } from "@/lib/lite";
interface ICalImportModalProps {
calendars: Calendar[];
@@ -276,17 +277,20 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
<Upload className="w-4 h-4" />
{t("tab_file")}
</button>
<button
onClick={() => { setImportMode("url"); setError(null); }}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
importMode === "url"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Globe className="w-4 h-4" />
{t("tab_url")}
</button>
{/* URL import goes through the /api/fetch-ical CORS proxy, absent in the static build. */}
{!IS_LITE && (
<button
onClick={() => { setImportMode("url"); setError(null); }}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
importMode === "url"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Globe className="w-4 h-4" />
{t("tab_url")}
</button>
)}
</div>
{importMode === "file" && (
+15 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { Suspense, useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
import { useRouter } from "@/i18n/navigation";
@@ -42,6 +42,7 @@ import { appPath, buildContactsPath, parseContactsPath, type ContactDeepLink } f
import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
type View =
| "list"
@@ -58,7 +59,9 @@ export interface ContactsAppProps {
linkSegments?: string[];
}
export function ContactsApp({ linkSegments }: ContactsAppProps = {}) {
function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = {}) {
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('contacts', routeSegments);
const t = useTranslations("contacts");
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
@@ -1169,3 +1172,13 @@ export function ContactsApp({ linkSegments }: ContactsAppProps = {}) {
</div>
);
}
// useSearchParams() above needs a Suspense boundary for the surface to
// prerender (static Lite build); at runtime it never suspends.
export function ContactsApp(props: ContactsAppProps = {}) {
return (
<Suspense fallback={null}>
<ContactsAppContent {...props} />
</Suspense>
);
}
+4 -1
View File
@@ -37,13 +37,16 @@ import { appPath, buildFilesPath, parseFilesPath, type FilesDeepLink } from "@/l
import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
export interface FilesAppProps {
/** Path segments after `/files` - the folder path, one segment per level. */
linkSegments?: string[];
}
export function FilesApp({ linkSegments }: FilesAppProps = {}) {
export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('files', routeSegments);
const router = useRouter();
const t = useTranslations("files");
const tDeepLink = useTranslations("deep_link");
+3 -1
View File
@@ -26,6 +26,7 @@ import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
import { Avatar } from "@/components/ui/avatar";
import { IS_LITE } from "@/lib/lite";
interface NavItem {
id: string;
@@ -293,7 +294,8 @@ export function NavigationRail({
useEffect(() => {
let cancelled = false;
const headers = getActiveAccountSlotHeaders();
if (!headers['X-JMAP-Cookie-Slot']) return;
// No admin console in the static Lite build.
if (IS_LITE || !headers['X-JMAP-Cookie-Slot']) return;
apiFetch('/api/admin/auth', { headers })
.then(res => res.json())
.then(data => {
+4 -1
View File
@@ -117,6 +117,7 @@ import {
import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import type { QuoteHeader } from "@/lib/plugin-types";
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
export interface MailAppProps {
/**
@@ -126,7 +127,9 @@ export interface MailAppProps {
linkSegments?: string[];
}
export function MailApp({ linkSegments }: MailAppProps = {}) {
export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('mail', routeSegments);
const t = useTranslations();
const tCommon = useTranslations('common');
const tQuote = useTranslations('quote_header');
@@ -1,6 +1,7 @@
"use client";
import { useEffect } from "react";
import { IS_LITE } from "@/lib/lite";
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
@@ -9,6 +10,12 @@ export function ServiceWorkerRegistration() {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return;
}
// sw.js enriches push notifications through /api/push/preview and serves
// icons from /api/pwa-icon; neither exists in the static build. Push stays
// off in Lite v1, so the worker is not registered at all.
if (IS_LITE) {
return;
}
if (process.env.NODE_ENV !== "production") {
// In development, unregister any previously installed service worker
@@ -19,6 +19,7 @@ import { useSettingsStore } from '@/stores/settings-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import { apiFetch } from '@/lib/browser-navigation';
import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors';
import { IS_LITE } from '@/lib/lite';
function CalendarColorPicker({
value,
@@ -192,7 +193,8 @@ export function CalendarManagementSettings() {
}, [client, calendars.length, fetchCalendars]);
useEffect(() => {
if (!client || !serverUrl || !username) {
// CalDAV discovery runs through /api/caldav/discover, absent in the static build.
if (!client || !serverUrl || !username || IS_LITE) {
setDiscoveredCalDavUrls({});
setWellKnownCalDavUrl(null);
return;
@@ -615,14 +617,17 @@ export function CalendarManagementSettings() {
<Upload className="w-4 h-4" />
{tImport('title')}
</button>
<button
type="button"
onClick={() => setShowSubscriptionModal(true)}
className="flex items-center gap-2 py-2.5 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border border-dashed border-border transition-colors"
>
<Globe className="w-4 h-4" />
{tSub('title')}
</button>
{/* ICS URL subscriptions refresh via /api/fetch-ical, absent in the static build. */}
{!IS_LITE && (
<button
type="button"
onClick={() => setShowSubscriptionModal(true)}
className="flex items-center gap-2 py-2.5 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border border-dashed border-border transition-colors"
>
<Globe className="w-4 h-4" />
{tSub('title')}
</button>
)}
</div>
)}
</div>
+4 -1
View File
@@ -100,6 +100,7 @@ import {
tabKeywords,
tabSearchPaths,
} from '@/lib/settings-search';
import { useLiteLinkSegments } from '@/hooks/use-lite-link-segments';
type Tab = SettingsSearchTab;
@@ -209,7 +210,9 @@ export interface SettingsAppProps {
linkSegments?: string[];
}
export function SettingsApp({ linkSegments }: SettingsAppProps = {}) {
export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = {}) {
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('settings', routeSegments);
const router = useRouter();
const t = useTranslations('settings');
const tSidebar = useTranslations('sidebar');
+3 -1
View File
@@ -7,6 +7,7 @@ import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
import { useConfig } from "@/hooks/use-config";
import { avatarHooks } from "@/lib/plugin-hooks";
import { withBasePath } from "@/lib/browser-navigation";
import { IS_LITE } from "@/lib/lite";
const IS_DEV = process.env.NODE_ENV !== "production";
@@ -230,8 +231,9 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
};
const profilePic = email && domain ? getProfilePictureUrl(email, domain, devMode, name) : null;
// Sender favicons come from the /api/favicon proxy, absent in the static build.
const showFavicon =
!disableFavicon && senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
!IS_LITE && !disableFavicon && senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
+9
View File
@@ -53,6 +53,15 @@ export default [
},
},
},
{
// Node-only build tooling (Bulwark Lite pipeline).
files: ["scripts/**/*.mjs"],
languageOptions: {
globals: {
...globals.node,
},
},
},
{
files: ["**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"],
languageOptions: {
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
const liteState = vi.hoisted(() => ({ IS_LITE: false }));
vi.mock('@/lib/lite', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/lite')>();
return {
...actual,
get IS_LITE() {
return liteState.IS_LITE;
},
};
});
import { useLiteLinkSegments } from '@/hooks/use-lite-link-segments';
import { LITE_PENDING_PATH_KEY } from '@/lib/lite';
describe('useLiteLinkSegments', () => {
beforeEach(() => {
sessionStorage.clear();
window.history.replaceState({}, '', '/en/mail/thread/t1?open=1');
});
it('is the identity outside the static build', () => {
liteState.IS_LITE = false;
expect(renderHook(() => useLiteLinkSegments('mail', ['folder', 'inbox'])).result.current).toEqual(['folder', 'inbox']);
// `undefined` stays undefined: the surfaces use it to fall back to the Pro handoff.
expect(renderHook(() => useLiteLinkSegments('mail', undefined)).result.current).toBeUndefined();
});
it('reads the segments from the address bar in the static build', () => {
liteState.IS_LITE = true;
const { result } = renderHook(() => useLiteLinkSegments('mail', []));
expect(result.current).toEqual(['thread', 't1']);
expect(window.location.search).toBe('?open=1');
});
it('yields no segments for a different surface', () => {
liteState.IS_LITE = true;
expect(renderHook(() => useLiteLinkSegments('calendar', [])).result.current).toEqual([]);
});
it('replays a parked deep link and restores the URL bar', () => {
liteState.IS_LITE = true;
window.history.replaceState({}, '', '/en/calendar/');
sessionStorage.setItem(LITE_PENDING_PATH_KEY, '/en/calendar/week/2026-09-17?view=1');
const { result } = renderHook(() => useLiteLinkSegments('calendar', []));
expect(result.current).toEqual(['week', '2026-09-17']);
expect(`${window.location.pathname}${window.location.search}`).toBe('/en/calendar/week/2026-09-17?view=1');
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/');
sessionStorage.setItem(LITE_PENDING_PATH_KEY, '/en/files/folder/x');
expect(renderHook(() => useLiteLinkSegments('mail', [])).result.current).toEqual([]);
expect(sessionStorage.getItem(LITE_PENDING_PATH_KEY)).toBe('/en/files/folder/x');
expect(window.location.pathname).toBe('/en/mail/');
});
it('is stable across re-renders (read once on mount)', () => {
liteState.IS_LITE = true;
const { result, rerender } = renderHook(() => useLiteLinkSegments('mail', []));
const first = result.current;
window.history.replaceState({}, '', '/en/mail/thread/t2');
rerender();
expect(result.current).toBe(first);
});
});
+34 -8
View File
@@ -4,8 +4,10 @@ import { useState, useEffect } from 'react';
import { usePolicyStore } from '@/stores/policy-store';
import { apiFetch } from '@/lib/browser-navigation';
import type { PublicJmapServerEntry } from '@/lib/admin/jmap-servers';
import { IS_LITE, LITE_CONFIG_PATH } from '@/lib/lite';
import { applyLiteConfig } from '@/lib/lite-config';
interface ConfigData {
export interface ConfigData {
appName: string;
jmapServerUrl: string;
oauthEnabled: boolean;
@@ -50,6 +52,36 @@ interface AppConfig extends ConfigData {
let configCache: ConfigData | null = null;
let configPromise: Promise<ConfigData> | null = null;
/**
* Static Lite build: the deployer-edited config.json next to index.html
* replaces /api/config. A missing or broken file falls back to the defaults
* (custom endpoint allowed) so an unedited download still lets people sign in.
*/
async function fetchLiteConfig(): Promise<ConfigData> {
try {
const res = await apiFetch(LITE_CONFIG_PATH, { cache: 'no-store' });
if (!res.ok) throw new Error(`config.json answered ${res.status}`);
return applyLiteConfig(await res.json());
} catch (err) {
console.warn('[lite] config.json missing or invalid, using defaults:', err);
return applyLiteConfig({});
}
}
async function fetchServerConfig(): Promise<ConfigData> {
const res = await apiFetch('/api/config');
if (!res.ok) {
throw new Error('Failed to fetch config');
}
return res.json();
}
/** Test hook: forget the cached config so the next fetch hits the network again. */
export function resetConfigCache(): void {
configCache = null;
configPromise = null;
}
export async function fetchConfig(): Promise<ConfigData> {
// Return cached config if available
if (configCache) {
@@ -62,13 +94,7 @@ export async function fetchConfig(): Promise<ConfigData> {
}
// Start a new fetch
configPromise = apiFetch('/api/config')
.then((res) => {
if (!res.ok) {
throw new Error('Failed to fetch config');
}
return res.json();
})
configPromise = (IS_LITE ? fetchLiteConfig() : fetchServerConfig())
.then((data) => {
configCache = data;
// Fetch admin policy alongside config (non-blocking)
+50
View File
@@ -0,0 +1,50 @@
"use client";
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 type { AppSurface } from '@/lib/deep-links';
/**
* Route segments for a surface's deep link.
*
* Outside Lite this is the identity: the `[[...segments]]` page already passed
* the segments Next parsed. In the static Lite build the prerendered params
* are always empty, so the segments are read from the address bar instead -
* once, on the first client render, which is when every surface consumes
* them. A deep link the 404 shim parked (hosts without rewrites) is replayed
* here as well, and the URL bar is put back to the link that was requested.
*
* Returns `undefined` only when the caller passed `undefined` outside Lite,
* preserving the "no route segments, check the Pro handoff" signal.
*/
export function useLiteLinkSegments(surface: AppSurface, linkSegments?: string[]): string[] | undefined {
const restoreUrl = useRef<string | null>(null);
const [segments] = useState<string[] | undefined>(() => {
if (!IS_LITE || typeof window === 'undefined') return linkSegments;
const prefix = getPathPrefix();
const pending = takePendingLitePath(surface, prefix);
if (pending) {
restoreUrl.current = pending;
return liteSegmentsFromPath(pending, surface, prefix);
}
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.
useEffect(() => {
const url = restoreUrl.current;
if (!url) return;
restoreUrl.current = null;
try {
window.history.replaceState(window.history.state, '', url);
} catch {
// A hardened browser may refuse; the segments still apply.
}
}, []);
return segments;
}
+3
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { IS_LITE } from "@/lib/lite";
/**
* Whether a WOPI document editor is configured for this deployment and which
@@ -18,6 +19,8 @@ const DISABLED: WopiStatus = { enabled: false, editExtensions: [], viewExtension
let statusPromise: Promise<WopiStatus> | null = null;
function fetchWopiStatus(): Promise<WopiStatus> {
// The WOPI bridge is a server feature; the static build never has an editor.
if (IS_LITE) return Promise.resolve(DISABLED);
if (!statusPromise) {
statusPromise = fetch("/api/wopi/status")
.then((res) => (res.ok ? res.json() : DISABLED))
+9 -2
View File
@@ -3,13 +3,20 @@ import { headers } from 'next/headers';
import { mergeMessages } from './merge-messages';
import { localeFromAcceptLanguage } from './locale-matcher';
import { routing, type Locale } from './routing';
import { IS_LITE } from '@/lib/lite';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as Locale)) {
const accept = (await headers()).get('accept-language');
locale = localeFromAcceptLanguage(accept, routing.locales) ?? routing.defaultLocale;
if (IS_LITE) {
// Static export: there is no request to sniff; every localized page pins
// its locale via setRequestLocale, so this only covers the root layout.
locale = routing.defaultLocale;
} else {
const accept = (await headers()).get('accept-language');
locale = localeFromAcceptLanguage(accept, routing.locales) ?? routing.defaultLocale;
}
}
// Use static imports for better compatibility
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import { applyLiteConfig, applyLitePolicy, LITE_FORCED_FLAGS } from '@/lib/lite-config';
import { DEFAULT_POLICY } from '@/lib/admin/types';
describe('applyLiteConfig (config.json for the static build)', () => {
it('fills every field with the defaults useConfig assumes when the file is empty', () => {
const config = applyLiteConfig({});
expect(config.appName).toBe('Webmail');
expect(config.jmapServerUrl).toBe('');
expect(config.rememberMeEnabled).toBe(true);
expect(config.loginShowTotp).toBe(true);
expect(config.faviconUrl).toBe('/branding/Bulwark_Favicon.svg');
expect(config.loginLogoLightUrl).toBe('/branding/Bulwark_Logo_Color.svg');
expect(config.jmapServers).toEqual([]);
expect(config.demoMode).toBe(false);
});
it('allows a custom endpoint by default only when no server is configured', () => {
expect(applyLiteConfig({}).allowCustomJmapEndpoint).toBe(true);
expect(applyLiteConfig({ jmapServerUrl: 'https://mail.example.com' }).allowCustomJmapEndpoint).toBe(false);
expect(applyLiteConfig({ jmapServers: [{ id: 'eu', url: 'https://eu.example.com' }] }).allowCustomJmapEndpoint).toBe(false);
// An explicit value always wins.
expect(applyLiteConfig({ jmapServerUrl: 'https://mail.example.com', allowCustomJmapEndpoint: true }).allowCustomJmapEndpoint).toBe(true);
});
it('trims trailing slashes from server URLs', () => {
const config = applyLiteConfig({
jmapServerUrl: 'https://mail.example.com///',
jmapServers: [{ id: 'eu', label: 'EU', url: 'https://eu.example.com/', domains: ['example.com', 42] }],
});
expect(config.jmapServerUrl).toBe('https://mail.example.com');
expect(config.jmapServers).toEqual([{ id: 'eu', label: 'EU', url: 'https://eu.example.com', domains: ['example.com'] }]);
});
it('drops malformed server entries and labels unnamed ones by id', () => {
const config = applyLiteConfig({ jmapServers: [{ id: 'a', url: 'https://a.example' }, { url: 'nope' }, 'junk', null] });
expect(config.jmapServers).toEqual([{ id: 'a', label: 'a', url: 'https://a.example', domains: [] }]);
});
it('pins every server-backed flag off no matter what the file says', () => {
const config = applyLiteConfig({
oauthEnabled: true,
oauthOnly: true,
autoSsoEnabled: true,
settingsSyncEnabled: true,
stalwartFeaturesEnabled: true,
stalwartJmapPassthroughEnabled: true,
devMode: true,
});
for (const [key, value] of Object.entries(LITE_FORCED_FLAGS)) {
expect(config[key as keyof typeof LITE_FORCED_FLAGS]).toBe(value);
}
expect(config.oauthClientId).toBe('');
});
it('ignores values of the wrong type instead of crashing', () => {
const config = applyLiteConfig({ appName: 12, rememberMeEnabled: 'yes', loginShowVersion: 0, jmapServers: 'x' });
expect(config.appName).toBe('Webmail');
expect(config.rememberMeEnabled).toBe(true);
expect(config.loginShowVersion).toBe(true);
expect(config.jmapServers).toEqual([]);
expect(applyLiteConfig(null).appName).toBe('Webmail');
expect(applyLiteConfig('garbage').appName).toBe('Webmail');
});
it('keeps deployer-facing branding and login toggles', () => {
const config = applyLiteConfig({
appName: 'Acme Mail',
loginCompanyName: 'Acme',
loginImprintUrl: 'https://acme.example/imprint',
loginShowHeading: false,
demoMode: true,
embeddedMode: true,
parentOrigin: 'https://intranet.acme.example',
});
expect(config.appName).toBe('Acme Mail');
expect(config.loginCompanyName).toBe('Acme');
expect(config.loginImprintUrl).toBe('https://acme.example/imprint');
expect(config.loginShowHeading).toBe(false);
expect(config.demoMode).toBe(true);
expect(config.embeddedMode).toBe(true);
expect(config.parentOrigin).toBe('https://intranet.acme.example');
});
});
describe('applyLitePolicy (policy.json for the static build)', () => {
it('returns the defaults with plugins and sidebar apps pinned off for an empty file', () => {
const policy = applyLitePolicy({});
expect(policy.features.pluginsEnabled).toBe(false);
expect(policy.features.sidebarAppsEnabled).toBe(false);
expect(policy.features.pluginsUploadEnabled).toBe(false);
expect(policy.features.calendarEnabled).toBe(DEFAULT_POLICY.features.calendarEnabled);
expect(policy.restrictions).toEqual({});
expect(policy.pushRelays).toEqual([]);
});
it('honours deployer gates and restrictions but never re-enables plugins', () => {
const policy = applyLitePolicy({
features: { pluginsEnabled: true, sidebarAppsEnabled: true, calendarEnabled: false },
restrictions: { theme: { locked: true, value: 'dark' } },
forceEnabledPlugins: ['evil'],
defaultSidebarApps: [{ id: 'x' }],
});
expect(policy.features.pluginsEnabled).toBe(false);
expect(policy.features.sidebarAppsEnabled).toBe(false);
expect(policy.features.calendarEnabled).toBe(false);
expect(policy.restrictions.theme).toEqual({ locked: true, value: 'dark' });
expect(policy.forceEnabledPlugins).toEqual([]);
expect(policy.defaultSidebarApps).toEqual([]);
});
it('tolerates junk input', () => {
expect(applyLitePolicy(null).features.themesEnabled).toBe(true);
expect(applyLitePolicy('nope').features.pluginsEnabled).toBe(false);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
liteSegmentsFromPath,
liteSurfaceRootFor,
parseLitePath,
stashPendingLitePath,
takePendingLitePath,
} from '@/lib/lite-link-segments';
import { LITE_PENDING_PATH_KEY } from '@/lib/lite';
describe('parseLitePath', () => {
it('splits locale, surface and the remaining segments', () => {
expect(parseLitePath('/en/mail/thread/abc')).toEqual({ locale: 'en', surface: 'mail', segments: ['thread', 'abc'] });
expect(parseLitePath('/de/calendar/week/2026-09-17/')).toEqual({ locale: 'de', surface: 'calendar', segments: ['week', '2026-09-17'] });
expect(parseLitePath('/zh-TW/settings/reading')).toEqual({ locale: 'zh-TW', surface: 'settings', segments: ['reading'] });
});
it('treats the locale root as the mail surface with no segments', () => {
expect(parseLitePath('/en/')).toEqual({ locale: 'en', surface: 'mail', segments: [] });
expect(parseLitePath('/en')).toEqual({ locale: 'en', surface: 'mail', segments: [] });
});
it('ignores query and hash and decodes each segment exactly once', () => {
expect(parseLitePath('/en/contacts/card/a%2Fb?x=1#y')).toEqual({ locale: 'en', surface: 'contacts', segments: ['card', 'a/b'] });
expect(parseLitePath('/en/files/folder/100%25')).toEqual({ locale: 'en', surface: 'files', segments: ['folder', '100%'] });
// A broken escape is passed through verbatim rather than throwing.
expect(parseLitePath('/en/files/folder/%E0%A4%A')).toEqual({ locale: 'en', surface: 'files', segments: ['folder', '%E0%A4%A'] });
});
it('strips a sub-path mount prefix first', () => {
expect(parseLitePath('/webmail/fr/mail/message/m1', '/webmail')).toEqual({ locale: 'fr', surface: 'mail', segments: ['message', 'm1'] });
expect(parseLitePath('/webmail', '/webmail')).toEqual({ locale: null, surface: 'mail', segments: [] });
// A different first segment that merely shares the prefix text is not the prefix.
expect(parseLitePath('/webmailer/en/mail', '/webmail').surface).toBeNull();
});
it('returns no surface for login, pro and unknown paths', () => {
expect(parseLitePath('/en/login').surface).toBeNull();
expect(parseLitePath('/en/pro').surface).toBeNull();
expect(parseLitePath('/nonsense/deep').surface).toBeNull();
expect(parseLitePath('/nonsense/deep').locale).toBeNull();
});
});
describe('liteSegmentsFromPath', () => {
it('only yields segments for the surface that is asking', () => {
expect(liteSegmentsFromPath('/en/mail/thread/t1', 'mail')).toEqual(['thread', 't1']);
expect(liteSegmentsFromPath('/en/mail/thread/t1', 'calendar')).toEqual([]);
expect(liteSegmentsFromPath('/en/', 'mail')).toEqual([]);
expect(liteSegmentsFromPath('/en/login', 'mail')).toEqual([]);
});
});
describe('liteSurfaceRootFor (404 shim target)', () => {
it('points a deep link at its surface shell, keeping locale and mount prefix', () => {
expect(liteSurfaceRootFor('/en/mail/thread/t1')).toBe('/en/mail/');
expect(liteSurfaceRootFor('/webmail/de/files/folder/x', '/webmail')).toBe('/webmail/de/files/');
});
it('returns null when there is nothing to replay', () => {
expect(liteSurfaceRootFor('/en/login')).toBeNull();
expect(liteSurfaceRootFor('/totally/unknown')).toBeNull();
// No locale in the URL: the shells are locale-prefixed, so nothing to load.
expect(liteSurfaceRootFor('/mail/thread/x')).toBeNull();
});
});
describe('pending path hand-off', () => {
beforeEach(() => {
sessionStorage.clear();
});
it('parks a link and hands it to the owning surface exactly once', () => {
expect(stashPendingLitePath('/en/mail/thread/t1?x=1')).toBe(true);
expect(sessionStorage.getItem(LITE_PENDING_PATH_KEY)).toBe('/en/mail/thread/t1?x=1');
// Another surface leaves it alone.
expect(takePendingLitePath('calendar')).toBeNull();
expect(sessionStorage.getItem(LITE_PENDING_PATH_KEY)).toBe('/en/mail/thread/t1?x=1');
expect(takePendingLitePath('mail')).toBe('/en/mail/thread/t1?x=1');
expect(takePendingLitePath('mail')).toBeNull();
});
it('respects the mount prefix when matching the surface', () => {
stashPendingLitePath('/webmail/en/contacts/card/c1');
expect(takePendingLitePath('contacts')).toBeNull(); // without prefix: "webmail" is not a locale
expect(takePendingLitePath('contacts', '/webmail')).toBe('/webmail/en/contacts/card/c1');
});
});
+230
View File
@@ -0,0 +1,230 @@
import { describe, expect, it } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
LITE_API_STRING_ALLOWLIST,
buildCaddyExample,
buildHeaders,
buildLiteConfig,
buildLitePolicy,
buildManifest,
buildNginxExample,
buildReadme,
buildRedirects,
buildRootRedirect,
collectApiStrings,
discoverBuiltLocales,
normalizeBasePath,
parseBool,
unexpectedApiStrings,
} from '../../scripts/lite/lib.mjs';
import { collectTestPaths, planRemovals, runPrepare } from '../../scripts/lite/prepare.mjs';
import { runPostbuild } from '../../scripts/lite/postbuild.mjs';
import { verifyExport } from '../../scripts/lite/verify.mjs';
/** The scripts take `process.env`-shaped input; tests pass plain objects. */
function env(values: Record<string, string> = {}): NodeJS.ProcessEnv {
return values as NodeJS.ProcessEnv;
}
function makeRepo(): string {
const root = mkdtempSync(join(tmpdir(), 'bulwark-lite-'));
const touch = (rel: string, content = '') => {
const full = join(root, ...rel.split('/'));
mkdirSync(join(full, '..'), { recursive: true });
writeFileSync(full, content);
};
touch('proxy.ts');
touch('app/api/config/route.ts');
touch('app/(main)/admin/page.tsx');
touch('app/(main)/[locale]/mail/[[...segments]]/page.tsx');
touch('lib/__tests__/foo.test.ts');
touch('lib/keep.ts');
touch('stores/bar.test.tsx');
touch('scripts/lite/lib.mjs');
touch('scripts/lite/smoke/lite.spec.ts');
touch('node_modules/pkg/x.test.js');
touch('repos/other/y.test.ts');
touch('VERSION', '1.10.0\n');
return root;
}
describe('lite build helpers', () => {
it('parses booleans and base paths', () => {
expect(parseBool('true')).toBe(true);
expect(parseBool('1')).toBe(true);
expect(parseBool('no')).toBe(false);
expect(parseBool(undefined, true)).toBe(true);
expect(normalizeBasePath('/webmail/')).toBe('/webmail');
expect(normalizeBasePath('')).toBe('');
expect(() => normalizeBasePath('webmail')).toThrow(/start with/);
});
it('derives config.json from the LITE_* inputs with safe defaults', () => {
const empty = buildLiteConfig({});
expect(empty.jmapServerUrl).toBe('');
expect(empty.allowCustomJmapEndpoint).toBe(true);
expect(empty.rememberMeEnabled).toBe(true);
expect(empty.demoMode).toBe(false);
expect(empty.appName).toBe('Bulwark Webmail');
const fixed = buildLiteConfig({ LITE_JMAP_SERVER_URL: 'https://mail.example.com/', LITE_APP_NAME: 'Acme', LITE_DEMO_MODE: 'true', LITE_REMEMBER_ME: 'false' });
expect(fixed.jmapServerUrl).toBe('https://mail.example.com');
expect(fixed.allowCustomJmapEndpoint).toBe(false);
expect(fixed.appName).toBe('Acme');
expect(fixed.demoMode).toBe(true);
expect(fixed.rememberMeEnabled).toBe(false);
expect(buildLitePolicy().features).toEqual({ pluginsEnabled: false, sidebarAppsEnabled: false });
});
it('prefixes manifest and redirect paths with the base path', () => {
const manifest = buildManifest({ appName: 'Acme', basePath: '/webmail' });
expect(manifest.start_url).toBe('/webmail/');
expect(manifest.icons[0].src).toBe('/webmail/icon-192x192.png');
const redirects = buildRedirects({ basePath: '/webmail', locales: ['en', 'de'] });
expect(redirects).toContain('/webmail/en/mail/* /webmail/en/mail/index.html 200');
expect(redirects).toContain('/webmail/de/settings/* /webmail/de/settings/index.html 200');
expect(redirects).not.toContain('/en/login/*');
});
it('writes a root shim that knows every built locale and the fallback', () => {
const html = buildRootRedirect({ basePath: '/webmail', locales: ['de', 'en', 'zh-TW'], defaultLocale: 'en' });
expect(html).toContain('var locales = ["de","en","zh-TW"]');
expect(html).toContain('var fallback = "en"');
expect(html).toContain('var base = "/webmail"');
expect(html).toContain('href="/webmail/en/"');
// An unknown default falls back to the first built locale.
expect(buildRootRedirect({ locales: ['de'], defaultLocale: 'fr' })).toContain('var fallback = "de"');
});
it('emits a CSP that allows inline scripts and the configured server only', () => {
const headers = buildHeaders({ basePath: '', connectSrc: 'https://mail.example.com' });
expect(headers).toContain("script-src 'self' 'unsafe-inline'");
expect(headers).toContain("connect-src 'self' https://mail.example.com");
expect(headers).toContain("frame-ancestors 'none'");
expect(headers).toContain('/_next/static/*');
expect(buildHeaders({ basePath: '/w', connectSrc: '*' })).toMatch(/^\/w\/\*/);
});
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/');
expect(buildCaddyExample({ basePath: '' })).toContain('try_files {path} {path}/ {path}/index.html /{re.surface.1}/{re.surface.2}/index.html');
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');
expect(readme).toContain('permissive-cors = true');
expect(readme).toContain('currently `https://m.example`');
});
it('flags server endpoints outside the documented allowlist', () => {
expect(unexpectedApiStrings(['/api/config', '/api/plugins/x', '/api/push/register/web'])).toEqual([]);
expect(unexpectedApiStrings(['/api/brand-new-thing', '/api/config'])).toEqual(['/api/brand-new-thing']);
expect(LITE_API_STRING_ALLOWLIST).toContain('/api/config');
});
});
describe('prepare / postbuild / verify against a fake checkout', () => {
it('plans the server-only trees and every test file, skipping node_modules, repos and scripts', () => {
const root = makeRepo();
try {
const tests = collectTestPaths(root).map((p) => p.slice(root.length + 1).replace(/\\/g, '/'));
expect(tests.sort()).toEqual(['lib/__tests__', 'stores/bar.test.tsx']);
const plan = planRemovals(root).map((p) => p.slice(root.length + 1).replace(/\\/g, '/'));
expect(plan).toContain('proxy.ts');
expect(plan).toContain('app/api');
expect(plan).toContain('app/(main)/admin');
expect(plan).not.toContain('app/(main)/[locale]/mail/[[...segments]]/page.tsx');
expect(plan).not.toContain('scripts/lite/smoke/lite.spec.ts');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('refuses to delete outside CI unless --in-place is given, and --dry-run never deletes', () => {
const root = makeRepo();
try {
const log = () => {};
expect(() => runPrepare({ root, argv: [], env: env(), log })).toThrow(/refusing/);
runPrepare({ root, argv: ['--dry-run'], env: env(), log });
expect(existsSync(join(root, 'proxy.ts'))).toBe(true);
const result = runPrepare({ root, argv: ['--in-place'], env: env(), log });
expect(result.removed.length).toBeGreaterThan(0);
expect(existsSync(join(root, 'proxy.ts'))).toBe(false);
expect(existsSync(join(root, 'app', 'api'))).toBe(false);
expect(existsSync(join(root, 'lib', '__tests__'))).toBe(false);
expect(existsSync(join(root, 'lib', 'keep.ts'))).toBe(true);
expect(existsSync(join(root, 'scripts', 'lite', 'smoke', 'lite.spec.ts'))).toBe(true);
expect(existsSync(join(root, 'node_modules', 'pkg', 'x.test.js'))).toBe(true);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('CI=true counts as consent', () => {
const root = makeRepo();
try {
runPrepare({ root, argv: [], env: env({ CI: 'true' }), log: () => {} });
expect(existsSync(join(root, 'proxy.ts'))).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('postbuild writes the deployer files for the built locales and verify accepts the result', () => {
const root = makeRepo();
try {
const out = join(root, 'out');
for (const locale of ['en', 'de']) {
for (const surface of ['mail', 'calendar', 'contacts', 'files', 'settings', 'login']) {
mkdirSync(join(out, locale, surface), { recursive: true });
writeFileSync(join(out, locale, surface, 'index.html'), '<html></html>');
}
writeFileSync(join(out, locale, 'index.html'), '<html></html>');
}
mkdirSync(join(out, '_next', 'static', 'chunks'), { recursive: true });
writeFileSync(join(out, '_next', 'static', 'chunks', 'app.js'), 'fetch("/api/config");fetch(`/api/plugins`);');
mkdirSync(join(out, 'branding'), { recursive: true });
writeFileSync(join(out, '404.html'), '<html></html>');
expect(discoverBuiltLocales(out)).toEqual(['de', 'en']);
const result = runPostbuild({
root,
env: env({ LITE_JMAP_SERVER_URL: 'https://mail.example.com', LITE_APP_NAME: 'Acme', NEXT_PUBLIC_BASE_PATH: '/w', GIT_COMMIT: 'abcdef1234567' }),
log: () => {},
});
expect(result.locales).toEqual(['de', 'en']);
expect(result.basePath).toBe('/w');
for (const file of ['config.json', 'policy.json', 'manifest.webmanifest', 'index.html', '_redirects', '_headers', 'nginx.conf.example', 'Caddyfile.example', 'LITE-README.md', 'lite-build.json']) {
expect(existsSync(join(out, file)), file).toBe(true);
}
expect(collectApiStrings(join(out, '_next', 'static'))).toEqual(['/api/config', '/api/plugins']);
const verdict = verifyExport({ root });
expect(verdict.problems).toEqual([]);
expect(verdict.locales).toEqual(['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');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('verify lists what is missing from an incomplete export', () => {
const root = makeRepo();
try {
mkdirSync(join(root, 'out'), { recursive: true });
const { problems } = verifyExport({ root });
expect(problems).toContain('no locale shells found (out/<locale>/mail/index.html)');
expect(problems).toContain('missing out/config.json');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
+47
View File
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { locales } from '@/i18n/routing';
const liteState = vi.hoisted(() => ({ IS_LITE: false }));
vi.mock('@/lib/lite', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/lite')>()),
get IS_LITE() {
return liteState.IS_LITE;
},
}));
import { generateLiteLocaleParams, generateLiteSegmentParams, liteLocaleParams, liteSegmentParams, pickLiteLocales } from '@/lib/lite-static-params';
describe('lite static params', () => {
afterEach(() => {
vi.unstubAllEnvs();
liteState.IS_LITE = false;
});
it('keeps the server build on demand: nothing is enumerated outside Lite', () => {
expect(liteLocaleParams()).toEqual([]);
expect(liteSegmentParams()).toEqual([]);
// The page/layout exports are `undefined` outside Lite, which Next treats
// as "no generateStaticParams" - the routes keep their dynamic mode.
expect(generateLiteLocaleParams).toBeUndefined();
expect(generateLiteSegmentParams).toBeUndefined();
});
it('enumerates every locale and the bare surface in Lite', () => {
liteState.IS_LITE = true;
expect(liteLocaleParams()).toEqual(locales.map((locale) => ({ locale })));
expect(liteSegmentParams()).toEqual([{ segments: [] }]);
});
it('honours a LITE_LOCALES subset and ignores unknown entries', () => {
liteState.IS_LITE = true;
vi.stubEnv('LITE_LOCALES', ' de, en ,xx ');
expect(liteLocaleParams()).toEqual([{ locale: 'de' }, { locale: 'en' }]);
});
it('falls back to all locales when the subset matches nothing', () => {
expect(pickLiteLocales('xx,yy', ['en', 'de'])).toEqual(['en', 'de']);
expect(pickLiteLocales('', ['en', 'de'])).toEqual(['en', 'de']);
expect(pickLiteLocales(undefined, ['en', 'de'])).toEqual(['en', 'de']);
expect(pickLiteLocales('de', ['en', 'de'])).toEqual(['de']);
});
});
+285
View File
@@ -0,0 +1,285 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
LiteLoginError,
clearAllLiteSessions,
clearLiteRefreshToken,
clearLiteSlot,
liteRefreshTokens,
liteTokenLogin,
probeLiteTokenLogin,
readLiteBasicSession,
readLiteRefreshToken,
resetLiteProbeCache,
saveLiteBasicSession,
saveLiteRefreshToken,
} from '@/lib/auth/lite-tokens';
type FetchInput = Parameters<typeof fetch>[0];
type FetchInit = Parameters<typeof fetch>[1];
const SERVER = 'https://mail.example.com';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
}
describe('lite token storage', () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});
it('keeps a remembered refresh token in localStorage and a tab-only one in sessionStorage', () => {
saveLiteRefreshToken(0, { serverUrl: `${SERVER}/`, username: 'alice', refreshToken: 'r0' }, true);
saveLiteRefreshToken(1, { serverUrl: SERVER, username: 'bob', refreshToken: 'r1' }, false);
expect(JSON.parse(localStorage.getItem('bulwark-lite:refresh:0')!)).toEqual({ serverUrl: SERVER, username: 'alice', refreshToken: 'r0' });
expect(localStorage.getItem('bulwark-lite:refresh:1')).toBeNull();
expect(JSON.parse(sessionStorage.getItem('bulwark-lite:refresh:1')!).refreshToken).toBe('r1');
expect(readLiteRefreshToken(0)?.refreshToken).toBe('r0');
expect(readLiteRefreshToken(1)?.refreshToken).toBe('r1');
expect(readLiteRefreshToken(2)).toBeNull();
});
it('moving a slot between persistence modes leaves no stale copy behind', () => {
saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'alice', refreshToken: 'old' }, true);
saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'alice', refreshToken: 'new' }, false);
expect(localStorage.getItem('bulwark-lite:refresh:0')).toBeNull();
expect(readLiteRefreshToken(0)?.refreshToken).toBe('new');
});
it('clears per slot and all at once, including the Basic fallback', () => {
saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'a', refreshToken: 'r' }, true);
saveLiteBasicSession(0, { serverUrl: SERVER, username: 'a', password: 'pw' });
saveLiteBasicSession(3, { serverUrl: SERVER, username: 'c', password: 'pw3' });
localStorage.setItem('unrelated', '1');
clearLiteRefreshToken(0);
expect(readLiteRefreshToken(0)).toBeNull();
expect(readLiteBasicSession(0)?.password).toBe('pw');
clearLiteSlot(0);
expect(readLiteBasicSession(0)).toBeNull();
expect(readLiteBasicSession(3)?.username).toBe('c');
clearAllLiteSessions();
expect(readLiteBasicSession(3)).toBeNull();
expect(localStorage.getItem('unrelated')).toBe('1');
});
it('ignores corrupt storage entries', () => {
localStorage.setItem('bulwark-lite:refresh:0', '{not json');
sessionStorage.setItem('bulwark-lite:basic:0', JSON.stringify({ username: 'x' }));
expect(readLiteRefreshToken(0)).toBeNull();
expect(readLiteBasicSession(0)).toBeNull();
});
});
describe('liteTokenLogin', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('runs the authCode + PKCE flow against the mail server itself', async () => {
fetchMock.mockImplementation(async (input: FetchInput, init?: FetchInit) => {
const url = String(input);
if (url === `${SERVER}/api/auth`) {
const body = JSON.parse(String(init?.body));
expect(body).toMatchObject({
type: 'authCode',
accountName: 'alice',
accountSecret: 'pw',
mfaToken: '123456',
clientId: 'bulwark-webmail',
redirectUri: 'https://lite.example/en/auth/callback',
codeChallengeMethod: 'S256',
});
expect(body.codeChallenge).toMatch(/^[A-Za-z0-9_-]{43}$/);
return jsonResponse({ type: 'authenticated', client_code: 'CODE' });
}
if (url === `${SERVER}/auth/token`) {
const params = new URLSearchParams(String(init?.body));
expect(params.get('grant_type')).toBe('authorization_code');
expect(params.get('code')).toBe('CODE');
expect(params.get('redirect_uri')).toBe('https://lite.example/en/auth/callback');
expect(params.get('code_verifier')).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(init?.headers).toMatchObject({ 'Content-Type': 'application/x-www-form-urlencoded' });
return jsonResponse({ access_token: 'AT', expires_in: 1200, refresh_token: 'RT' });
}
throw new Error(`unexpected fetch ${url}`);
});
const tokens = await liteTokenLogin({
serverUrl: `${SERVER}/`,
username: 'alice',
password: 'pw',
totp: '123456',
redirectUri: 'https://lite.example/en/auth/callback',
});
expect(tokens).toEqual({ accessToken: 'AT', expiresIn: 1200, refreshToken: 'RT' });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('omits the MFA token when none was given and defaults expires_in', async () => {
fetchMock.mockImplementation(async (input: FetchInput, init?: FetchInit) => {
if (String(input).endsWith('/api/auth')) {
expect(JSON.parse(String(init?.body))).not.toHaveProperty('mfaToken');
return jsonResponse({ type: 'authenticated', client_code: 'C' });
}
return jsonResponse({ access_token: 'AT' });
});
const tokens = await liteTokenLogin({ serverUrl: SERVER, username: 'a', password: 'p', redirectUri: 'https://x/cb' });
expect(tokens).toEqual({ accessToken: 'AT', expiresIn: 3600, refreshToken: null });
});
it.each([
[{ type: 'mfaRequired' }, 'totp_required'],
[{ type: 'failure' }, 'invalid_credentials'],
[{ type: 'authenticated' }, 'login_failed'],
])('maps a %j login answer to %s', async (answer, code) => {
fetchMock.mockResolvedValue(jsonResponse(answer));
await expect(liteTokenLogin({ serverUrl: SERVER, username: 'a', password: 'p', redirectUri: 'https://x/cb' }))
.rejects.toMatchObject({ name: 'LiteLoginError', code });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('reports a server without the structured login as endpoint_missing', async () => {
fetchMock.mockResolvedValue(new Response('not found', { status: 404 }));
await expect(liteTokenLogin({ serverUrl: SERVER, username: 'a', password: 'p', redirectUri: 'https://x/cb' }))
.rejects.toMatchObject({ code: 'endpoint_missing', status: 404 });
});
it('reports a failed code exchange', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ type: 'authenticated', client_code: 'C' }))
.mockResolvedValueOnce(jsonResponse({ error: 'invalid_grant' }, 400));
await expect(liteTokenLogin({ serverUrl: SERVER, username: 'a', password: 'p', redirectUri: 'https://x/cb' }))
.rejects.toMatchObject({ code: 'token_exchange_failed', status: 400 });
});
it('lets network failures propagate untouched', async () => {
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
await expect(liteTokenLogin({ serverUrl: SERVER, username: 'a', password: 'p', redirectUri: 'https://x/cb' }))
.rejects.toBeInstanceOf(TypeError);
});
});
describe('liteRefreshTokens', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('renews with grant_type=refresh_token and stores a rotated token where the old one lived', async () => {
saveLiteRefreshToken(2, { serverUrl: SERVER, username: 'alice', refreshToken: 'RT1' }, true);
fetchMock.mockImplementation(async (input: FetchInput, init?: FetchInit) => {
expect(String(input)).toBe(`${SERVER}/auth/token`);
const params = new URLSearchParams(String(init?.body));
expect(params.get('grant_type')).toBe('refresh_token');
expect(params.get('refresh_token')).toBe('RT1');
expect(params.get('client_id')).toBe('bulwark-webmail');
return jsonResponse({ access_token: 'AT2', expires_in: 900, refresh_token: 'RT2' });
});
const tokens = await liteRefreshTokens(2);
expect(tokens).toEqual({ accessToken: 'AT2', expiresIn: 900, refreshToken: 'RT2' });
expect(JSON.parse(localStorage.getItem('bulwark-lite:refresh:2')!).refreshToken).toBe('RT2');
expect(sessionStorage.getItem('bulwark-lite:refresh:2')).toBeNull();
});
it('keeps a tab-only token in sessionStorage after rotation', async () => {
saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'a', refreshToken: 'RT1' }, false);
fetchMock.mockResolvedValue(jsonResponse({ access_token: 'AT', refresh_token: 'RT2' }));
await liteRefreshTokens(0);
expect(localStorage.getItem('bulwark-lite:refresh:0')).toBeNull();
expect(readLiteRefreshToken(0)?.refreshToken).toBe('RT2');
});
it('rejects immediately without a stored token', async () => {
await expect(liteRefreshTokens(0)).rejects.toMatchObject({ code: 'refresh_rejected' });
expect(fetchMock).not.toHaveBeenCalled();
});
it('clears the slot when the server rejects the refresh token', async () => {
saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'a', refreshToken: 'RT' }, true);
fetchMock.mockResolvedValue(jsonResponse({ error: 'invalid_grant' }, 400));
await expect(liteRefreshTokens(0)).rejects.toMatchObject({ code: 'refresh_rejected', status: 400 });
expect(readLiteRefreshToken(0)).toBeNull();
});
it('keeps the token on a 5xx so the session can resume after the outage', async () => {
saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'a', refreshToken: 'RT' }, true);
fetchMock.mockResolvedValue(new Response('', { status: 503 }));
await expect(liteRefreshTokens(0)).rejects.toMatchObject({ code: 'token_exchange_failed', status: 503 });
expect(readLiteRefreshToken(0)?.refreshToken).toBe('RT');
});
it('propagates network errors and keeps the token', async () => {
saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'a', refreshToken: 'RT' }, true);
fetchMock.mockRejectedValue(new TypeError('offline'));
await expect(liteRefreshTokens(0)).rejects.toBeInstanceOf(TypeError);
expect(readLiteRefreshToken(0)?.refreshToken).toBe('RT');
});
});
describe('probeLiteTokenLogin', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
resetLiteProbeCache();
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('answers true for a Stalwart that validates the empty login and caches per URL', async () => {
fetchMock.mockResolvedValue(new Response('bad request', { status: 400 }));
await expect(probeLiteTokenLogin(`${SERVER}/`)).resolves.toBe(true);
await expect(probeLiteTokenLogin(SERVER)).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe(`${SERVER}/api/auth`);
});
it.each([404, 405, 501])('answers false when the endpoint answers %i', async (status) => {
fetchMock.mockResolvedValue(new Response('', { status }));
await expect(probeLiteTokenLogin(SERVER)).resolves.toBe(false);
});
it('answers null when the server cannot be reached', async () => {
fetchMock.mockRejectedValue(new TypeError('blocked'));
await expect(probeLiteTokenLogin(SERVER)).resolves.toBeNull();
});
it('never probes a non-http URL', async () => {
await expect(probeLiteTokenLogin('/api/dev-jmap')).resolves.toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it('exposes typed errors', () => {
const err = new LiteLoginError('endpoint_missing', 404, 'nope');
expect(err.message).toBe('endpoint_missing: nope');
expect(err.status).toBe(404);
});
});
+341
View File
@@ -0,0 +1,341 @@
import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce';
/**
* Cookie-free sessions for the static Lite build.
*
* The regular build keeps refresh tokens and "remember me" passwords in
* encrypted cookies written by /api/auth/*. Lite has no server, so it does
* what Stalwart's own web admin does: authenticate against the mail server's
* structured login endpoint straight from the browser
*
* POST <server>/api/auth (password + optional MFA token, PKCE challenge)
* -> authorization code
* POST <server>/auth/token (grant_type=authorization_code)
* -> access token (memory) + refresh token
*
* and renew with `grant_type=refresh_token`. The refresh token is the only
* thing that persists: localStorage when the user ticked "remember me",
* sessionStorage (this tab only) otherwise. Servers without /api/auth (older
* Stalwart, other JMAP servers) fall back to Basic auth; those sessions can
* only survive a reload inside the same tab.
*
* Trade-off, documented in LITE-README.md: a refresh token in web storage is
* readable by any script on the origin. That is the standard SPA position,
* and strictly better than storing the password.
*/
/**
* OAuth client id registered with Stalwart. Mirrors DEFAULT_CLIENT_ID in
* lib/oauth/token-exchange.ts, which cannot be imported here: that module
* reads secrets from the filesystem and must stay out of the browser bundle.
*/
export const LITE_CLIENT_ID = 'bulwark-webmail';
export type LiteLoginErrorCode =
| 'endpoint_missing'
| 'totp_required'
| 'invalid_credentials'
| 'login_failed'
| 'token_exchange_failed'
| 'refresh_rejected';
export class LiteLoginError extends Error {
constructor(readonly code: LiteLoginErrorCode, readonly status?: number, detail?: string) {
super(detail ? `${code}: ${detail}` : code);
this.name = 'LiteLoginError';
}
}
export interface LiteTokens {
accessToken: string;
expiresIn: number;
refreshToken: string | null;
}
interface StoredRefreshToken {
serverUrl: string;
username: string;
refreshToken: string;
}
interface StoredBasicSession {
serverUrl: string;
username: string;
password: string;
}
const REFRESH_KEY_PREFIX = 'bulwark-lite:refresh:';
const BASIC_KEY_PREFIX = 'bulwark-lite:basic:';
function trimUrl(url: string): string {
return url.replace(/\/+$/, '');
}
function storage(kind: 'local' | 'session'): Storage | null {
try {
if (typeof window === 'undefined') return null;
return kind === 'local' ? window.localStorage : window.sessionStorage;
} catch {
return null;
}
}
function readJson<T>(store: Storage | null, key: string): T | null {
try {
const raw = store?.getItem(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
function writeJson(store: Storage | null, key: string, value: unknown): void {
try {
store?.setItem(key, JSON.stringify(value));
} catch {
// Storage full or blocked: the session simply won't survive a reload.
}
}
function remove(store: Storage | null, key: string): void {
try {
store?.removeItem(key);
} catch {
// ignore
}
}
// ---------------------------------------------------------------------------
// Storage
// ---------------------------------------------------------------------------
/**
* Persists a refresh token for an account slot. `persistent` (remember me)
* survives browser restarts; otherwise the token lives with the tab.
*/
export function saveLiteRefreshToken(
slot: number,
entry: StoredRefreshToken,
persistent: boolean,
): void {
const key = `${REFRESH_KEY_PREFIX}${slot}`;
remove(storage('local'), key);
remove(storage('session'), key);
writeJson(storage(persistent ? 'local' : 'session'), key, { ...entry, serverUrl: trimUrl(entry.serverUrl) });
}
export function readLiteRefreshToken(slot: number): StoredRefreshToken | null {
const key = `${REFRESH_KEY_PREFIX}${slot}`;
const entry = readJson<StoredRefreshToken>(storage('local'), key) ?? readJson<StoredRefreshToken>(storage('session'), key);
return entry && typeof entry.refreshToken === 'string' && entry.refreshToken ? entry : null;
}
/** Where the slot's refresh token currently lives, so a rotation stays put. */
function refreshTokenIsPersistent(slot: number): boolean {
return readJson<StoredRefreshToken>(storage('local'), `${REFRESH_KEY_PREFIX}${slot}`) !== null;
}
export function clearLiteRefreshToken(slot: number): void {
const key = `${REFRESH_KEY_PREFIX}${slot}`;
remove(storage('local'), key);
remove(storage('session'), key);
}
/**
* Basic-auth fallback for servers without token login: the credentials stay
* with this tab (sessionStorage) so a reload does not end the session.
*/
export function saveLiteBasicSession(slot: number, entry: StoredBasicSession): void {
writeJson(storage('session'), `${BASIC_KEY_PREFIX}${slot}`, { ...entry, serverUrl: trimUrl(entry.serverUrl) });
}
export function readLiteBasicSession(slot: number): StoredBasicSession | null {
const entry = readJson<StoredBasicSession>(storage('session'), `${BASIC_KEY_PREFIX}${slot}`);
return entry && typeof entry.password === 'string' ? entry : null;
}
export function clearLiteBasicSession(slot: number): void {
remove(storage('session'), `${BASIC_KEY_PREFIX}${slot}`);
}
export function clearLiteSlot(slot: number): void {
clearLiteRefreshToken(slot);
clearLiteBasicSession(slot);
}
export function clearAllLiteSessions(): void {
for (const kind of ['local', 'session'] as const) {
const store = storage(kind);
if (!store) continue;
const keys: string[] = [];
try {
for (let i = 0; i < store.length; i++) {
const key = store.key(i);
if (key && (key.startsWith(REFRESH_KEY_PREFIX) || key.startsWith(BASIC_KEY_PREFIX))) keys.push(key);
}
} catch {
continue;
}
keys.forEach((key) => remove(store, key));
}
}
// ---------------------------------------------------------------------------
// Stalwart endpoints
// ---------------------------------------------------------------------------
interface LoginResponse {
type?: string;
client_code?: string;
}
interface TokenResponse {
access_token?: string;
expires_in?: number;
refresh_token?: string;
error?: string;
}
/**
* Password (+ optional TOTP) login against Stalwart's structured endpoint.
* Mirrors app/api/auth/totp-token-exchange/route.ts, minus the server hop.
*/
export async function liteTokenLogin(params: {
serverUrl: string;
username: string;
password: string;
totp?: string;
redirectUri: string;
clientId?: string;
}): Promise<LiteTokens> {
const base = trimUrl(params.serverUrl);
const clientId = params.clientId || LITE_CLIENT_ID;
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const loginResponse = await fetch(`${base}/api/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'authCode',
accountName: params.username,
accountSecret: params.password,
...(params.totp ? { mfaToken: params.totp } : {}),
clientId,
redirectUri: params.redirectUri,
codeChallenge: challenge,
codeChallengeMethod: 'S256',
}),
});
if (!loginResponse.ok) {
if (loginResponse.status === 404 || loginResponse.status === 405 || loginResponse.status === 501) {
throw new LiteLoginError('endpoint_missing', loginResponse.status);
}
const detail = (await loginResponse.text().catch(() => '')).slice(0, 200);
throw new LiteLoginError('login_failed', loginResponse.status, detail);
}
const login = (await loginResponse.json().catch(() => ({}))) as LoginResponse;
switch (login.type) {
case 'authenticated':
break;
case 'mfaRequired':
throw new LiteLoginError('totp_required', 401);
default:
throw new LiteLoginError('invalid_credentials', 401);
}
if (!login.client_code) throw new LiteLoginError('login_failed', 502, 'missing client_code');
const tokenResponse = await fetch(`${base}/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: login.client_code,
client_id: clientId,
redirect_uri: params.redirectUri,
code_verifier: verifier,
}).toString(),
});
const tokens = (await tokenResponse.json().catch(() => ({}))) as TokenResponse;
if (!tokenResponse.ok || !tokens.access_token) {
throw new LiteLoginError('token_exchange_failed', tokenResponse.status, tokens.error);
}
return {
accessToken: tokens.access_token,
expiresIn: tokens.expires_in || 3600,
refreshToken: tokens.refresh_token ?? null,
};
}
/**
* Renews the slot's access token. A rejected refresh token clears the slot and
* throws `refresh_rejected`; a network failure or 5xx propagates untouched so
* callers treat it as an outage, not a sign-out.
*/
export async function liteRefreshTokens(slot: number, clientId = LITE_CLIENT_ID): Promise<LiteTokens> {
const stored = readLiteRefreshToken(slot);
if (!stored) throw new LiteLoginError('refresh_rejected', 401, 'no refresh token');
const response = await fetch(`${stored.serverUrl}/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: stored.refreshToken,
client_id: clientId,
}).toString(),
});
if (response.status >= 500) {
throw new LiteLoginError('token_exchange_failed', response.status);
}
const tokens = (await response.json().catch(() => ({}))) as TokenResponse;
if (!response.ok || !tokens.access_token) {
clearLiteRefreshToken(slot);
throw new LiteLoginError('refresh_rejected', response.status || 401, tokens.error);
}
if (tokens.refresh_token && tokens.refresh_token !== stored.refreshToken) {
saveLiteRefreshToken(slot, { ...stored, refreshToken: tokens.refresh_token }, refreshTokenIsPersistent(slot));
}
return {
accessToken: tokens.access_token,
expiresIn: tokens.expires_in || 3600,
refreshToken: tokens.refresh_token ?? stored.refreshToken,
};
}
// ---------------------------------------------------------------------------
// Capability probe
// ---------------------------------------------------------------------------
const probeCache = new Map<string, Promise<boolean | null>>();
/**
* Whether `serverUrl` exposes Stalwart's structured login (and therefore
* "remember me" across browser restarts). `null` means the probe could not
* tell (unreachable, CORS blocked) - callers then attempt token login anyway
* and fall back on `endpoint_missing`.
*/
export function probeLiteTokenLogin(serverUrl: string): Promise<boolean | null> {
const base = trimUrl(serverUrl);
if (!/^https?:\/\//.test(base)) return Promise.resolve(false);
let pending = probeCache.get(base);
if (!pending) {
pending = fetch(`${base}/api/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
.then((res) => !(res.status === 404 || res.status === 405 || res.status === 501))
.catch(() => null);
probeCache.set(base, pending);
}
return pending;
}
/** Test hook. */
export function resetLiteProbeCache(): void {
probeCache.clear();
}
+153
View File
@@ -0,0 +1,153 @@
import type { ConfigData } from '@/hooks/use-config';
import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY } from '@/lib/admin/types';
/**
* Runtime configuration for the static Lite build.
*
* `/api/config` used to merge env vars, the admin dashboard and per-host
* branding on the server. Lite reads `config.json` instead: a deployer edits a
* handful of keys (server URL, app name, logos) and everything else falls
* back to the same defaults `useConfig` already assumes. Whatever the file
* says, the flags that only work with a Next.js server behind them are forced
* off so no surface ever calls an endpoint that does not exist.
*/
/** Keys a deployer may set in config.json. Everything else is ignored. */
export const LITE_CONFIG_KEYS = [
'appName',
'jmapServerUrl',
'allowCustomJmapEndpoint',
'jmapServers',
'jmapServerAutoPickByDomain',
'demoMode',
'rememberMeEnabled',
'faviconUrl',
'appLogoLightUrl',
'appLogoDarkUrl',
'loginLogoLightUrl',
'loginLogoDarkUrl',
'loginCompanyName',
'loginImprintUrl',
'loginPrivacyPolicyUrl',
'loginWebsiteUrl',
'loginLogoMaxHeight',
'loginLogoMaxWidth',
'loginShowHeading',
'loginShowSubtitle',
'loginShowTotp',
'loginShowVersion',
'embeddedMode',
'parentOrigin',
] as const;
/**
* Flags that need the Next.js server (cookies, filesystem, OAuth relay). They
* are pinned regardless of what config.json contains.
*/
export const LITE_FORCED_FLAGS = {
oauthEnabled: false,
oauthOnly: false,
autoSsoEnabled: false,
settingsSyncEnabled: false,
stalwartFeaturesEnabled: false,
stalwartJmapPassthroughEnabled: false,
devMode: false,
} as const satisfies Partial<ConfigData>;
function str(value: unknown, fallback: string): string {
return typeof value === 'string' ? value : fallback;
}
function bool(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback;
}
function trimUrl(value: string): string {
return value.trim().replace(/\/+$/, '');
}
/**
* Turns the (possibly partial, possibly hand-edited) contents of config.json
* into the full `ConfigData` shape the app consumes.
*
* - `jmapServerUrl` is trimmed of trailing slashes.
* - `allowCustomJmapEndpoint` defaults to *on* when no server is configured,
* so an unedited download still lets people type their server.
* - `rememberMeEnabled` defaults to on; the login page still hides the box for
* servers without Stalwart's token login (see lib/auth/lite-tokens.ts).
*/
export function applyLiteConfig(raw: unknown): ConfigData {
const input = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
const jmapServerUrl = trimUrl(str(input.jmapServerUrl, ''));
const jmapServers = Array.isArray(input.jmapServers)
? (input.jmapServers as unknown[])
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
.filter((entry) => typeof entry.id === 'string' && typeof entry.url === 'string')
.map((entry) => ({
id: entry.id as string,
label: str(entry.label, entry.id as string),
url: trimUrl(entry.url as string),
domains: Array.isArray(entry.domains)
? (entry.domains as unknown[]).filter((d): d is string => typeof d === 'string')
: [],
}))
: [];
return {
appName: str(input.appName, '') || 'Webmail',
jmapServerUrl,
oauthClientId: '',
oauthIssuerUrl: '',
oauthScopes: '',
rememberMeEnabled: bool(input.rememberMeEnabled, true),
faviconUrl: str(input.faviconUrl, '') || '/branding/Bulwark_Favicon.svg',
appLogoLightUrl: str(input.appLogoLightUrl, ''),
appLogoDarkUrl: str(input.appLogoDarkUrl, ''),
loginLogoLightUrl: str(input.loginLogoLightUrl, '') || '/branding/Bulwark_Logo_Color.svg',
loginLogoDarkUrl: str(input.loginLogoDarkUrl, '') || '/branding/Bulwark_Logo_White.svg',
loginCompanyName: str(input.loginCompanyName, ''),
loginImprintUrl: str(input.loginImprintUrl, ''),
loginPrivacyPolicyUrl: str(input.loginPrivacyPolicyUrl, ''),
loginWebsiteUrl: str(input.loginWebsiteUrl, ''),
loginLogoMaxHeight: str(input.loginLogoMaxHeight, ''),
loginLogoMaxWidth: str(input.loginLogoMaxWidth, ''),
loginShowHeading: bool(input.loginShowHeading, true),
loginShowSubtitle: bool(input.loginShowSubtitle, true),
loginShowTotp: bool(input.loginShowTotp, true),
loginShowVersion: bool(input.loginShowVersion, true),
demoMode: bool(input.demoMode, false),
allowCustomJmapEndpoint: bool(input.allowCustomJmapEndpoint, jmapServerUrl === '' && jmapServers.length === 0),
jmapServers,
jmapServerAutoPickByDomain: bool(input.jmapServerAutoPickByDomain, false),
embeddedMode: bool(input.embeddedMode, false),
parentOrigin: str(input.parentOrigin, ''),
...LITE_FORCED_FLAGS,
};
}
/**
* Policy for Lite: whatever policy.json says, merged over the defaults, with
* the features that need the sandbox routes or the admin server pinned off.
*/
export function applyLitePolicy(raw: unknown): SettingsPolicy {
const input = (raw && typeof raw === 'object' ? raw : {}) as Partial<SettingsPolicy>;
return {
...DEFAULT_POLICY,
...input,
restrictions: { ...(input.restrictions ?? {}) },
defaults: { ...(input.defaults ?? {}) },
themePolicy: { ...DEFAULT_POLICY.themePolicy, ...(input.themePolicy ?? {}) },
features: {
...DEFAULT_POLICY.features,
...(input.features ?? {}),
pluginsEnabled: false,
pluginsUploadEnabled: false,
sidebarAppsEnabled: false,
},
forceEnabledPlugins: [],
approvedPlugins: [],
forceEnabledThemes: [],
defaultSidebarApps: [],
};
}
+98
View File
@@ -0,0 +1,98 @@
import { locales } from '@/i18n/routing';
import { LITE_PENDING_PATH_KEY } from '@/lib/lite';
import type { AppSurface } from '@/lib/deep-links';
/**
* Deep-link segments for the static Lite build.
*
* The exported HTML for `/en/mail/` is what a static host serves for every
* path below it (`/en/mail/thread/abc`), so the route params Next prerendered
* are always empty. The surfaces recover the segments from the address bar
* instead, with exactly the shape Next would have passed: the path after
* `/<prefix>/<locale>/<surface>/`, split and percent-decoded once.
*
* Hosts without rewrite rules (GitHub Pages) answer such a URL with 404.html.
* The not-found page parks the requested path in sessionStorage and jumps to
* the surface root; `takePendingLitePath` hands it over once so the surface
* still opens the linked item and restores the URL.
*/
const SURFACES: readonly AppSurface[] = ['mail', 'calendar', 'contacts', 'files', 'settings'];
function stripPrefix(pathname: string, prefix: string): string {
if (prefix && (pathname === prefix || pathname.startsWith(`${prefix}/`))) {
return pathname.slice(prefix.length) || '/';
}
return pathname;
}
function decodeOnce(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
/** Splits a browser pathname into locale, surface and the remaining segments. */
export function parseLitePath(
pathname: string,
prefix = '',
): { locale: string | null; surface: AppSurface | null; segments: string[] } {
const parts = stripPrefix(pathname.split('?')[0].split('#')[0], prefix)
.split('/')
.filter(Boolean);
let locale: string | null = null;
if (parts.length > 0 && (locales as readonly string[]).includes(parts[0])) {
locale = parts.shift() as string;
}
const head = parts[0];
if (!head) return { locale, surface: 'mail', segments: [] };
if ((SURFACES as readonly string[]).includes(head)) {
return { locale, surface: head as AppSurface, segments: parts.slice(1).map(decodeOnce) };
}
return { locale, surface: null, segments: [] };
}
/**
* Segments for `surface` derived from `pathname`, or `[]` when the URL points
* somewhere else (the root, another surface, the Pro shell).
*/
export function liteSegmentsFromPath(pathname: string, surface: AppSurface, prefix = ''): string[] {
const parsed = parseLitePath(pathname, prefix);
return parsed.surface === surface ? parsed.segments : [];
}
/** The surface root a parked deep link should be replayed on, or null. */
export function liteSurfaceRootFor(pathname: string, prefix = ''): string | null {
const parsed = parseLitePath(pathname, prefix);
if (!parsed.locale || !parsed.surface) return null;
return `${prefix}/${parsed.locale}/${parsed.surface}/`;
}
/** Parks a deep link for the surface root to pick up (404 shim). */
export function stashPendingLitePath(pathWithSearch: string): boolean {
try {
sessionStorage.setItem(LITE_PENDING_PATH_KEY, pathWithSearch);
return true;
} catch {
return false;
}
}
/**
* 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.
*/
export function takePendingLitePath(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;
} catch {
return null;
}
}
+48
View File
@@ -0,0 +1,48 @@
import { locales } from '@/i18n/routing';
import { IS_LITE } from '@/lib/lite';
/**
* `generateStaticParams` inputs for the localized page tree.
*
* The static export must know every path up front, so in Lite the `[locale]`
* layout enumerates the locales and each `[[...segments]]` page contributes
* its bare surface (`/en/mail`, never `/en/mail/thread/x` - deep links are
* resolved in the browser, see hooks/use-lite-link-segments.ts).
*
* The regular server build keeps every route on demand: an empty list means
* "prerender nothing", and the root layout's `headers()` call makes the routes
* dynamic anyway. Only the file exports change, not the runtime behaviour.
*/
/** Parses `LITE_LOCALES` ("de,en,fr") against the supported set; unknown or empty means all. */
export function pickLiteLocales(raw: string | undefined, all: readonly string[] = locales): string[] {
const wanted = (raw ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (wanted.length === 0) return [...all];
const picked = all.filter((l) => wanted.includes(l));
return picked.length > 0 ? picked : [...all];
}
export function liteLocaleParams(): Array<{ locale: string }> {
if (!IS_LITE) return [];
return pickLiteLocales(process.env.LITE_LOCALES).map((locale) => ({ locale }));
}
export function liteSegmentParams(): Array<{ segments: string[] }> {
if (!IS_LITE) return [];
return [{ segments: [] }];
}
/**
* What the layouts/pages export as `generateStaticParams`.
*
* Next only treats the export as present when it is a function
* (`typeof === 'function'`, build/segment-config/app/app-segments.js). An
* `undefined` export is therefore identical to no export: the server build's
* routes keep their dynamic (ƒ) mode instead of turning into SSG routes with
* an empty path list, which would change how they are rendered and cached.
*/
export const generateLiteLocaleParams = IS_LITE ? liteLocaleParams : undefined;
export const generateLiteSegmentParams = IS_LITE ? liteSegmentParams : undefined;
+26
View File
@@ -0,0 +1,26 @@
/**
* Bulwark Lite: the static build of this repo (`npm run build:lite`).
*
* Lite is the same client shell exported with `output: "export"`: mail,
* calendar, contacts and files talk to the JMAP server straight from the
* browser, so nothing here needs the Next.js server. What the server used to
* provide is either replaced by a static file next to `index.html`
* (`config.json`, `policy.json`), done in the browser against Stalwart's own
* endpoints (token login, "remember me"), or switched off (admin console,
* plugins, settings sync, cookie-backed features).
*
* `IS_LITE` is a build-time constant (`NEXT_PUBLIC_BULWARK_LITE=1`), so the
* bundler can drop the server-only branches from the Lite bundle and the
* Lite-only branches from the regular one. Keep the checks cheap and boolean:
* `if (IS_LITE) ...` next to the existing feature gates.
*/
export const IS_LITE = process.env.NEXT_PUBLIC_BULWARK_LITE === '1';
/** Runtime config file a deployer edits after unzipping (relative to the mount prefix). */
export const LITE_CONFIG_PATH = '/config.json';
/** Optional admin policy file next to `config.json`; missing means defaults. */
export const LITE_POLICY_PATH = '/policy.json';
/** sessionStorage key the 404 shim parks a deep link under (see not-found.tsx). */
export const LITE_PENDING_PATH_KEY = 'bulwark-lite:pending-path';
+6
View File
@@ -1,5 +1,6 @@
import { apiFetch } from '@/lib/browser-navigation';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { IS_LITE } from '@/lib/lite';
export type JmapMethodCall = [string, Record<string, unknown>, string];
export type JmapMethodResponse = [string, Record<string, unknown>, string];
@@ -37,6 +38,11 @@ export async function stalwartJmap(
methodCalls: JmapMethodCall[],
options: StalwartJmapOptions = {},
): Promise<JmapMethodResponse[]> {
if (IS_LITE) {
// The static build has no server to hold the credentials; behave exactly
// like a deployment with the passthrough switched off (#904).
throw buildError('Stalwart management passthrough is not available in the static build', 404);
}
const slotHeaders = typeof options.slot === 'number'
? { 'X-JMAP-Cookie-Slot': String(options.slot) }
: getActiveAccountSlotHeaders();
+4
View File
@@ -7,6 +7,7 @@
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { EmailPushConfig, Mailbox } from '@/lib/jmap/types';
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
import { IS_LITE } from '@/lib/lite';
// Per-account keys: a single browser may be signed in to multiple accounts,
// each with its own JMAP PushSubscription and its own relay record. Scoping
@@ -156,6 +157,9 @@ export class WebPushUnsupportedError extends Error {
export function isWebPushSupported(): boolean {
if (typeof window === 'undefined') return false;
// No service worker is registered in the static Lite build (see
// components/service-worker-registration.tsx), so push cannot be enabled.
if (IS_LITE) return false;
return (
'serviceWorker' in navigator &&
'PushManager' in window &&
+29 -11
View File
@@ -38,24 +38,41 @@ if (basePath && !basePath.startsWith("/")) {
);
}
// Bulwark Lite (see lib/lite.ts and scripts/lite/): the same tree exported as
// static files. `npm run build:lite` sets the flag; a plain `next build` never
// sees it, so the standalone/Docker build is unaffected.
const isLite = process.env.NEXT_PUBLIC_BULWARK_LITE === "1";
if (isLite && (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? "never") !== "always") {
// Without the proxy nothing rewrites /mail to /<locale>/mail.
throw new Error("Bulwark Lite requires NEXT_PUBLIC_LOCALE_PREFIX=always (use `npm run build:lite`)");
}
const nextConfig: NextConfig = {
output: "standalone",
output: isLite ? "export" : "standalone",
// Directory-style pages (out/en/mail/index.html) are what static hosts and
// the shipped SPA-fallback rules expect; next/image has no optimizer here.
...(isLite ? { trailingSlash: true, images: { unoptimized: true } } : {}),
allowedDevOrigins: ["192.168.1.51"],
basePath: basePath || undefined,
// esbuild ships native binaries + a README the bundler can't parse; load
// it from node_modules at runtime instead of trying to bundle it. Used by
// PLUGIN_DEV_DIR's on-the-fly bundler.
serverExternalPackages: ["esbuild"],
async headers() {
return [
{
// Untrusted plugin iframes have opaque origins and load these public,
// immutable chunks in CORS mode. Next prefixes this source with basePath.
source: "/_next/static/:path*",
headers: [{ key: "Access-Control-Allow-Origin", value: "*" }],
},
];
},
// Response headers are the host's job in a static export (see out/_headers).
...(isLite
? {}
: {
async headers() {
return [
{
// Untrusted plugin iframes have opaque origins and load these public,
// immutable chunks in CORS mode. Next prefixes this source with basePath.
source: "/_next/static/:path*",
headers: [{ key: "Access-Control-Allow-Origin", value: "*" }],
},
];
},
}),
// Sibling repos checked out under ./repos/ are unrelated source trees that
// Turbopack's NFT can otherwise rope into the trace when dynamic fs calls
// confuse it. Keeps the build from ballooning memory tracing dead code.
@@ -70,6 +87,7 @@ const nextConfig: NextConfig = {
NEXT_PUBLIC_APP_VERSION: appVersion,
NEXT_PUBLIC_BASE_PATH: basePath,
NEXT_PUBLIC_DEV_MOCK_JMAP: process.env.DEV_MOCK_JMAP ?? "",
NEXT_PUBLIC_BULWARK_LITE: isLite ? "1" : "",
},
};
+3
View File
@@ -24,6 +24,9 @@
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"build:lite": "node scripts/lite/build.mjs",
"lite:serve": "node scripts/lite/serve.mjs out 4173",
"test:lite-smoke": "playwright test -c scripts/lite/smoke/playwright.config.ts",
"start": "next start",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
// `npm run build:lite` - the whole Lite pipeline in one process so no
// cross-platform env juggling is needed:
//
// 1. prepare delete server-only trees (disposable checkout only)
// 2. next build with NEXT_PUBLIC_BULWARK_LITE=1 (output: "export")
// 3. postbuild write config.json, manifest, host snippets into out/
// 4. verify fail on missing files or undocumented /api references
//
// Inputs (env): LITE_JMAP_SERVER_URL, LITE_APP_NAME, LITE_ALLOW_CUSTOM_ENDPOINT,
// LITE_REMEMBER_ME, LITE_DEMO_MODE, LITE_LOCALES, NEXT_PUBLIC_BASE_PATH,
// NEXT_PUBLIC_DEFAULT_LOCALE, GIT_COMMIT. Flags: --skip-prepare (tree already
// pruned), --dry-run (print the plan only).
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { resolveRepoRoot } from "./lib.mjs";
import { runPrepare } from "./prepare.mjs";
import { runPostbuild } from "./postbuild.mjs";
import { verifyExport } from "./verify.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
const argv = process.argv.slice(2);
const dryRun = argv.includes("--dry-run");
const env = {
...process.env,
NEXT_PUBLIC_BULWARK_LITE: "1",
// Without the proxy nothing rewrites /mail to /en/mail: the locale must be
// in every URL.
NEXT_PUBLIC_LOCALE_PREFIX: "always",
NEXT_TELEMETRY_DISABLED: process.env.NEXT_TELEMETRY_DISABLED ?? "1",
};
if (dryRun) {
runPrepare({ argv: ["--dry-run"], env });
console.log("[lite] would run: next build --turbopack with", {
LITE_JMAP_SERVER_URL: env.LITE_JMAP_SERVER_URL ?? "",
LITE_APP_NAME: env.LITE_APP_NAME ?? "",
LITE_LOCALES: env.LITE_LOCALES ?? "(all)",
LITE_DEMO_MODE: env.LITE_DEMO_MODE ?? "false",
NEXT_PUBLIC_BASE_PATH: env.NEXT_PUBLIC_BASE_PATH ?? "",
});
process.exit(0);
}
if (!argv.includes("--skip-prepare")) {
runPrepare({ argv: argv.filter((a) => a === "--in-place"), env });
}
const require = createRequire(import.meta.url);
const nextBin = require.resolve("next/dist/bin/next", { paths: [repoRoot] });
console.log("[lite] next build --turbopack (static export)");
const build = spawnSync(process.execPath, [nextBin, "build", "--turbopack"], { cwd: repoRoot, env, stdio: "inherit" });
if (build.status !== 0) {
console.error(`[lite] next build failed with exit code ${build.status}`);
process.exit(build.status ?? 1);
}
runPostbuild({ env });
const { problems, locales, apiStrings } = verifyExport();
if (problems.length > 0) {
console.error(`[lite] verification failed (${problems.length} problems):`);
for (const p of problems) console.error(` - ${p}`);
process.exit(1);
}
console.log(`[lite] done: out/ holds ${locales.length} locales; ${apiStrings.length} documented /api strings in chunks`);
+421
View File
@@ -0,0 +1,421 @@
// Shared, side-effect-free helpers for the Lite build scripts. Kept separate
// so lib/__tests__/lite-scripts.test.ts can exercise them without touching
// the filesystem or spawning `next build`.
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
/**
* Repository root for a script under scripts/lite/. Falls back to the current
* directory when `import.meta.url` is not a file URL (vitest's transform).
*/
export function resolveRepoRoot(metaUrl) {
try {
return resolve(fileURLToPath(new URL("../..", metaUrl)));
} catch {
return process.cwd();
}
}
/** True when the module was started directly (`node scripts/lite/x.mjs`). */
export function isMainModule(metaUrl) {
try {
return !!process.argv[1] && resolve(process.argv[1]) === fileURLToPath(metaUrl);
} catch {
return false;
}
}
/** Server-only trees the static export cannot contain. */
export const LITE_REMOVED_PATHS = [
"proxy.ts",
"app/api",
"app/(main)/admin",
"app/(main)/setup",
"app/(sandbox)",
"app/manifest.ts",
"instrumentation.ts",
"instrumentation.node.ts",
"app/(main)/[...rest]",
"app/(main)/protocol",
"e2e",
"integration",
];
/** Test trees are pruned too: they import the routes removed above. */
export const LITE_TEST_DIR_NAME = "__tests__";
export const LITE_TEST_FILE_PATTERN = /\.test\.[cm]?[jt]sx?$/;
export const LITE_PRUNE_SKIP_DIRS = new Set(["node_modules", ".git", ".next", "out", "repos", "scripts"]);
/** The client shells a deployer's host must SPA-fallback to. */
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"];
/**
* `/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
* the Lite config pins off) or tolerates a 404 by design. Anything else is a
* new server dependency and fails `verify.mjs`.
*/
export const LITE_API_STRING_ALLOWLIST = [
// Path-independent generic prefix used by `apiFetch` docs/comments.
"/api/",
// stores/update-store.ts - startPolling() is a no-op in Lite.
"/api/system/update-status",
// stores/plugin-store.ts + theme-store.ts - initializePlugins()/syncServerThemes() return early in Lite.
"/api/plugins",
"/api/plugin-approval-status",
"/api/plugin-signing-pubkey",
"/api/admin/plugins",
"/api/admin/themes",
// stores/settings-store.ts - settingsSyncEnabled is pinned off.
"/api/settings",
// OAuth / SSO / pairing - oauthEnabled and stalwartFeaturesEnabled are pinned off.
"/api/auth/token",
"/api/auth/oauth/metadata",
"/api/auth/sso/start",
"/api/auth/sso/complete",
"/api/auth/reauth/sso/complete",
"/api/auth/pair/create",
"/api/auth/totp-token-exchange",
"/api/auth/session",
"/api/auth/verify",
"/api/auth/stalwart-context",
"/api/account/stalwart/jmap",
// Admin shield probe - skipped in Lite (components/layout/navigation-rail.tsx).
"/api/admin/auth",
// Settings/policy - replaced by config.json / policy.json.
"/api/config",
"/api/admin/policy",
// WOPI (#425) - use-wopi-status returns disabled in Lite.
"/api/wopi/status",
"/api/wopi/launch",
// Calendar: ICS URL import/subscriptions + CalDAV discovery are hidden in Lite.
"/api/fetch-ical",
"/api/caldav/discover",
"/api/webdav",
// Sender favicons - disabled in Lite (initials fallback).
"/api/favicon",
// Web push relay paths (relative to the *relay* origin, not this host).
"/api/push/vapid-public-key",
"/api/push/register",
"/api/push/active",
"/api/push/verify",
"/api/push/jmap",
// Dev-only JMAP mock.
"/api/jmap",
"/api/dev-jmap",
];
export function parseBool(value, fallback = false) {
if (value === undefined || value === null || value === "") return fallback;
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
}
export function normalizeBasePath(raw) {
const trimmed = (raw ?? "").trim().replace(/\/+$/, "");
if (!trimmed) return "";
if (!trimmed.startsWith("/")) throw new Error(`base path must start with "/" (got ${JSON.stringify(raw)})`);
return trimmed;
}
/** config.json contents from the LITE_* build inputs. */
export function buildLiteConfig(env = {}) {
const jmapServerUrl = (env.LITE_JMAP_SERVER_URL ?? "").trim().replace(/\/+$/, "");
return {
_comment: "Bulwark Lite runtime configuration. Edit and re-upload; no rebuild needed. See LITE-README.md.",
appName: (env.LITE_APP_NAME ?? "").trim() || "Bulwark Webmail",
jmapServerUrl,
allowCustomJmapEndpoint: parseBool(env.LITE_ALLOW_CUSTOM_ENDPOINT, jmapServerUrl === ""),
rememberMeEnabled: parseBool(env.LITE_REMEMBER_ME, true),
demoMode: parseBool(env.LITE_DEMO_MODE, false),
loginShowTotp: true,
loginShowVersion: true,
};
}
/** policy.json: only the gates Lite must pin; the app merges the rest over its defaults. */
export function buildLitePolicy() {
return {
_comment: "Optional admin policy for Bulwark Lite (same shape as the admin dashboard's policy). Plugins and sidebar apps stay off in Lite.",
features: {
pluginsEnabled: false,
sidebarAppsEnabled: false,
},
};
}
export function buildManifest({ appName, basePath = "" }) {
const p = (path) => `${basePath}${path}`;
return {
name: appName,
short_name: appName,
description: `${appName} - webmail for JMAP servers`,
start_url: p("/"),
scope: p("/"),
display: "standalone",
background_color: "#ffffff",
theme_color: "#ffffff",
icons: [
{ src: p("/icon-192x192.png"), sizes: "192x192", type: "image/png" },
{ src: p("/icon-512x512.png"), sizes: "512x512", type: "image/png" },
{ src: p("/icon-maskable-light-192x192.png"), sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: p("/icon-maskable-light-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
],
};
}
/** Root index.html: pick the visitor's locale in the browser and jump to it. */
export function buildRootRedirect({ basePath = "", locales, defaultLocale = "en" }) {
const list = JSON.stringify(locales);
const fallback = locales.includes(defaultLocale) ? defaultLocale : locales[0];
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="robots" content="noindex">
<title>Loading</title>
<script>
(function () {
var locales = ${list};
var fallback = ${JSON.stringify(fallback)};
var base = ${JSON.stringify(basePath)};
function pick(tag) {
if (!tag) return null;
tag = String(tag);
if (locales.indexOf(tag) !== -1) return tag;
var lower = tag.toLowerCase();
if (lower.indexOf("zh") === 0) {
var traditional = /zh-(tw|hk|mo|hant)/.test(lower);
if (traditional && locales.indexOf("zh-TW") !== -1) return "zh-TW";
if (locales.indexOf("zh") !== -1) return "zh";
}
var short = lower.split("-")[0];
for (var i = 0; i < locales.length; i++) if (locales[i].toLowerCase() === short) return locales[i];
return null;
}
var chosen = null;
try { chosen = pick(localStorage.getItem("NEXT_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);
})();
</script>
</head>
<body><noscript>This app needs JavaScript. <a href="${basePath}/${fallback}/">Continue</a></noscript></body>
</html>
`;
}
/** Netlify / Cloudflare Pages rewrite rules. */
export function buildRedirects({ basePath = "", locales, surfaces = LITE_SURFACES }) {
const lines = [
"# Bulwark Lite SPA fallback (Netlify / Cloudflare Pages). Deep links below a",
"# surface serve that surface's shell; everything else keeps its own file.",
];
for (const locale of locales) {
for (const surface of surfaces) {
lines.push(`${basePath}/${locale}/${surface}/* ${basePath}/${locale}/${surface}/index.html 200`);
}
// /<locale>/login etc. resolve directly; a bare locale root goes to mail.
}
return lines.join("\n") + "\n";
}
/** Security headers for hosts that read a `_headers` file. */
export function buildHeaders({ basePath = "", connectSrc = "*" }) {
const csp = [
"default-src 'self'",
// The static export ships inline hydration scripts, so no nonce is possible.
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' https: data:",
`connect-src 'self' ${connectSrc}`,
"frame-src 'self' blob:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"media-src 'self' blob:",
].join("; ");
return `${basePath || ""}/*
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Content-Security-Policy: ${csp}
${basePath || ""}/_next/static/*
Cache-Control: public, max-age=31536000, immutable
`;
}
export function buildNginxExample({ basePath = "", surfaces = LITE_SURFACES }) {
const location = basePath ? `${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.
server {
listen 80;
server_name webmail.example.com;
root /var/www/bulwark-lite;
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.
location ${basePath}/_next/static/ {
${basePath ? `alias /var/www/bulwark-lite/_next/static/;\n ` : ""}expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# /<locale>/<surface>/anything -> /<locale>/<surface>/index.html
location ~ ^${basePath}/(?<locale>[a-zA-Z-]+)/(?<surface>${surfaceAlternation})(/.*)?$ {
try_files $uri $uri/ $uri/index.html ${basePath}/$locale/$surface/index.html;
}
location ${location} {
${basePath ? `alias /var/www/bulwark-lite/;\n ` : ""}try_files $uri $uri/ $uri/index.html =404;
error_page 404 ${basePath}/404.html;
}
}
`;
}
export function buildCaddyExample({ basePath = "", surfaces = LITE_SURFACES }) {
const surfaceAlternation = surfaces.join("|");
return `# Bulwark Lite - Caddy example.
webmail.example.com {
root * /var/www/bulwark-lite
encode gzip
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
}
@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
}
file_server
}
`;
}
export function buildReadme({ version, commit, basePath = "", locales, jmapServerUrl = "", demoMode = false }) {
return `# Bulwark Lite ${version} (${commit})
A static build of Bulwark Webmail: the same mail, calendar, contacts and files
client, without the Node.js server. Upload this folder to any static host and
point it at your Stalwart (or other JMAP) server.
Built for mount path: ${basePath || "/ (site root)"}
Locales included: ${locales.join(", ")}
${demoMode ? "Demo mode is ON: the login page offers a built-in demo account and no server is needed.\n" : ""}
## Three steps
1. Unzip this archive and upload the whole folder to your web host, so that
\`${basePath || ""}/index.html\` and \`${basePath || ""}/config.json\` are served from the mount path above.
2. Edit \`config.json\`:
- \`jmapServerUrl\`: your mail server, e.g. \`https://mail.example.com\`${jmapServerUrl ? ` (currently \`${jmapServerUrl}\`)` : ""}.
- \`appName\`: the name shown in the tab and on the login page.
- \`allowCustomJmapEndpoint\`: \`true\` shows a server field on the login page.
- \`rememberMeEnabled\`: \`false\` hides "remember me" (sessions then end with the tab).
Optional keys: \`demoMode\`, \`jmapServers\`, \`jmapServerAutoPickByDomain\`, the login logo/company/link keys and \`loginShow*\` toggles (same names as the Docker env vars, camelCased).
3. Allow the browser to talk to the mail server (CORS). In Stalwart:
\`\`\`toml
[http]
permissive-cors = true
\`\`\`
or an equivalent reverse-proxy rule that allows your Lite origin with the
\`Authorization\` and \`Content-Type\` headers on \`/.well-known/jmap\`, \`/jmap/*\`,
\`/api/auth\` and \`/auth/token\`.
## Host configuration
Deep links such as \`${basePath}/en/mail/thread/abc\` are served by the shell at
\`${basePath}/en/mail/index.html\`. Configure your host to fall back to it:
- 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
in the browser. It works, with one extra page load.
\`_headers\` also carries the recommended security headers. Adjust
\`connect-src\` to your JMAP server's origin if you prefer a strict policy.
## What is different from the full Bulwark Webmail
Everything that runs in the browser works: mail, threads, search, compose,
multiple accounts, calendar, contacts, files, themes, settings export/import,
password and TOTP login, demo mode, deep links.
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.
## Security notes
- No cookies and no server session. Requests to the mail server carry the
token or credentials of the signed-in account only.
- "Remember me" keeps a Stalwart refresh token in the browser's localStorage
(a plain token, never the password). Without "remember me" the token lives
in sessionStorage and the session ends with the tab. Both are readable by
any script running on your Lite origin, so serve Lite from an origin you
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.
- 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.
`;
}
/** Locales actually present in the export (dirs that hold a mail shell). */
export function discoverBuiltLocales(outDir) {
if (!existsSync(outDir)) return [];
return readdirSync(outDir)
.filter((name) => {
const dir = join(outDir, name);
return statSync(dir).isDirectory() && existsSync(join(dir, "mail", "index.html"));
})
.sort();
}
/** Every `"/api/...` string literal found in the client chunks, deduplicated. */
export function collectApiStrings(chunksDir) {
const found = new Set();
if (!existsSync(chunksDir)) return [];
const walk = (dir) => {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) walk(full);
else if (entry.endsWith(".js")) {
const text = readFileSync(full, "utf8");
for (const match of text.matchAll(/["'`](\/api\/[A-Za-z0-9/_-]*)/g)) found.add(match[1]);
}
}
};
walk(chunksDir);
return [...found].sort();
}
export function unexpectedApiStrings(strings, allowlist = LITE_API_STRING_ALLOWLIST) {
return strings.filter((s) => !allowlist.some((allowed) => s === allowed || s.startsWith(`${allowed}/`) || (allowed.endsWith("/") && s === allowed.slice(0, -1))));
}
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
// Writes the deployer-facing files into out/ after `next build` exported the
// shells: runtime config, policy, manifest, the root locale shim and the host
// snippets. Everything is derived from the LITE_* env the build ran with.
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,
} from "./lib.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
export function runPostbuild({ root = repoRoot, env = process.env, log = console.log } = {}) {
const outDir = join(root, "out");
if (!existsSync(outDir)) throw new Error(`[lite] ${outDir} does not exist - run the export first`);
const basePath = normalizeBasePath(env.NEXT_PUBLIC_BASE_PATH);
const locales = discoverBuiltLocales(outDir);
if (locales.length === 0) throw new Error("[lite] no locale shells found under out/ (expected out/<locale>/mail/index.html)");
const defaultLocale = (env.NEXT_PUBLIC_DEFAULT_LOCALE ?? "").trim() || "en";
let version = "0.0.0";
try {
version = readFileSync(join(root, "VERSION"), "utf8").trim();
} catch {
// no VERSION file
}
const commit = (env.GIT_COMMIT ?? "").trim().slice(0, 7) || "local";
const config = buildLiteConfig(env);
const connectSrc = config.allowCustomJmapEndpoint || !config.jmapServerUrl ? "*" : new URL(config.jmapServerUrl).origin;
const files = {
"config.json": JSON.stringify(config, null, 2) + "\n",
"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 }),
"_redirects": buildRedirects({ basePath, locales }),
"_headers": buildHeaders({ basePath, connectSrc }),
"nginx.conf.example": buildNginxExample({ basePath }),
"Caddyfile.example": buildCaddyExample({ basePath }),
"LITE-README.md": buildReadme({ version, commit, basePath, locales, jmapServerUrl: config.jmapServerUrl, demoMode: config.demoMode }),
"lite-build.json": JSON.stringify({ version, commit, basePath, locales, builtAt: new Date().toISOString() }, null, 2) + "\n",
};
mkdirSync(outDir, { recursive: true });
for (const [name, content] of Object.entries(files)) {
writeFileSync(join(outDir, name), content);
}
log(`[lite] wrote ${Object.keys(files).length} files into out/ (${locales.length} locales, base path ${basePath || "/"})`);
return { outDir, locales, basePath, files: Object.keys(files) };
}
if (isMainModule(import.meta.url)) {
try {
runPostbuild();
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
}
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
// Strips the server-only trees from a *disposable* checkout so `next build`
// with `output: "export"` has nothing dynamic left to trip over. It never
// rewrites a file - it only deletes - and it refuses to run unless you say
// the tree is throwaway (`CI=true`, as in GitHub Actions, or `--in-place`).
//
// node scripts/lite/prepare.mjs --dry-run # list what would go
// node scripts/lite/prepare.mjs --in-place # delete (in a git worktree!)
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import {
LITE_PRUNE_SKIP_DIRS, LITE_REMOVED_PATHS, LITE_TEST_DIR_NAME, LITE_TEST_FILE_PATTERN, isMainModule, resolveRepoRoot,
} from "./lib.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
export function collectTestPaths(root) {
const out = [];
const walk = (dir) => {
for (const entry of readdirSync(dir)) {
if (LITE_PRUNE_SKIP_DIRS.has(entry)) continue;
const full = join(dir, entry);
let info;
try {
info = statSync(full);
} catch {
continue;
}
if (info.isDirectory()) {
if (entry === LITE_TEST_DIR_NAME) out.push(full);
else walk(full);
} else if (LITE_TEST_FILE_PATTERN.test(entry)) {
out.push(full);
}
}
};
walk(root);
return out;
}
export function planRemovals(root) {
const fixed = LITE_REMOVED_PATHS.map((p) => join(root, ...p.split("/"))).filter((p) => existsSync(p));
return [...fixed, ...collectTestPaths(root)];
}
export function runPrepare({ root = repoRoot, argv = process.argv.slice(2), env = process.env, log = console.log } = {}) {
const dryRun = argv.includes("--dry-run");
const inPlace = argv.includes("--in-place") || ["true", "1"].includes(String(env.CI).toLowerCase());
const removals = planRemovals(root);
if (dryRun) {
log(`[lite] would remove ${removals.length} paths from ${root}:`);
for (const p of removals) log(` - ${relative(root, p)}`);
return { removed: [], planned: removals };
}
if (!inPlace) {
throw new Error(
"[lite] refusing to delete files from a working tree. Run this in a disposable checkout " +
"(CI=true) or pass --in-place inside a throwaway `git worktree`. Use --dry-run to preview.",
);
}
for (const p of removals) rmSync(p, { recursive: true, force: true });
log(`[lite] removed ${removals.length} server-only paths from ${root}`);
return { removed: removals, planned: removals };
}
if (isMainModule(import.meta.url)) {
try {
runPrepare();
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
}
}
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env node
// Dependency-free static server for a Lite export, applying the same SPA
// 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=]
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 TYPES = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".webmanifest": "application/manifest+json",
".txt": "text/plain; charset=utf-8",
".md": "text/markdown; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
".woff2": "font/woff2",
".woff": "font/woff",
".map": "application/json",
};
function send(res, file, status = 200) {
res.writeHead(status, { "content-type": TYPES[extname(file)] || "application/octet-stream" });
createReadStream(file).pipe(res);
}
function fileFor(pathname) {
const rel = normalize(decodeURIComponent(pathname)).replace(/^[/\\]+/, "");
const direct = join(root, rel);
if (!direct.startsWith(root)) return null;
if (existsSync(direct) && statSync(direct).isFile()) return direct;
const index = join(direct, "index.html");
if (existsSync(index)) return index;
const html = `${direct}.html`;
if (existsSync(html)) return html;
return null;
}
const surfacePattern = new RegExp(`^/([^/]+)/(${LITE_SURFACES.join("|")})(?:/|$)`);
export function resolveRequest(pathname) {
if (basePath) {
if (pathname !== basePath && !pathname.startsWith(`${basePath}/`)) return { status: 404, file: join(root, "404.html") };
pathname = pathname.slice(basePath.length) || "/";
}
const file = fileFor(pathname);
if (file) return { status: 200, file };
const match = pathname.match(surfacePattern);
if (match) {
const shell = join(root, match[1], match[2], "index.html");
if (existsSync(shell)) return { status: 200, file: shell };
}
return { status: 404, file: join(root, "404.html") };
}
createServer((req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
const { status, file } = resolveRequest(url.pathname);
if (file && existsSync(file)) return send(res, file, status);
res.writeHead(404, { "content-type": "text/plain" });
res.end("not found");
}).listen(port, () => {
console.log(`[lite] serving ${root} at http://localhost:${port}${basePath}/`);
});
+83
View File
@@ -0,0 +1,83 @@
import { test, expect, type Page } from "@playwright/test";
/**
* Drives a Lite export built with LITE_DEMO_MODE=true. Everything runs in the
* browser against the built-in demo client, so no mail server is needed.
*
* What it proves:
* - the root shim picks a locale and the login shell hydrates,
* - config.json (not /api/config) configures the app,
* - 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,
* - no request ever targets a server endpoint on the Lite origin.
*/
async function loginAsDemo(page: Page) {
await page.goto("/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 });
}
test.describe("Bulwark Lite demo export", () => {
let apiRequests: string[];
test.beforeEach(async ({ page }) => {
apiRequests = [];
page.on("request", (req) => {
const url = new URL(req.url());
if (url.origin === new URL(page.url() || "http://localhost").origin && url.pathname.includes("/api/")) {
apiRequests.push(url.pathname);
}
});
});
test("root shim redirects to a locale and serves config.json", async ({ page }) => {
await page.goto("/");
await page.waitForURL(/\/(en|de)\/$/);
const config = await page.request.get("/config.json");
expect(config.ok()).toBe(true);
const body = await config.json();
expect(body.demoMode).toBe(true);
expect(apiRequests).toEqual([]);
});
test("demo login, surface switch and deep-link reload", async ({ page }) => {
await loginAsDemo(page);
// Mail list rendered from the demo client.
await expect(page.locator("body")).toContainText(/inbox/i, { timeout: 30_000 });
// Top-level navigation to other static shells.
await page.getByRole("link", { name: /calendar/i }).first().click();
await page.waitForURL(/\/en\/calendar\/?/);
await page.getByRole("link", { name: /contacts/i }).first().click();
await page.waitForURL(/\/en\/contacts\/?/);
// A deep link below a surface must keep its URL after a full reload and
// still hydrate the shell (served through the SPA fallback).
// (The calendar re-serialises its own state into the URL once applied,
// so the view segment may change; the date must survive.)
await page.goto("/en/calendar/week/2026-09-17");
await expect(page.locator("body")).toContainText(/September 2026/, { timeout: 30_000 });
expect(new URL(page.url()).pathname).toMatch(/^\/en\/calendar\/\w+\/2026-09-17$/);
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/");
await page.waitForFunction(() => window.location.pathname === "/en/mail/folder/inbox", null, { timeout: 30_000 });
expect(await page.evaluate(() => sessionStorage.getItem("bulwark-lite:pending-path"))).toBeNull();
expect(apiRequests).toEqual([]);
});
});
+26
View File
@@ -0,0 +1,26 @@
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.
const port = Number(process.env.LITE_SMOKE_PORT || 4173);
export default defineConfig({
testDir: ".",
testMatch: /.*\.spec\.ts$/,
timeout: 60_000,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list",
use: {
baseURL: `http://localhost:${port}`,
screenshot: "only-on-failure",
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(),
},
});
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
// 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 { join } from "node:path";
import {
collectApiStrings, discoverBuiltLocales, isMainModule, resolveRepoRoot, unexpectedApiStrings,
} from "./lib.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
export function verifyExport({ root = repoRoot } = {}) {
const outDir = join(root, "out");
const problems = [];
const locales = discoverBuiltLocales(outDir);
if (locales.length === 0) problems.push("no locale shells found (out/<locale>/mail/index.html)");
for (const locale of locales) {
for (const surface of ["mail", "calendar", "contacts", "files", "settings", "login"]) {
if (!existsSync(join(outDir, locale, surface, "index.html"))) problems.push(`missing out/${locale}/${surface}/index.html`);
}
if (!existsSync(join(outDir, locale, "index.html"))) problems.push(`missing out/${locale}/index.html`);
}
for (const file of ["index.html", "404.html", "config.json", "policy.json", "manifest.webmanifest", "_redirects", "_headers", "LITE-README.md", "lite-build.json"]) {
if (!existsSync(join(outDir, file))) problems.push(`missing out/${file}`);
}
for (const file of ["_next/static", "branding"]) {
if (!existsSync(join(outDir, file))) problems.push(`missing out/${file}`);
}
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)`);
return { problems, locales, apiStrings };
}
if (isMainModule(import.meta.url)) {
const { problems, locales, apiStrings } = verifyExport();
if (problems.length > 0) {
console.error(`[lite] verification failed (${problems.length} problems):`);
for (const p of problems) console.error(` - ${p}`);
process.exit(1);
}
console.log(`[lite] export verified: ${locales.length} locales, ${apiStrings.length} known /api strings in chunks`);
}
+280
View File
@@ -0,0 +1,280 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Bulwark Lite (static export) has no /api/* routes: sessions are kept in web
// storage and renewed against the mail server directly. These tests run the
// auth store with IS_LITE forced on and assert that nothing under /api/ on
// our own origin is ever called (config.json / policy.json are static files).
vi.mock('@/lib/lite', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/lite')>()),
IS_LITE: true,
}));
import { JMAPClient } from '@/lib/jmap/client';
import * as browserNavigation from '@/lib/browser-navigation';
import { useAuthStore } from '../auth-store';
import { useAccountStore } from '../account-store';
import { readLiteRefreshToken, readLiteBasicSession, saveLiteRefreshToken, saveLiteBasicSession } from '@/lib/auth/lite-tokens';
type FetchInput = Parameters<typeof fetch>[0];
type FetchInit = Parameters<typeof fetch>[1];
const SERVER = 'https://mail.example.com';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
}
/**
* A fetch that serves Stalwart's login + token endpoints, the static
* config/policy files, and fails on anything else - in particular on any
* /api/ route of our own origin. `calls` lists the mail-server requests.
*/
function stalwartFetch(options: { loginAnswer?: unknown; refreshAnswer?: () => Response; loginStatus?: number } = {}) {
const calls: string[] = [];
const mock = vi.fn(async (input: FetchInput, init?: FetchInit) => {
const url = String(input);
if (url === '/config.json') return jsonResponse({ jmapServerUrl: SERVER });
if (url === '/policy.json') return jsonResponse({});
if (url.startsWith('/')) throw new Error(`Lite must not call its own origin: ${url}`);
calls.push(`${init?.method ?? 'GET'} ${url}`);
if (url === `${SERVER}/api/auth`) {
if (options.loginStatus) return new Response('nope', { status: options.loginStatus });
return jsonResponse(options.loginAnswer ?? { type: 'authenticated', client_code: 'CODE' });
}
if (url === `${SERVER}/auth/token`) {
const params = new URLSearchParams(String(init?.body));
if (params.get('grant_type') === 'refresh_token') {
return options.refreshAnswer ? options.refreshAnswer() : jsonResponse({ access_token: 'AT-refreshed', expires_in: 3600, refresh_token: 'RT-2' });
}
return jsonResponse({ access_token: 'AT-1', expires_in: 3600, refresh_token: 'RT-1' });
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', mock);
return { mock, calls };
}
function liteStorageKeys(): string[] {
return [...Object.keys(localStorage), ...Object.keys(sessionStorage)].filter((k) => k.startsWith('bulwark-lite:'));
}
function registerAccount(authMode: 'basic' | 'oauth'): string {
return useAccountStore.getState().addAccount({
label: 'alice',
serverUrl: SERVER,
username: 'alice',
authMode,
rememberMe: true,
displayName: 'alice',
email: 'alice@example.com',
lastLoginAt: Date.now(),
isConnected: false,
hasError: false,
isDefault: true,
});
}
describe('auth-store in the static Lite build', () => {
let connectSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.restoreAllMocks();
sessionStorage.clear();
localStorage.clear();
window.history.pushState({}, '', '/en/login');
useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null });
useAuthStore.setState({
isAuthenticated: false,
isLoading: false,
error: null,
serverUrl: null,
username: null,
client: null,
identities: [],
primaryIdentity: null,
authMode: 'basic',
rememberMe: false,
accessToken: null,
tokenExpiresAt: null,
connectionLost: false,
activeAccountId: null,
isDemoMode: false,
});
connectSpy = vi.spyOn(JMAPClient.prototype, 'connect').mockResolvedValue(undefined);
vi.spyOn(JMAPClient.prototype, 'getIdentities').mockResolvedValue([]);
vi.spyOn(JMAPClient.prototype, 'getSessionUsername').mockReturnValue('alice@example.com');
vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {});
});
afterEach(() => {
vi.unstubAllGlobals();
for (const [id] of useAuthStore.getState().getAllConnectedClients()) {
useAuthStore.getState().removeAccount(id);
}
});
it('"remember me" logs in through Stalwart token login and keeps only a refresh token in localStorage', async () => {
const { calls } = stalwartFetch();
const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw', undefined, true);
expect(ok).toBe(true);
expect(calls).toEqual([`POST ${SERVER}/api/auth`, `POST ${SERVER}/auth/token`]);
const state = useAuthStore.getState();
expect(state.authMode).toBe('oauth');
expect(state.accessToken).toBe('AT-1');
expect(state.client?.getAuthHeader()).toBe('Bearer AT-1');
expect(readLiteRefreshToken(0)).toEqual({ serverUrl: SERVER, username: 'alice', refreshToken: 'RT-1' });
expect(localStorage.getItem('bulwark-lite:refresh:0')).not.toBeNull();
// No password anywhere in web storage.
const dump = JSON.stringify({ ...localStorage, ...sessionStorage });
expect(dump).not.toContain('"pw"');
expect(readLiteBasicSession(0)).toBeNull();
});
it('a TOTP login without "remember me" keeps the refresh token with the tab only', async () => {
const { mock } = stalwartFetch();
const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw', '123456', false);
expect(ok).toBe(true);
const loginCall = mock.mock.calls.find(([input]) => String(input) === `${SERVER}/api/auth`);
expect(JSON.parse(String(loginCall?.[1]?.body)).mfaToken).toBe('123456');
expect(localStorage.getItem('bulwark-lite:refresh:0')).toBeNull();
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 () => {
const { calls } = stalwartFetch();
const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw');
expect(ok).toBe(true);
expect(calls).toEqual([]);
expect(connectSpy).toHaveBeenCalledTimes(1);
expect(useAuthStore.getState().authMode).toBe('basic');
expect(useAuthStore.getState().client?.getAuthHeader()).toBe(`Basic ${btoa('alice:pw')}`);
expect(liteStorageKeys()).toEqual([]);
});
it('surfaces a missing MFA code as totp_required', async () => {
stalwartFetch({ loginAnswer: { type: 'mfaRequired' } });
const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw', undefined, true);
expect(ok).toBe(false);
expect(useAuthStore.getState().error).toBe('totp_required');
expect(connectSpy).not.toHaveBeenCalled();
});
it('falls back to Basic auth with a tab-scoped session when the server has no token login', async () => {
const { calls } = stalwartFetch({ loginStatus: 404 });
const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw', undefined, true);
expect(ok).toBe(true);
expect(calls).toEqual([`POST ${SERVER}/api/auth`]);
expect(useAuthStore.getState().authMode).toBe('basic');
expect(connectSpy).toHaveBeenCalledTimes(1);
expect(readLiteRefreshToken(0)).toBeNull();
expect(readLiteBasicSession(0)).toEqual({ serverUrl: SERVER, username: 'alice', password: 'pw' });
expect(localStorage.getItem('bulwark-lite:basic:0')).toBeNull();
});
it('restores a remembered token session on reload by refreshing against the mail server', async () => {
saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'alice', refreshToken: 'RT-1' }, true);
registerAccount('oauth');
const { calls } = stalwartFetch();
await useAuthStore.getState().checkAuth();
expect(calls).toEqual([`POST ${SERVER}/auth/token`]);
const state = useAuthStore.getState();
expect(state.isAuthenticated).toBe(true);
expect(state.client?.getAuthHeader()).toBe('Bearer AT-refreshed');
expect(readLiteRefreshToken(0)?.refreshToken).toBe('RT-2');
});
it('restores a tab-scoped Basic session from sessionStorage', async () => {
saveLiteBasicSession(0, { serverUrl: SERVER, username: 'alice', password: 'pw' });
registerAccount('basic');
const { calls } = stalwartFetch();
await useAuthStore.getState().checkAuth();
expect(calls).toEqual([]);
expect(useAuthStore.getState().isAuthenticated).toBe(true);
expect(useAuthStore.getState().client?.getAuthHeader()).toBe(`Basic ${btoa('alice:pw')}`);
});
it('evicts an account whose refresh token the server rejected', async () => {
saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'alice', refreshToken: 'RT-dead' }, true);
const id = registerAccount('oauth');
stalwartFetch({ refreshAnswer: () => jsonResponse({ error: 'invalid_grant' }, 400) });
await useAuthStore.getState().checkAuth();
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(useAccountStore.getState().getAccountById(id)).toBeUndefined();
expect(readLiteRefreshToken(0)).toBeNull();
});
it('keeps the account when the mail server is unreachable during restore', async () => {
saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'alice', refreshToken: 'RT-1' }, true);
const id = registerAccount('oauth');
vi.stubGlobal('fetch', vi.fn(async (input: FetchInput) => {
if (String(input) === '/config.json' || String(input) === '/policy.json') return jsonResponse({});
throw new TypeError('Failed to fetch');
}));
await useAuthStore.getState().checkAuth();
expect(useAccountStore.getState().getAccountById(id)?.hasError).toBe(true);
expect(readLiteRefreshToken(0)?.refreshToken).toBe('RT-1');
});
it('logout wipes the slot from web storage without touching our origin', async () => {
const { calls } = stalwartFetch();
await useAuthStore.getState().login(SERVER, 'alice', 'pw', undefined, true);
expect(readLiteRefreshToken(0)).not.toBeNull();
calls.length = 0;
await useAuthStore.getState().logout();
expect(calls).toEqual([]);
expect(readLiteRefreshToken(0)).toBeNull();
expect(useAuthStore.getState().isAuthenticated).toBe(false);
});
it('logoutAll clears every Lite slot', async () => {
saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'a', refreshToken: 'r0' }, true);
saveLiteRefreshToken(1, { serverUrl: SERVER, username: 'b', refreshToken: 'r1' }, false);
saveLiteBasicSession(2, { serverUrl: SERVER, username: 'c', password: 'p' });
const { calls } = stalwartFetch();
await useAuthStore.getState().logoutAll();
expect(calls).toEqual([]);
expect(liteStorageKeys()).toEqual([]);
});
it('refreshAccessToken renews through the mail server and signs out on a definitive rejection', async () => {
const { calls } = stalwartFetch();
await useAuthStore.getState().login(SERVER, 'alice', 'pw', undefined, true);
calls.length = 0;
const token = await useAuthStore.getState().refreshAccessToken();
expect(token).toBe('AT-refreshed');
expect(calls).toEqual([`POST ${SERVER}/auth/token`]);
expect(useAuthStore.getState().accessToken).toBe('AT-refreshed');
stalwartFetch({ refreshAnswer: () => jsonResponse({ error: 'invalid_grant' }, 400) });
const rejected = await useAuthStore.getState().refreshAccessToken();
expect(rejected).toBeNull();
// logout() runs asynchronously after the 401.
await vi.waitFor(() => expect(useAuthStore.getState().isAuthenticated).toBe(false));
expect(readLiteRefreshToken(0)).toBeNull();
});
});
+7
View File
@@ -475,6 +475,13 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
fetchPrincipal: async () => {
set({ isLoadingPrincipal: true, error: null });
try {
// Without the passthrough (operator switched it off, or the static Lite
// build) the principal cannot be read at all; skip the round trip and
// leave the aliases unknown, as for a non-Stalwart server. (#904)
if (!(await isStalwartJmapPassthroughEnabled())) {
set({ isLoadingPrincipal: false });
return;
}
const accountId = getPrimaryAccountId();
const responses = await stalwartJmap([
['x:Account/get', { accountId, ids: [accountId] }, '0'],
+173 -43
View File
@@ -19,6 +19,18 @@ import { notifyParent } from '@/lib/iframe-bridge';
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager';
import type { Identity } from '@/lib/jmap/types';
import { authHooks } from '@/lib/plugin-hooks';
import { IS_LITE } from '@/lib/lite';
import {
LiteLoginError,
clearAllLiteSessions,
clearLiteRefreshToken,
clearLiteSlot,
liteRefreshTokens,
liteTokenLogin,
readLiteBasicSession,
saveLiteBasicSession,
saveLiteRefreshToken,
} from '@/lib/auth/lite-tokens';
interface AuthState {
isAuthenticated: boolean;
@@ -102,8 +114,9 @@ function isRateLimitError(error: unknown): error is RateLimitError {
* browser-side connect so no deployment loses the ability to log in.
*/
async function precheckBasicCredentials(serverUrl: string, username: string, password: string): Promise<boolean> {
// App-relative servers (the dev mock) never send a Basic challenge.
if (serverUrl.startsWith('/')) return false;
// App-relative servers (the dev mock) never send a Basic challenge, and the
// static Lite build has no backend to ask.
if (IS_LITE || serverUrl.startsWith('/')) return false;
try {
const res = await apiFetch('/api/auth/verify', {
method: 'POST',
@@ -203,6 +216,8 @@ async function syncStalwartAuthContext(
authHeader: string,
slot: number,
): Promise<void> {
// The passthrough context lives on the server; Lite has none.
if (IS_LITE) return;
try {
const response = await apiFetch('/api/auth/stalwart-context', {
method: 'POST',
@@ -218,6 +233,133 @@ async function syncStalwartAuthContext(
}
}
/*
* Per-slot credential endpoints.
*
* The regular build keeps refresh tokens and remembered passwords in encrypted
* per-slot cookies behind /api/auth/*. The static Lite build has no server and
* keeps them in web storage instead (lib/auth/lite-tokens.ts). The Lite
* variants below answer with the same Response shapes the routes do, so every
* caller's status handling (401 = rejected, 5xx/network = outage) is shared.
*/
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
}
function liteErrorStatus(err: LiteLoginError): number {
switch (err.code) {
case 'endpoint_missing':
return 404;
case 'totp_required':
case 'invalid_credentials':
case 'refresh_rejected':
return 401;
default:
return err.status && err.status >= 500 ? err.status : 502;
}
}
/** POST /api/auth/totp-token-exchange - password (+ optional TOTP) to bearer tokens for `slot`. */
async function exchangePasswordForTokens(params: {
serverUrl: string;
username: string;
password: string;
totp?: string;
slot: number;
redirectUri: string;
rememberMe: boolean;
}): Promise<Response> {
const { serverUrl, username, password, totp, slot, redirectUri, rememberMe } = params;
if (!IS_LITE) {
return apiFetch('/api/auth/totp-token-exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// server_id isn't passed - the route looks up the server entry by
// serverUrl, so per-server OAuth still applies for password+TOTP.
body: JSON.stringify({ serverUrl, username, password, totp, slot, redirectUri }),
});
}
try {
const tokens = await liteTokenLogin({ serverUrl, username, password, totp, redirectUri });
// "Remember me" keeps the refresh token across browser restarts
// (localStorage); otherwise it lives with the tab (sessionStorage).
if (tokens.refreshToken) {
saveLiteRefreshToken(slot, { serverUrl, username, refreshToken: tokens.refreshToken }, rememberMe);
} else {
clearLiteRefreshToken(slot);
}
return jsonResponse({ access_token: tokens.accessToken, expires_in: tokens.expiresIn, has_refresh_token: !!tokens.refreshToken });
} catch (err) {
if (err instanceof LiteLoginError) return jsonResponse({ error: err.code }, liteErrorStatus(err));
throw err;
}
}
/** POST /api/auth/session?slot=N - remember Basic credentials for `slot`. */
async function persistBasicSession(slot: number, serverUrl: string, username: string, password: string): Promise<void> {
if (IS_LITE) {
// Only reached when the server has no token login: keep the credentials
// with this tab so a reload does not sign the user out.
saveLiteBasicSession(slot, { serverUrl, username, password });
return;
}
try {
const res = await apiFetch(`/api/auth/session?slot=${slot}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverUrl, username, password, slot }),
});
if (!res.ok) debug.error('Failed to store session: server returned', res.status);
} catch (err) {
debug.error('Failed to store session:', err);
}
}
/** PUT /api/auth/token?slot=N - renew (or, unless `force`, reuse) the slot's access token. */
async function fetchSlotAccessToken(slot: number, opts: { force?: boolean } = {}): Promise<Response> {
if (!IS_LITE) {
return apiFetch(`/api/auth/token?slot=${slot}${opts.force ? '&force=true' : ''}`, { method: 'PUT' });
}
try {
const tokens = await liteRefreshTokens(slot);
return jsonResponse({ access_token: tokens.accessToken, expires_in: tokens.expiresIn });
} catch (err) {
if (err instanceof LiteLoginError) return jsonResponse({ error: err.code }, liteErrorStatus(err));
throw err; // network failure - callers treat it as an outage, not a rejection
}
}
/** PUT /api/auth/session[?slot=N] - the slot's remembered Basic credentials. */
async function fetchSlotSession(slot?: number): Promise<Response> {
if (!IS_LITE) {
return apiFetch(slot === undefined ? '/api/auth/session' : `/api/auth/session?slot=${slot}`, { method: 'PUT' });
}
const stored = readLiteBasicSession(slot ?? 0);
return stored ? jsonResponse(stored) : jsonResponse({ error: 'no_session' }, 401);
}
/** DELETE the slot's remembered credentials (and refresh token when `includeToken`). Fire and forget. */
function discardSlotCredentials(slot: number, includeToken: boolean, keepalive = false): void {
if (IS_LITE) {
clearLiteSlot(slot);
return;
}
const init: RequestInit = keepalive ? { method: 'DELETE', keepalive: true } : { method: 'DELETE' };
apiFetch(`/api/auth/session?slot=${slot}`, init).catch(() => {});
if (includeToken) {
apiFetch(`/api/auth/token?slot=${slot}`, init).catch(() => {});
}
}
function discardAllCredentials(): void {
if (IS_LITE) {
clearAllLiteSessions();
return;
}
apiFetch('/api/auth/session?all=true', { method: 'DELETE', keepalive: true }).catch(() => {});
apiFetch('/api/auth/token?all=true', { method: 'DELETE', keepalive: true }).catch(() => {});
}
function bindClientStatusHandlers(
client: IJMAPClient,
set: (state: Partial<AuthState>) => void,
@@ -706,7 +848,11 @@ export const useAuthStore = create<AuthState>()(
let oauthAccessToken: string | null = null;
let oauthExpiresIn = 0;
if (totp) {
// 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);
if (useTokenLogin) {
// Stalwart 0.16+ dropped the `password$totp` basic-auth convention;
// the MFA code must be exchanged for tokens via the structured login
// endpoint (handled server-side). Token auth also survives TOTP
@@ -719,12 +865,8 @@ export const useAuthStore = create<AuthState>()(
const redirectUri = typeof window !== 'undefined'
? `${window.location.origin}${getPathPrefix()}/${getLocaleFromPath()}/auth/callback`
: '';
const tokenRes = await apiFetch('/api/auth/totp-token-exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// server_id isn't passed - the route looks up the server entry by
// serverUrl, so per-server OAuth still applies for password+TOTP.
body: JSON.stringify({ serverUrl, username, password, totp, slot: cookieSlot, redirectUri }),
const tokenRes = await exchangePasswordForTokens({
serverUrl, username, password, totp, slot: cookieSlot, redirectUri, rememberMe: !!rememberMe,
});
if (tokenRes.ok) {
const { access_token, expires_in, has_refresh_token } = await tokenRes.json();
@@ -750,7 +892,7 @@ export const useAuthStore = create<AuthState>()(
await client.connect();
oauthAccessToken = bearerToken;
upgradedToOAuth = true;
} else {
} else if (totp) {
// Legacy fallback for pre-0.16 Stalwart, which accepts the TOTP
// appended to the password over basic auth.
if (await precheckBasicCredentials(serverUrl, username, `${password}$${totp}`)) {
@@ -761,6 +903,11 @@ export const useAuthStore = create<AuthState>()(
const { useTotpReauthStore } = await import('@/stores/totp-reauth-store');
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.
client = new JMAPClient(serverUrl, username, password);
await client.connect();
}
} else {
if (await precheckBasicCredentials(serverUrl, username, password)) {
@@ -788,13 +935,7 @@ export const useAuthStore = create<AuthState>()(
// 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)
? apiFetch(`/api/auth/session?slot=${cookieSlot}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverUrl, username, password, slot: cookieSlot }),
}).then((res) => {
if (!res.ok) debug.error('Failed to store session: server returned', res.status);
}).catch((err) => debug.error('Failed to store session:', err))
? persistBasicSession(cookieSlot, serverUrl, username, password)
: Promise.resolve();
const [rawIdentities] = await Promise.all([
@@ -1262,8 +1403,7 @@ export const useAuthStore = create<AuthState>()(
// the current token is unusable (rejected by JMAP, or due for
// scheduled renewal), so the server-side cache must be skipped.
// Session restore passes allowCached to reuse a still-valid token.
const force = options?.allowCached ? '' : '&force=true';
const res = await apiFetch(`/api/auth/token?slot=${slot}${force}`, { method: 'PUT' });
const res = await fetchSlotAccessToken(slot, { force: !options?.allowCached });
if (!res.ok) {
// Only a definitive 401 ends the session. Anything else (5xx
@@ -1426,10 +1566,7 @@ export const useAuthStore = create<AuthState>()(
}
// Background cookie cleanup for the removed account
apiFetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {});
if (wasOAuth) {
apiFetch(`/api/auth/token?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {});
}
discardSlotCredentials(slot, wasOAuth, true);
return;
}
@@ -1440,10 +1577,7 @@ export const useAuthStore = create<AuthState>()(
// Background cookie/token cleanup - keepalive ensures completion during navigation
if (!wasDemoMode) {
apiFetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {});
if (wasOAuth) {
apiFetch(`/api/auth/token?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {});
}
discardSlotCredentials(slot, wasOAuth, true);
}
// Redirect to login - this is synchronous and happens AFTER all state is cleared
@@ -1469,10 +1603,7 @@ export const useAuthStore = create<AuthState>()(
evictAccount(accountId);
accountStore.removeAccount(accountId);
apiFetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {});
if (wasOAuth) {
apiFetch(`/api/auth/token?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {});
}
discardSlotCredentials(slot, wasOAuth, true);
},
logoutAll: async () => {
@@ -1506,8 +1637,7 @@ export const useAuthStore = create<AuthState>()(
});
// Background cookie/token cleanup
apiFetch('/api/auth/session?all=true', { method: 'DELETE', keepalive: true }).catch(() => {});
apiFetch('/api/auth/token?all=true', { method: 'DELETE', keepalive: true }).catch(() => {});
discardAllCredentials();
redirectToLogin();
},
@@ -1548,7 +1678,7 @@ export const useAuthStore = create<AuthState>()(
// Client not connected - try to restore
try {
if (targetAccount.authMode === 'oauth') {
const res = await apiFetch(`/api/auth/token?slot=${targetAccount.cookieSlot}`, { method: 'PUT' });
const res = await fetchSlotAccessToken(targetAccount.cookieSlot);
if (res.ok) {
const { access_token, expires_in } = await res.json();
const refreshFn = get().refreshAccessToken;
@@ -1565,7 +1695,7 @@ export const useAuthStore = create<AuthState>()(
);
}
} else if (targetAccount.authMode === 'basic' && targetAccount.rememberMe) {
const res = await apiFetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`, { method: 'PUT' });
const res = await fetchSlotSession(targetAccount.cookieSlot);
if (res.ok) {
const { serverUrl, username, password } = await res.json();
targetClient = new JMAPClient(serverUrl, username, password);
@@ -1614,7 +1744,7 @@ export const useAuthStore = create<AuthState>()(
// Cannot restore - remove the stale account and redirect to login
evictAccount(accountId);
accountStore.removeAccount(accountId);
apiFetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`, { method: 'DELETE' }).catch(() => {});
discardSlotCredentials(targetAccount.cookieSlot, false);
// Restore the previous account if still available
if (state.activeAccountId && state.activeAccountId !== accountId) {
@@ -1661,8 +1791,7 @@ export const useAuthStore = create<AuthState>()(
debug.error(`switchAccount: slot ${targetAccount.cookieSlot} for ${accountId} resolved to [${connectedCandidates.join(", ")}] — forcing re-auth`);
clients.delete(accountId);
try { targetClient.disconnect(); } catch { /* noop */ }
apiFetch(`/api/auth/token?slot=${targetAccount.cookieSlot}`, { method: 'DELETE' }).catch(() => {});
apiFetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`, { method: 'DELETE' }).catch(() => {});
discardSlotCredentials(targetAccount.cookieSlot, true);
accountStore.updateAccount(accountId, { isConnected: false, hasError: true, errorMessage: 'session_mismatch' });
set({ isLoading: false, error: 'connection_failed', activeAccountId: state.activeAccountId });
replaceWindowLocation(getLocaleLoginPath());
@@ -1750,7 +1879,8 @@ export const useAuthStore = create<AuthState>()(
// or by another server-side hand-off), promote it into the account
// registry so the normal restoration path picks it up. Without this
// the cookies sit unused and the SPA bounces to the login screen.
if (accounts.length === 0) {
// (Not in Lite: nothing hands sessions over server-side.)
if (accounts.length === 0 && !IS_LITE) {
try {
const restore = await apiFetch('/api/auth/session', { method: 'PUT' });
if (restore.ok) {
@@ -1814,7 +1944,7 @@ export const useAuthStore = create<AuthState>()(
try {
if (account.authMode === 'oauth') {
const res = await apiFetch(`/api/auth/token?slot=${account.cookieSlot}`, { method: 'PUT' });
const res = await fetchSlotAccessToken(account.cookieSlot);
if (res.ok) {
const { access_token, expires_in } = await res.json();
const refreshFn = get().refreshAccessToken;
@@ -1836,7 +1966,7 @@ export const useAuthStore = create<AuthState>()(
throw new Error(`Token refresh failed: ${res.status}`);
}
} else {
const res = await apiFetch(`/api/auth/session?slot=${account.cookieSlot}`, { method: 'PUT' });
const res = await fetchSlotSession(account.cookieSlot);
if (res.ok) {
const { serverUrl, username, password } = await res.json();
const client = new JMAPClient(serverUrl, username, password);
@@ -1879,7 +2009,7 @@ export const useAuthStore = create<AuthState>()(
// again rather than seeing a stale error entry forever.
evictAccount(account.id);
accountStore.removeAccount(account.id);
apiFetch(`/api/auth/session?slot=${account.cookieSlot}`, { method: 'DELETE' }).catch(() => {});
discardSlotCredentials(account.cookieSlot, false);
}
};
@@ -2081,7 +2211,7 @@ export const useAuthStore = create<AuthState>()(
if (state.authMode === 'basic') {
set({ isLoading: true, isRateLimited: false, rateLimitUntil: null });
try {
const res = await apiFetch('/api/auth/session', { method: 'PUT' });
const res = await fetchSlotSession();
if (res.ok) {
const data = await res.json();
if (!data.serverUrl || !data.username || !data.password) {
+4
View File
@@ -16,6 +16,7 @@ import { usePolicyStore } from '@/stores/policy-store';
import { apiFetch } from '@/lib/browser-navigation';
import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types';
import type { Permission } from '@/lib/plugin-types';
import { IS_LITE } from '@/lib/lite';
let pluginInitializationPromise: Promise<void> | null = null;
// One-time guard so we attach the locale->sandbox subscription only once.
@@ -243,6 +244,9 @@ export const usePluginStore = create<PluginStoreState>()(
},
initializePlugins: async () => {
// Plugins need the sandbox routes and the plugin API; both are
// server-side, and policy pins pluginsEnabled off in the static build.
if (IS_LITE) return;
if (get().initialized) return;
if (pluginInitializationPromise) {
+10 -4
View File
@@ -2,6 +2,8 @@ import { create } from 'zustand';
import type { SettingsPolicy, FeatureGates, SettingRestriction, ThemePolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
import { IS_LITE, LITE_POLICY_PATH } from '@/lib/lite';
import { applyLitePolicy } from '@/lib/lite-config';
interface PolicyState {
policy: SettingsPolicy;
@@ -25,16 +27,20 @@ export const usePolicyStore = create<PolicyState>()((set, get) => ({
loaded: false,
fetchPolicy: async () => {
// Static Lite build: an optional policy.json next to index.html stands in
// for the admin server; missing means defaults, and the gates that need
// the server stay pinned off either way (lib/lite-config.ts).
const fallback = IS_LITE ? { policy: applyLitePolicy({}) } : {};
try {
const res = await apiFetch('/api/admin/policy');
const res = await apiFetch(IS_LITE ? LITE_POLICY_PATH : '/api/admin/policy', IS_LITE ? { cache: 'no-store' } : undefined);
if (res.ok) {
const data = await res.json();
set({ policy: data, loaded: true });
set({ policy: IS_LITE ? applyLitePolicy(data) : data, loaded: true });
} else {
set({ loaded: true });
set({ ...fallback, loaded: true });
}
} catch {
set({ loaded: true });
set({ ...fallback, loaded: true });
}
},
+3
View File
@@ -15,6 +15,7 @@ import { usePolicyStore } from '@/stores/policy-store';
import { apiFetch } from '@/lib/browser-navigation';
import { themeHooks } from '@/lib/plugin-hooks';
import { syncThemeColorMeta } from '@/lib/theme-color-meta';
import { IS_LITE } from '@/lib/lite';
type Theme = 'light' | 'dark' | 'system';
@@ -330,6 +331,8 @@ export const useThemeStore = create<ThemeState>()(
},
syncServerThemes: async () => {
// Server-managed themes come from the admin dashboard; none in Lite.
if (IS_LITE) return;
if (themeSyncPromise) {
await themeSyncPromise;
return;
+3 -1
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand';
import { apiFetch } from '@/lib/browser-navigation';
import { IS_LITE } from '@/lib/lite';
import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
const POLL_INTERVAL_MS = 15 * 60 * 1000;
@@ -51,7 +52,8 @@ export const useUpdateStore = create<UpdateState>()((set, get) => ({
},
startPolling: () => {
if (pollTimer) return;
// The update check is served by the Node.js backend; the static build has none.
if (IS_LITE || pollTimer) return;
void get().fetchStatus();
pollTimer = setInterval(() => {
void get().fetchStatus();
+2 -1
View File
@@ -13,7 +13,8 @@ export default defineConfig({
// repos/** holds sibling checkouts with their own runners. node_modules
// must be globbed at any depth or the nested repos/*/node_modules trees
// get collected too.
exclude: ['e2e/**', 'integration/**', 'examples/**', 'repos/**', '**/node_modules/**', '.next/**'],
// scripts/lite/smoke/** is the Playwright smoke for the static Lite export.
exclude: ['e2e/**', 'integration/**', 'examples/**', 'repos/**', '**/node_modules/**', '.next/**', 'out/**', 'scripts/lite/smoke/**'],
},
resolve: {
alias: {