feat: add Bulwark Lite build for Stalwart Applications

This commit is contained in:
Linus Rath
2026-09-19 00:53:53 +02:00
parent 2d4a562432
commit fdfcb1711e
30 changed files with 2267 additions and 84 deletions
+72 -10
View File
@@ -2,9 +2,16 @@ 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
# attaches the zips to releases, and lets maintainers build a custom variant
# (fixed server URL, sub-path mount, locale subset, demo) on demand.
#
# Two targets are built side by side:
# - static: bulwark-lite-<version>.zip for any static web host;
# - stalwart: bulwark-lite-stalwart.zip, a Stalwart `Application` bundle. Its
# release asset name carries no version, so
# releases/latest/download/bulwark-lite-stalwart.zip is a stable
# resourceUrl that follows new releases.
#
# 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).
@@ -17,14 +24,14 @@ on:
workflow_dispatch:
inputs:
jmap_server_url:
description: "Default JMAP server URL baked into config.json (editable after download)"
description: "Default JMAP server URL baked into config.json (static: editable after download; stalwart: leave empty for the serving Stalwart)"
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)"
description: "Sub-path mount for the static target, e.g. /webmail (baked into asset URLs; the stalwart target ignores it)"
required: false
locales:
description: "Comma-separated locale subset, e.g. en,de,fr (default: all)"
@@ -41,6 +48,10 @@ jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
target: [static, stalwart]
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -58,7 +69,9 @@ jobs:
env:
CI: "true"
GIT_COMMIT: ${{ github.sha }}
NEXT_PUBLIC_BASE_PATH: ${{ inputs.base_path }}
LITE_TARGET: ${{ matrix.target }}
# The Stalwart bundle learns its mount prefix at runtime.
NEXT_PUBLIC_BASE_PATH: ${{ matrix.target == 'static' && inputs.base_path || '' }}
LITE_JMAP_SERVER_URL: ${{ inputs.jmap_server_url }}
LITE_APP_NAME: ${{ inputs.app_name }}
LITE_LOCALES: ${{ inputs.locales }}
@@ -69,13 +82,19 @@ jobs:
env:
REF_NAME: ${{ github.ref_name }}
run: |
if [ "${{ github.event_name }}" = "release" ]; then
VERSION="${REF_NAME#v}"
if [ "${{ matrix.target }}" = "stalwart" ]; then
# postbuild already packed it (entries at the archive root, no
# wrapping folder); the name stays version-less on purpose.
ZIP="bulwark-lite-stalwart.zip"
else
VERSION="$(cat VERSION)-${GITHUB_SHA::7}"
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}" .)
fi
ZIP="bulwark-lite-${VERSION}.zip"
(cd out && zip -qr "../${ZIP}" .)
echo "ZIP=${ZIP}" >> "$GITHUB_ENV"
ls -la "${ZIP}"
@@ -90,7 +109,7 @@ jobs:
if: github.event_name != 'release'
uses: actions/upload-artifact@v4
with:
name: bulwark-lite
name: bulwark-lite-${{ matrix.target }}
path: ${{ env.ZIP }}
retention-days: 7
@@ -137,3 +156,46 @@ jobs:
name: lite-smoke-report
path: playwright-report
retention-days: 7
smoke-stalwart:
# The Stalwart Application bundle, served by scripts/lite/serve.mjs
# --stalwart (an emulation of how Stalwart serves Applications) from the
# zip itself, under two prefixes.
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
- name: Build demo bundle
env:
CI: "true"
GIT_COMMIT: ${{ github.sha }}
LITE_TARGET: stalwart
LITE_DEMO_MODE: "true"
LITE_LOCALES: "en,de"
run: npm run build:lite
- name: Run smoke test
run: npm run test:lite-smoke:stalwart
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: lite-smoke-stalwart-report
path: playwright-report
retention-days: 7
+1
View File
@@ -158,5 +158,6 @@
- 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
- Bulwark Lite for Stalwart: `bulwark-lite-stalwart.zip` installs as a Stalwart `Application` (served by Stalwart itself under any prefix, same origin, zero configuration)
- `NEXT_PUBLIC_BASE_PATH` mounts the app at a subpath behind a reverse proxy
- Demo mode runs on fixture data, no mail server required
+39 -1
View File
@@ -95,7 +95,7 @@ npm run build && npm start
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:
Every release ships `bulwark-lite-<version>.zip` (and `bulwark-lite-stalwart.zip`, see [Install on Stalwart](#install-on-stalwart)), 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
@@ -108,6 +108,44 @@ npm run lite:serve # http://localhost:4173/
Lite keeps everything that runs in the browser (multi-account, password and TOTP login, "remember me" via Stalwart refresh tokens, themes, deep links, demo mode). Server-backed features are off: the admin console and setup wizard, plugins and sidebar apps, settings sync, OAuth/SSO, the account security tab, ICS URL subscriptions and CalDAV discovery, sender favicons, office editing, web push, the update banner, the "Default apps" protocol-handler registration. Password logins go through Stalwart's token endpoints in the browser (`/api/auth` + `/auth/token`, so those need CORS as well); without "remember me" the refresh token lives in sessionStorage and the session ends with the tab. `scripts/lite/verify.mjs` fails the build if a client chunk references a server endpoint that is not documented in `scripts/lite/lib.mjs`, and `npm run test:lite-smoke` drives a demo export with Playwright.
#### Install on Stalwart
Stalwart 0.16 can host the webmail itself as an `Application`: it downloads a zip, serves it under a path of its own HTTP listener, and the webmail talks to the same origin's JMAP, `/api/auth` and `/auth/token`, so there is no server URL to enter and no CORS to set up. Every release ships `bulwark-lite-stalwart.zip` for this (built with `npm run build:lite -- --target=stalwart`). An administrator creates the Application and then asks Stalwart to fetch it. Creating it alone mounts nothing:
```bash
stalwart-cli create Application \
--field description='Bulwark Webmail' \
--field resourceUrl='https://github.com/bulwarkmail/webmail/releases/latest/download/bulwark-lite-stalwart.zip' \
--field 'urlPrefix={"/webmail":true}'
stalwart-cli create Action/UpdateApps
```
The same in the web admin: **Settings > Web Applications**, create the entry, then run the "update applications" action (or restart Stalwart). Over JMAP, as an administrator:
```json
{
"using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"],
"methodCalls": [
["x:Application/set", { "create": { "webmail": {
"description": "Bulwark Webmail",
"resourceUrl": "https://github.com/bulwarkmail/webmail/releases/latest/download/bulwark-lite-stalwart.zip",
"urlPrefix": { "/webmail": true }
} } }, "0"],
["x:Action/set", { "create": { "u": { "@type": "UpdateApps" } } }, "1"]
]
}
```
Then open `https://<stalwart host>/webmail/`. Notes:
- **Permissions**: `sysApplicationCreate` (plus `sysApplicationGet`/`Query`/`Update`/`Destroy` to manage it) and `actionUpdateApps`.
- **Prefix**: `urlPrefix` matches the first path segment only, and one bundle may be mounted under several prefixes; it reads its prefix at runtime. Stalwart routes these segments itself, so they cannot host an Application: `jmap`, `dav`, `.well-known`, `auth`, `api`, `scim`, `mail`, `calendar`, `autodiscover`, `login`, `device`, `metrics`, `healthz`, `logo`, `form`, `robots.txt`, and the web admin's `admin` and `account`.
- **Updates**: Stalwart caches the zip for `autoUpdateFrequency` (default 90 days) and downloads `resourceUrl` again at the next restart or `UpdateApps` after that. `UpdateApps` always re-downloads, so it is also how to update right away. Open tabs switch to the new build on their next navigation (one full page load); files the app fetches without a content hash carry the build id, so Stalwart's year-long cache headers never pin an old build.
- **Pinning**: use `https://github.com/bulwarkmail/webmail/releases/download/v<version>/bulwark-lite-stalwart.zip` as `resourceUrl`.
- **OAuth client**: login uses the Application's `oauthClientId`, or `bulwark-webmail` when it is empty. If client registration is required, register that id with the redirect URI `https://<stalwart host>/<prefix>/` (one per prefix).
- **Minimum version**: Stalwart 0.16.0 (0.16.19 for `oauthClientId`).
- **Differences from the static-host zip**: one entry document boots every route (Stalwart has no rewrite rules), no `_redirects`/`_headers`/nginx/Caddy files and no `404.html`, `config.json` defaults to the serving Stalwart and hides the server field, and `manifest.json` replaces `manifest.webmanifest`. The bundle is read-only; to rename or rebrand it, run the **Build Static Lite** workflow (or the build above with `LITE_APP_NAME` etc.), host the resulting `bulwark-lite-stalwart.zip` yourself and point `resourceUrl` at it.
### Development
```bash
+2 -2
View File
@@ -15,7 +15,7 @@ import {
} from "@/lib/admin/domain-branding";
import { withBasePath } from "@/lib/browser-navigation";
import { locales, defaultLocale } from "@/i18n/routing";
import { IS_LITE } from "@/lib/lite";
import { IS_LITE, LITE_MOUNT_GLOBAL } from "@/lib/lite";
import "../globals.css";
// This layout renders <html> and sits ABOVE the [locale] segment, so
@@ -239,7 +239,7 @@ function liteLocaleBootstrap(): string {
try {
var locales = ${JSON.stringify([...locales])};
var rtl = ${JSON.stringify(rtl)};
var base = ${JSON.stringify(basePath)};
var base = ${JSON.stringify(basePath)} || (typeof window.${LITE_MOUNT_GLOBAL} === 'string' ? window.${LITE_MOUNT_GLOBAL} : '');
var path = location.pathname;
if (base && path.indexOf(base) === 0) path = path.slice(base.length);
var seg = path.split('/').filter(Boolean)[0];
+1 -1
View File
@@ -3,7 +3,7 @@
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { BookUser, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail, Star } from "lucide-react";
import { useRouter } from "next/navigation";
import { useRouter } from "@/i18n/navigation";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
+3 -2
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Loader2, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { liteMountedAssetUrl } from "@/lib/lite";
// Real inline PDF preview for mobile browsers. Android Chrome / iOS WebKit have
// no native inline PDF viewer, so the desktop <iframe src=blob:> approach
@@ -50,10 +51,10 @@ export function PdfMobileViewer({ url, blob }: { url: string; blob: Blob }) {
(async () => {
try {
const pdfjs = await import("pdfjs-dist");
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
pdfjs.GlobalWorkerOptions.workerSrc = liteMountedAssetUrl(new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
).toString());
// The Uint8Array is created fresh from the blob, so pdf.js taking
// ownership of (and transferring) the buffer is fine.
+9 -5
View File
@@ -4,8 +4,8 @@ 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';
import { IS_LITE, IS_LITE_STALWART, LITE_CONFIG_PATH, withLiteBuildId } from '@/lib/lite';
import { applyLiteConfig, liteStalwartDefaults } from '@/lib/lite-config';
export interface ConfigData {
appName: string;
@@ -58,13 +58,17 @@ let configPromise: Promise<ConfigData> | null = null;
* (custom endpoint allowed) so an unedited download still lets people sign in.
*/
async function fetchLiteConfig(): Promise<ConfigData> {
// On Stalwart the bundle is read-only and served from the JMAP origin:
// config.json ships inside the zip (build-id URL, immutable cache) and an
// empty server URL means "this origin".
const defaults = IS_LITE_STALWART ? liteStalwartDefaults() : undefined;
try {
const res = await apiFetch(LITE_CONFIG_PATH, { cache: 'no-store' });
const res = await apiFetch(withLiteBuildId(LITE_CONFIG_PATH), IS_LITE_STALWART ? undefined : { cache: 'no-store' });
if (!res.ok) throw new Error(`config.json answered ${res.status}`);
return applyLiteConfig(await res.json());
return applyLiteConfig(await res.json(), defaults);
} catch (err) {
console.warn('[lite] config.json missing or invalid, using defaults:', err);
return applyLiteConfig({});
return applyLiteConfig({}, defaults);
}
}
+58
View File
@@ -0,0 +1,58 @@
'use client';
import { forwardRef, useMemo, useSyncExternalStore, type ComponentProps } from 'react';
import NextLink from 'next/link';
import { useRouter as useNextRouter, usePathname as useNextPathname } from 'next/navigation';
import { useLocale } from 'next-intl';
import { getPathPrefix } from '@/lib/browser-navigation';
import { mountedHref, stripMountedPathname, type MountedHref } from '@/lib/lite-mounted-href';
/**
* `@/i18n/navigation` for a Lite bundle whose mount prefix is only known at
* runtime (the Stalwart build, see lib/lite-mounted-href.ts). Same call
* signatures as next-intl's helpers; only the hrefs differ.
*/
type NavigateOptions = { locale?: string; scroll?: boolean };
const noopSubscribe = () => () => {};
/**
* The mount prefix, hydration-safe: '' while prerendering and during the
* hydration pass, the real prefix right after.
*/
function useMountPrefix(): string {
return useSyncExternalStore(noopSubscribe, getPathPrefix, () => '');
}
export function useMountedRouter() {
const router = useNextRouter();
const locale = useLocale();
return useMemo(() => {
const to = (href: MountedHref, options?: NavigateOptions) =>
mountedHref(href, options?.locale ?? locale, getPathPrefix());
return {
...router,
push: (href: MountedHref, options?: NavigateOptions) => router.push(to(href, options), { scroll: options?.scroll }),
replace: (href: MountedHref, options?: NavigateOptions) => router.replace(to(href, options), { scroll: options?.scroll }),
prefetch: (href: MountedHref, options?: NavigateOptions) => router.prefetch(to(href, options)),
};
}, [router, locale]);
}
export function useMountedPathname(): string {
const pathname = useNextPathname();
const prefix = useMountPrefix();
return stripMountedPathname(pathname ?? '/', prefix);
}
type MountedLinkProps = Omit<ComponentProps<typeof NextLink>, 'href'> & { href: MountedHref; locale?: string };
export const MountedLink = forwardRef<HTMLAnchorElement, MountedLinkProps>(function MountedLink(
{ href, locale, ...rest },
ref,
) {
const current = useLocale();
const prefix = useMountPrefix();
return <NextLink ref={ref} href={mountedHref(href, locale ?? current, prefix)} {...rest} />;
});
+12 -1
View File
@@ -1,4 +1,15 @@
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
import { IS_LITE_STALWART } from '@/lib/lite';
import { MountedLink, useMountedPathname, useMountedRouter } from './mounted-navigation';
export const { Link, redirect, usePathname, useRouter } = createNavigation(routing);
const navigation = createNavigation(routing);
// The Stalwart Lite bundle is mounted wherever the admin's `urlPrefix` says,
// which no build-time basePath can know: its navigation adds and strips the
// runtime prefix (i18n/mounted-navigation.tsx). Every other build uses
// next-intl's helpers unchanged.
export const Link = (IS_LITE_STALWART ? MountedLink : navigation.Link) as typeof navigation.Link;
export const usePathname = (IS_LITE_STALWART ? useMountedPathname : navigation.usePathname) as typeof navigation.usePathname;
export const useRouter = (IS_LITE_STALWART ? useMountedRouter : navigation.useRouter) as typeof navigation.useRouter;
export const { redirect } = navigation;
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { applyLiteConfig, applyLitePolicy, LITE_FORCED_FLAGS } from '@/lib/lite-config';
import { applyLiteConfig, applyLitePolicy, liteStalwartDefaults, LITE_FORCED_FLAGS } from '@/lib/lite-config';
import { DEFAULT_POLICY } from '@/lib/admin/types';
describe('applyLiteConfig (config.json for the static build)', () => {
@@ -114,3 +114,22 @@ describe('applyLitePolicy (policy.json for the static build)', () => {
expect(applyLitePolicy('nope').features.pluginsEnabled).toBe(false);
});
});
describe('applyLiteConfig for the Stalwart bundle (same-origin defaults)', () => {
it('uses the page origin when config.json names no server, and hides the server field', () => {
const defaults = liteStalwartDefaults();
expect(defaults.jmapServerUrl).toBe(window.location.origin);
const config = applyLiteConfig({}, defaults);
expect(config.jmapServerUrl).toBe(window.location.origin);
expect(config.allowCustomJmapEndpoint).toBe(false);
expect(config.rememberMeEnabled).toBe(true);
expect(config.demoMode).toBe(false);
expect(applyLiteConfig({ jmapServerUrl: '' }, { jmapServerUrl: 'https://mail.example.com/' }).jmapServerUrl).toBe('https://mail.example.com');
});
it('still lets a custom build point elsewhere or show the field explicitly', () => {
const defaults = { jmapServerUrl: 'https://mail.example.com' };
expect(applyLiteConfig({ jmapServerUrl: 'https://other.example.com' }, defaults).jmapServerUrl).toBe('https://other.example.com');
expect(applyLiteConfig({ allowCustomJmapEndpoint: true }, defaults).allowCustomJmapEndpoint).toBe(true);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { hrefToString, mountedHref, stripMountedPathname } from '@/lib/lite-mounted-href';
describe('hrefToString', () => {
it('passes strings through and serialises next-intl href objects', () => {
expect(hrefToString('/settings?tab=x')).toBe('/settings?tab=x');
expect(hrefToString({ pathname: '/mail', query: { a: 1, b: ['x', 'y'], skip: undefined }, hash: 'top' })).toBe('/mail?a=1&b=x&b=y#top');
expect(hrefToString({ pathname: '/calendar' })).toBe('/calendar');
});
});
describe('mountedHref', () => {
it('adds the runtime mount and the locale', () => {
expect(mountedHref('/settings', 'en', '/webmail')).toBe('/webmail/en/settings');
expect(mountedHref('/', 'de', '/webmail')).toBe('/webmail/de/');
expect(mountedHref('/?mode=x', 'de', '/webmail')).toBe('/webmail/de/?mode=x');
expect(mountedHref('/login?mode=add-account', 'en', '/bulwark')).toBe('/bulwark/en/login?mode=add-account');
expect(mountedHref({ pathname: '/contacts', query: { id: 'c1' } }, 'en', '/webmail')).toBe('/webmail/en/contacts?id=c1');
});
it('behaves like next-intl at the site root', () => {
expect(mountedHref('/settings', 'en', '')).toBe('/en/settings');
expect(mountedHref('/', 'en', '')).toBe('/en/');
});
it('is idempotent and leaves external, relative and protocol-relative hrefs alone', () => {
expect(mountedHref('/webmail/en/mail', 'en', '/webmail')).toBe('/webmail/en/mail');
expect(mountedHref('/webmail', 'en', '/webmail')).toBe('/webmail');
expect(mountedHref('/en/mail', 'en', '')).toBe('/en/mail');
expect(mountedHref('https://example.com/x', 'en', '/webmail')).toBe('https://example.com/x');
expect(mountedHref('//cdn.example.com/x', 'en', '/webmail')).toBe('//cdn.example.com/x');
expect(mountedHref('thread/1', 'en', '/webmail')).toBe('thread/1');
expect(mountedHref('#anchor', 'en', '/webmail')).toBe('#anchor');
});
it('does not mistake a path that merely starts with the mount text for a mounted one', () => {
expect(mountedHref('/webmailer', 'en', '/webmail')).toBe('/webmail/en/webmailer');
});
});
describe('stripMountedPathname', () => {
it('drops the mount, the locale and a trailing slash', () => {
expect(stripMountedPathname('/webmail/en/settings/', '/webmail')).toBe('/settings');
expect(stripMountedPathname('/webmail/en/calendar/week/2026-09-17', '/webmail')).toBe('/calendar/week/2026-09-17');
expect(stripMountedPathname('/webmail/en/', '/webmail')).toBe('/');
expect(stripMountedPathname('/webmail/zh-TW', '/webmail')).toBe('/');
expect(stripMountedPathname('/webmail', '/webmail')).toBe('/');
});
it('matches next-intl at the site root and during prerendering (no mount)', () => {
expect(stripMountedPathname('/en/mail', '')).toBe('/mail');
expect(stripMountedPathname('/', '')).toBe('/');
expect(stripMountedPathname('', '')).toBe('/');
});
it('keeps paths without a locale as they are', () => {
expect(stripMountedPathname('/webmail/nope/x', '/webmail')).toBe('/nope/x');
});
});
+429
View File
@@ -5,7 +5,14 @@ import { join } from 'node:path';
import {
LITE_API_STRING_ALLOWLIST,
LITE_PENDING_PATH_KEY,
LITE_TARGETS,
LOCALE_STORAGE_KEY,
STALWART_BASE_HREF_LITERAL,
STALWART_CONTENT_TYPES,
STALWART_DEFAULT_PREFIX,
STALWART_ENTRY_MARKER,
STALWART_OAUTH_META_LITERAL,
STALWART_ZIP_NAME,
buildCaddyExample,
buildHeaders,
buildLiteConfig,
@@ -16,10 +23,35 @@ import {
buildReadme,
buildRedirects,
buildRootRedirect,
buildStalwartEntry,
buildStalwartManifest,
buildStalwartReadme,
buildZip,
collectApiStrings,
collectZipEntries,
discoverBuiltLocales,
discoverShellDirs,
emulateStalwartRequest,
findSegmentPrefetchDirs,
flightTextRowsWithNeedles,
inlineJson,
installStalwartFetchShim,
isReservedStalwartSegment,
listZipEntries,
makeBuildId,
normalizeBasePath,
parseBool,
parseLiteTarget,
readZipEntry,
resolveStalwartBoot,
rewriteFlightForMount,
rewriteShellForMount,
stalwartContentType,
stalwartEntryProblems,
stalwartOauthMeta,
stalwartPrefixProblems,
stalwartRewriteIndex,
stalwartZipEntryProblems,
unexpectedApiStrings,
} from '../../scripts/lite/lib.mjs';
import { collectTestPaths, planRemovals, runPrepare } from '../../scripts/lite/prepare.mjs';
@@ -265,3 +297,400 @@ describe('prepare / postbuild / verify against a fake checkout', () => {
}
});
});
// ---------------------------------------------------------------------------
// Stalwart Application target
// ---------------------------------------------------------------------------
function entriesOf(files: Record<string, string>): Map<string, Buffer> {
return new Map(Object.entries(files).map(([name, text]) => [name, Buffer.from(text)]));
}
describe('Stalwart target: routing rules and constants', () => {
it('parses the build target from argv and env', () => {
expect(parseLiteTarget([], {})).toBe('static');
expect(parseLiteTarget(['--target=stalwart'], {})).toBe('stalwart');
expect(parseLiteTarget(['--target', 'Stalwart'], {})).toBe('stalwart');
expect(parseLiteTarget([], { LITE_TARGET: 'stalwart' })).toBe('stalwart');
expect(parseLiteTarget(['--target=static'], { LITE_TARGET: 'stalwart' })).toBe('static');
expect(() => parseLiteTarget(['--target=netlify'], {})).toThrow(/unknown Lite target/);
expect(LITE_TARGETS).toEqual(['static', 'stalwart']);
});
it('knows which first segments Stalwart keeps for itself', () => {
for (const s of ['jmap', 'api', 'auth', 'dav', '.well-known', 'login', 'device', 'metrics', 'admin', 'account', 'mail', 'calendar', 'AutoDiscover']) {
expect(isReservedStalwartSegment(s), s).toBe(true);
}
for (const s of ['webmail', 'bulwark', 'en', '_next', 'index.html']) expect(isReservedStalwartSegment(s), s).toBe(false);
expect(stalwartPrefixProblems(STALWART_DEFAULT_PREFIX)).toEqual([]);
expect(stalwartPrefixProblems('/bulwark/')).toEqual([]);
expect(stalwartPrefixProblems('/mail')[0]).toMatch(/routed by Stalwart/);
expect(stalwartPrefixProblems('/apps/webmail')[0]).toMatch(/first segment only/);
expect(stalwartPrefixProblems('/')[0]).toMatch(/empty/);
expect(stalwartPrefixProblems('/we b')[0]).toMatch(/characters/);
});
it('maps extensions exactly like application.rs', () => {
expect(stalwartContentType('index.html')).toBe('text/html');
expect(stalwartContentType('_next/static/chunks/a.js')).toBe('application/javascript');
expect(stalwartContentType('config.json')).toBe('application/json');
expect(stalwartContentType('icon.png')).toBe('image/png');
for (const name of ['en/mail/index.txt', 'a.woff2', 'manifest.webmanifest', 'x.mjs', 'x.mp3', 'x.webp', 'LICENSE']) {
expect(stalwartContentType(name), name).toBe('application/octet-stream');
}
expect(Object.keys(STALWART_CONTENT_TYPES).sort()).toEqual(['css', 'html', 'ico', 'js', 'json', 'png', 'svg', 'wasm']);
});
it('makes build ids that differ per commit and build time', () => {
const id = makeBuildId({ version: '1.10.0', commit: 'abcdef1234', now: 1_700_000_000_000 });
expect(id).toBe(`1.10.0-abcdef1-${(1_700_000_000).toString(36)}`);
expect(makeBuildId({ version: '1.10.0', commit: 'abcdef1234', now: 1_700_000_001_000 })).not.toBe(id);
expect(makeBuildId({ version: '1 0"<', commit: '' })).toMatch(/^10-local-/);
});
});
describe('Stalwart target: entry document logic (runs in the browser)', () => {
const locales = ['de', 'en', 'zh-TW'];
const shells = ['', 'auth/callback', 'calendar', 'contacts', 'files', 'login', 'mail', 'plugins/oauth/callback', 'pro', 'settings'];
it('boots the shell that owns the path and keeps the address', () => {
expect(resolveStalwartBoot('/webmail/en/mail/thread/abc', '/webmail', locales, shells, 'de')).toEqual({ locale: 'en', shell: 'en/mail/index.html', canonical: null });
expect(resolveStalwartBoot('/webmail/de/calendar/week/2026-09-17', '/webmail', locales, shells, 'en')).toEqual({ locale: 'de', shell: 'de/calendar/index.html', canonical: null });
expect(resolveStalwartBoot('/webmail/zh-TW/settings/', '/webmail', locales, shells, 'en')?.shell).toBe('zh-TW/settings/index.html');
expect(resolveStalwartBoot('/webmail/en/auth/callback', '/webmail', locales, shells, 'en')?.shell).toBe('en/auth/callback/index.html');
expect(resolveStalwartBoot('/webmail/en/', '/webmail', locales, shells, 'de')).toEqual({ locale: 'en', shell: 'en/index.html', canonical: null });
expect(resolveStalwartBoot('/webmail/en', '/webmail', locales, shells, 'de')?.shell).toBe('en/index.html');
});
it('sends locale-less and unknown paths to the preferred (or linked) locale root', () => {
expect(resolveStalwartBoot('/webmail/', '/webmail', locales, shells, 'de')).toEqual({ locale: 'de', shell: 'de/index.html', canonical: '/webmail/de/' });
expect(resolveStalwartBoot('/webmail', '/webmail', locales, shells, 'en')?.canonical).toBe('/webmail/en/');
expect(resolveStalwartBoot('/webmail/nope/x', '/webmail', locales, shells, 'en')).toEqual({ locale: 'en', shell: 'en/index.html', canonical: '/webmail/en/' });
expect(resolveStalwartBoot('/webmail/en/nope', '/webmail', locales, shells, 'de')).toEqual({ locale: 'en', shell: 'en/index.html', canonical: '/webmail/en/' });
// `auth` alone is not the auth/callback shell.
expect(resolveStalwartBoot('/webmail/en/auth', '/webmail', locales, shells, 'de')?.canonical).toBe('/webmail/en/');
});
it('works at the site root and refuses paths outside the mount', () => {
expect(resolveStalwartBoot('/en/mail', '', locales, shells, 'de')?.shell).toBe('en/mail/index.html');
expect(resolveStalwartBoot('/', '', locales, shells, 'de')?.canonical).toBe('/de/');
expect(resolveStalwartBoot('/other/en/mail', '/webmail', locales, shells, 'en')).toBeNull();
expect(resolveStalwartBoot('/webmailer/en/mail', '/webmail', locales, shells, 'en')).toBeNull();
});
it('points a prerendered shell at the mount, in markup and in the inline RSC payload', () => {
const shell = '<!DOCTYPE html><html><head><link rel="stylesheet" href="/_next/static/chunks/a.css"/>'
+ '<link rel="manifest" href="/manifest.webmanifest"/><link rel="icon" href="/branding/Bulwark_Favicon.svg"/>'
+ '<script src="/_next/static/chunks/b.js" async=""></script></head><body>'
+ '<script>self.__next_f.push([1,"2:I[1,[\\"/_next/static/chunks/c.js\\"],\\"default\\"]\\n"])</script></body></html>';
const out = rewriteShellForMount(shell, '/webmail', 'b 1', '<script>BOOT</script>');
expect(out).toContain('<head><script>BOOT</script><link');
expect(out).toContain('href="/webmail/_next/static/chunks/a.css"');
expect(out).toContain('src="/webmail/_next/static/chunks/b.js"');
expect(out).toContain('\\"/webmail/_next/static/chunks/c.js\\"');
expect(out).toContain('href="/webmail/manifest.json?v=b%201"');
expect(out).toContain('href="/webmail/branding/Bulwark_Favicon.svg"');
expect(out).not.toMatch(/"\/_next\//);
// At the site root nothing moves, the boot script still goes in.
expect(rewriteShellForMount('<head></head>', '', 'b', 'X')).toBe('<head>X</head>');
});
it('rewrites RSC payload rows but copies length-prefixed text rows byte-exactly', () => {
const text = 'ü€'; // 2 + 3 UTF-8 bytes
const tRow = `9:T${Buffer.byteLength(`${text}"/_next/x`).toString(16)},${text}"/_next/x`;
const payload = `0:["$","link",null,{"href":"/_next/static/chunks/a.css"}]\n${tRow}1:I[5,["/_next/static/chunks/b.js"],"x"]\n2:{"m":"/manifest.webmanifest","i":"/branding/i.svg"}\n`;
const out = rewriteFlightForMount(payload, '/webmail', 'id');
expect(out).toContain('{"href":"/webmail/_next/static/chunks/a.css"}');
expect(out).toContain(`${tRow}1:I[5,["/webmail/_next/static/chunks/b.js"],"x"]`);
expect(out).toContain('"m":"/webmail/manifest.json?v=id","i":"/webmail/branding/i.svg"');
// A surrogate pair counts as four bytes.
const emoji = '😀ab';
const row = `3:T${Buffer.byteLength(emoji).toString(16)},${emoji}`;
expect(rewriteFlightForMount(`${row}4:"/_next/y"\n`, '/w', 'b')).toBe(`${row}4:"/w/_next/y"\n`);
// Malformed text rows cannot loop forever.
expect(rewriteFlightForMount('5:Tzz,"/_next/"', '/w', 'b')).toBe('5:Tzz,"/_next/"');
expect(rewriteFlightForMount('', '/w', 'b')).toBe('');
});
it('finds text rows that would escape the rewrite', () => {
expect(flightTextRowsWithNeedles('0:"x"\n1:T3,abc2:"/_next/ok"\n')).toEqual([]);
const body = 'see "/_next/static/x"';
expect(flightTextRowsWithNeedles(`a:T${Buffer.byteLength(body).toString(16)},${body}`)).toEqual(['a']);
});
it('relabels and rewrites RSC payloads fetched below the mount, and nothing else', async () => {
const origin = 'https://mail.example.com';
const responses: Record<string, [string, string]> = {
[`${origin}/webmail/en/mail/index.txt`]: ['application/octet-stream', '0:I[1,["/_next/static/chunks/a.js"],"x"]\n'],
[`${origin}/webmail/en/cal/index.txt`]: ['text/plain', '0:"/_next/b.js"\n'],
[`${origin}/webmail/en/x.txt`]: ['text/html', '<html>entry</html>'],
[`${origin}/other/y.txt`]: ['application/octet-stream', '"/_next/z"'],
[`${origin}/webmail/font.woff2`]: ['application/octet-stream', 'bin'],
};
const win = {
location: { origin },
fetch: async (url: string) => {
const [type, body] = responses[url];
const res = new Response(body, { status: 200, headers: { 'content-type': type } });
Object.defineProperty(res, 'url', { value: url });
return res;
},
} as unknown as Window & { fetch: (u: string) => Promise<Response> };
installStalwartFetchShim(win, '/webmail', 'b', rewriteFlightForMount);
installStalwartFetchShim(win, '/webmail', 'b', rewriteFlightForMount); // idempotent
const rsc = await win.fetch(`${origin}/webmail/en/mail/index.txt`);
expect(rsc.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(rsc.url).toBe(`${origin}/webmail/en/mail/index.txt`);
expect(await rsc.text()).toBe('0:I[1,["/webmail/_next/static/chunks/a.js"],"x"]\n');
expect(await (await win.fetch(`${origin}/webmail/en/cal/index.txt`)).text()).toBe('0:"/webmail/_next/b.js"\n');
const html = await win.fetch(`${origin}/webmail/en/x.txt`);
expect(html.headers.get('content-type')).toBe('text/html');
expect(await (await win.fetch(`${origin}/other/y.txt`)).text()).toBe('"/_next/z"');
expect((await win.fetch(`${origin}/webmail/font.woff2`)).headers.get('content-type')).toBe('application/octet-stream');
});
it('serialises values for inline scripts without a </script> breakout', () => {
expect(inlineJson(['</script>', 'a\u2028b'])).toBe('["\\u003c/script>","a\\u2028b"]');
});
});
describe('Stalwart target: the entry document', () => {
const html = buildStalwartEntry({ locales: ['de', 'en'], shells: ['', 'mail', 'calendar'], defaultLocale: 'en', buildId: '1.10.0-abc-x', appName: 'Acme <Mail>' });
it('carries both literals Stalwart rewrites exactly once and nothing root-absolute', () => {
expect(html.split(STALWART_BASE_HREF_LITERAL)).toHaveLength(2);
expect(html.split(STALWART_OAUTH_META_LITERAL)).toHaveLength(2);
expect(html).toContain(STALWART_ENTRY_MARKER);
expect(html).toContain('<title>Acme &lt;Mail&gt;</title>');
expect(stalwartEntryProblems(html)).toEqual([]);
// The inline script is valid JavaScript.
const script = html.slice(html.indexOf('<script>') + 8, html.lastIndexOf('</script>'));
expect(() => new Function(script)).not.toThrow();
expect(script).toContain('var BUILD_ID = "1.10.0-abc-x"');
});
it('survives both Stalwart rewrites, including the pre-0.16.19 replace-all', () => {
const current = stalwartRewriteIndex(html, 'webmail', 'my"client');
expect(current).toContain('<base href="/webmail/" />');
expect(current).toContain('<meta name="oauth-client-id" content="my&quot;client" />');
const legacy = stalwartRewriteIndex(html, 'webmail', 'ignored', { legacy: true });
expect(legacy).toContain('<base href="/webmail/" />');
expect(legacy).toContain('<meta name="oauth-client-id" content="" />');
expect(stalwartOauthMeta('a&b<c>')).toBe('<meta name="oauth-client-id" content="a&amp;b&lt;c&gt;"');
});
it('flags a broken entry', () => {
expect(stalwartEntryProblems('<html></html>').join('\n')).toMatch(/exactly once[\s\S]*exactly once[\s\S]*not the Stalwart entry/);
const doubled = html.replace('<title>', `${STALWART_BASE_HREF_LITERAL} /><title>`);
expect(stalwartEntryProblems(doubled)[0]).toMatch(/found 2/);
expect(stalwartEntryProblems(html.replace('<title>', '<script src="/_next/static/x.js"></script><title>')).join('\n')).toMatch(/root-absolute src="\/_next/);
expect(stalwartEntryProblems(html.replace('<title>', '<script>fetch("/config.json")</script><title>')).join('\n')).toMatch(/config\.json absolutely/);
});
it('ships a manifest with relative URLs', () => {
const manifest = buildStalwartManifest({ appName: 'Acme' });
expect(manifest.start_url).toBe('./');
expect(manifest.scope).toBe('./');
expect(manifest.icons.every((icon: { src: string }) => !icon.src.startsWith('/'))).toBe(true);
});
it('documents the install, reserved prefixes, updates and the redirect URI', () => {
const readme = buildStalwartReadme({ version: '1.10.0', commit: 'abc1234', locales: ['en'], buildId: 'b' });
expect(readme).toContain(`releases/latest/download/${STALWART_ZIP_NAME}`);
expect(readme).toContain('stalwart-cli create Application');
// The CLI wants JSON (verified against stalwart-cli 1.0.12), and nothing is mounted before UpdateApps.
expect(readme).toContain(`--field 'urlPrefix={"/webmail":true}'`);
expect(readme).toContain('stalwart-cli create Action/UpdateApps');
expect(readme).toContain('"@type": "UpdateApps"');
expect(readme).toContain('x:Application/set');
expect(readme).toContain('sysApplicationCreate');
expect(readme).toContain('actionUpdateApps');
expect(readme).toContain('autoUpdateFrequency');
expect(readme).toContain(`releases/download/v1.10.0/${STALWART_ZIP_NAME}`);
expect(readme).toContain('https://mail.example.com/webmail/');
for (const segment of ['`mail`', '`calendar`', '`admin`', '`account`']) expect(readme).toContain(segment);
});
});
describe('Stalwart target: zip and emulator', () => {
it('writes a zip that its own reader, jszip and Stalwart-style lookups agree on', async () => {
const entries = [
{ name: 'index.html', data: Buffer.from('<base href="/" />'.repeat(50)) },
{ name: '_next/static/chunks/a.js', data: Buffer.from('x') },
{ name: 'en/mail/index.txt', data: Buffer.from('ü'.repeat(100)) },
];
const zip = buildZip(entries);
const listed = listZipEntries(zip);
expect(listed.map((e) => e.name)).toEqual(entries.map((e) => e.name));
expect(listed[0].method).toBe(8);
expect(listed[1].method).toBe(0);
for (let i = 0; i < entries.length; i++) expect(readZipEntry(zip, listed[i]).equals(entries[i].data)).toBe(true);
const JSZip = (await import('jszip')).default;
const parsed = await JSZip.loadAsync(zip);
expect(await parsed.file('en/mail/index.txt')!.async('string')).toBe('ü'.repeat(100));
// Identical input, identical bytes.
expect(buildZip(entries).equals(zip)).toBe(true);
expect(() => listZipEntries(Buffer.from('nope'))).toThrow(/not a zip/);
});
it('flags zip layouts Stalwart would serve wrongly', () => {
expect(stalwartZipEntryProblems(['index.html', '_next/a.js', 'en/mail/index.html'])).toEqual([]);
expect(stalwartZipEntryProblems(['bulwark/index.html'])[0]).toMatch(/archive root/);
const problems = stalwartZipEntryProblems(['index.html', '404.html', '404/index.html', '_redirects', 'api/x.json', 'a\\b', '../x']).join('\n');
expect(problems).toMatch(/404\.html belongs to the static-host target/);
expect(problems).toMatch(/404\/index\.html belongs/);
expect(problems).toMatch(/_redirects belongs/);
expect(problems).toMatch(/top-level entry api shares its name/);
expect(problems).toMatch(/unsafe entry name "a\\\\b"/);
expect(problems).toMatch(/unsafe entry name "\.\.\/x"/);
});
it('emulates Stalwart request handling for Applications', () => {
const entry = buildStalwartEntry({ locales: ['en'], shells: ['', 'mail'], buildId: 'b' });
const entries = entriesOf({ 'index.html': entry, 'en/mail/index.html': '<html>shell</html>', 'en/mail/index.txt': 'rsc', 'config.json': '{}' });
const serve = (pathname: string, extra: Partial<Parameters<typeof emulateStalwartRequest>[0]> = {}) =>
emulateStalwartRequest({ pathname, entries, prefixes: ['/webmail', 'bulwark/'], oauthClientId: 'cid', ...extra });
expect(serve('/webmail')).toMatchObject({ status: 302, headers: { location: '/webmail/' } });
for (const path of ['/webmail/', '/webmail/en/mail/', '/webmail/en/mail/thread/1', '/webmail/nope']) {
const res = serve(path);
expect(res.status, path).toBe(200);
expect(res.headers['cache-control'], path).toBe('no-cache');
expect(res.headers['content-type'], path).toBe('text/html');
expect(res.body.toString(), path).toContain('<base href="/webmail/" />');
expect(res.body.toString(), path).toContain('<meta name="oauth-client-id" content="cid" />');
}
expect(serve('/bulwark/en/').body.toString()).toContain('<base href="/bulwark/" />');
const exact = serve('/webmail/en/mail/index.html');
expect(exact.headers['cache-control']).toBe('public, max-age=31536000, immutable');
expect(exact.body.toString()).toBe('<html>shell</html>');
expect(serve('/webmail/index.html').headers['cache-control']).toBe('no-cache');
expect(serve('/webmail/en/mail/index.txt').headers['content-type']).toBe('application/octet-stream');
expect(serve('/webmail/config.json').headers['content-type']).toBe('application/json');
for (const path of ['/', '/mail/x', '/jmap', '/api/auth', '/other/']) expect(serve(path).status, path).toBe(404);
expect(serve('/webmail/', { oauthClientId: '' }).body.toString()).toContain('<meta name="oauth-client-id" content="" />');
});
it('lists the shells below a locale', () => {
const root = mkdtempSync(join(tmpdir(), 'bulwark-lite-shells-'));
try {
for (const dir of ['en', 'en/mail', 'en/auth/callback', 'en/mail/__next.x', 'en/_not-found']) {
mkdirSync(join(root, ...dir.split('/')), { recursive: true });
writeFileSync(join(root, ...dir.split('/'), 'index.html'), '');
}
mkdirSync(join(root, 'en', 'auth'), { recursive: true });
expect(discoverShellDirs(root, 'en')).toEqual(['', 'auth/callback', 'mail']);
expect(discoverShellDirs(root, 'fr')).toEqual([]);
expect(collectZipEntries(join(root, 'en')).map((e) => e.name)).toEqual(['_not-found/index.html', 'auth/callback/index.html', 'index.html', 'mail/__next.x/index.html', 'mail/index.html']);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('writes a same-origin config for the Stalwart target', () => {
const config = buildLiteConfig({}, { target: 'stalwart' });
expect(config.jmapServerUrl).toBe('');
expect(config.allowCustomJmapEndpoint).toBe(false);
expect(config.rememberMeEnabled).toBe(true);
expect(config.demoMode).toBe(false);
expect(config._comment).toMatch(/Read-only inside the Application bundle/);
expect(buildLiteConfig({ LITE_ALLOW_CUSTOM_ENDPOINT: 'true' }, { target: 'stalwart' }).allowCustomJmapEndpoint).toBe(true);
});
});
describe('Stalwart target: postbuild + verify against a fake checkout', () => {
function makeExport(root: string) {
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><head></head></html>');
writeFileSync(join(out, locale, surface, 'index.txt'), '0:I[1,["/_next/static/chunks/a.js"],"x"]\n1:T3,abc');
// Next 16.3's nested segment-prefetch tree (requested with dots, never read) and the flat files.
mkdirSync(join(out, locale, surface, '__next.!KG1haW4p', '$d$locale'), { recursive: true });
writeFileSync(join(out, locale, surface, '__next.!KG1haW4p', '$d$locale.txt'), 'seg');
writeFileSync(join(out, locale, surface, '__next._tree.txt'), 'tree');
}
writeFileSync(join(out, locale, 'index.html'), '<html></html>');
}
mkdirSync(join(out, '_next', 'static', 'chunks'), { recursive: true });
mkdirSync(join(out, '_next', 'static', 'media'), { recursive: true });
writeFileSync(join(out, '_next', 'static', 'chunks', 'app.js'), 'fetch("/api/config")');
writeFileSync(join(out, '_next', 'static', 'media', 'pdf.worker.min.abc.mjs'), 'worker()');
mkdirSync(join(out, 'branding'), { recursive: true });
mkdirSync(join(out, '404'), { recursive: true });
writeFileSync(join(out, '404', 'index.html'), 'next 404');
writeFileSync(join(out, '404.html'), 'next 404');
return out;
}
it('writes the entry, drops static-host files, copies .mjs as .js, packs and verifies the zip', () => {
const root = makeRepo();
try {
const out = makeExport(root);
const result = runPostbuild({ root, env: env({ LITE_TARGET: 'stalwart', NEXT_PUBLIC_LITE_BUILD_ID: 'bid-1', GIT_COMMIT: 'abcdef1234567' }), log: () => {} });
expect(result.target).toBe('stalwart');
expect(result.buildId).toBe('bid-1');
expect(result.zipPath).toBe(join(root, STALWART_ZIP_NAME));
for (const file of ['index.html', 'config.json', 'policy.json', 'manifest.json', 'LITE-README.md', 'lite-build.json']) expect(existsSync(join(out, file)), file).toBe(true);
for (const file of ['404.html', '404', '_redirects', '_headers', 'nginx.conf.example', 'Caddyfile.example', 'manifest.webmanifest']) expect(existsSync(join(out, file)), file).toBe(false);
expect(readFileSync(join(out, '_next', 'static', 'media', 'pdf.worker.min.abc.js'), 'utf8')).toBe('worker()');
expect(existsSync(join(out, 'en', 'mail', '__next.!KG1haW4p'))).toBe(false);
expect(existsSync(join(out, 'en', 'mail', '__next._tree.txt'))).toBe(true);
expect(findSegmentPrefetchDirs(out)).toEqual([]);
const entry = readFileSync(join(out, 'index.html'), 'utf8');
expect(entry).toContain('var BUILD_ID = "bid-1"');
expect(entry).toContain('var SHELLS = ["","calendar","contacts","files","login","mail","settings"]');
expect(JSON.parse(readFileSync(join(out, 'lite-build.json'), 'utf8'))).toMatchObject({ target: 'stalwart', buildId: 'bid-1', commit: 'abcdef1' });
expect(JSON.parse(readFileSync(join(out, 'config.json'), 'utf8')).allowCustomJmapEndpoint).toBe(false);
const names = listZipEntries(readFileSync(result.zipPath!)).map((e) => e.name);
expect(names).toContain('index.html');
expect(names).toContain('en/mail/index.html');
expect(names.some((n) => n.startsWith('out/'))).toBe(false);
const verdict = verifyExport({ root });
expect(verdict.target).toBe('stalwart');
expect(verdict.problems).toEqual([]);
expect(verdict.zipBytes).toBeGreaterThan(0);
// Size guard: above the safety margin fails.
expect(verifyExport({ root, safeBundleBytes: 10 }).problems.join('\n')).toMatch(/safety margin/);
// A text row holding an asset URL would never be rewritten.
writeFileSync(join(out, 'en', 'mail', 'index.txt'), '1:T8,"/_next/');
expect(verifyExport({ root }).problems.join('\n')).toMatch(/text rows 1 hold root-absolute URLs/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('refuses a baked base path and reports a missing zip', () => {
const root = makeRepo();
try {
makeExport(root);
expect(() => runPostbuild({ root, env: env({ LITE_TARGET: 'stalwart', NEXT_PUBLIC_BASE_PATH: '/webmail' }), log: () => {} })).toThrow(/unset NEXT_PUBLIC_BASE_PATH/);
expect(verifyExport({ root, target: 'stalwart' }).problems.join('\n')).toMatch(/missing bulwark-lite-stalwart\.zip/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('keeps the static target unchanged', () => {
const root = makeRepo();
try {
makeExport(root);
const result = runPostbuild({ root, env: env({}), log: () => {} });
expect(result.target).toBe('static');
expect(result.zipPath).toBeNull();
expect(existsSync(join(root, 'out', '_redirects'))).toBe(true);
expect(readFileSync(join(root, 'out', '404.html'), 'utf8')).toContain(LITE_PENDING_PATH_KEY);
// Static hosts keep the segment-prefetch trees untouched (2 locales x 6 surfaces).
expect(findSegmentPrefetchDirs(join(root, 'out'))).toHaveLength(12);
expect(existsSync(join(root, STALWART_ZIP_NAME))).toBe(false);
expect(verifyExport({ root }).problems).toEqual([]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
+133
View File
@@ -0,0 +1,133 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* The Stalwart Lite target is a build-time switch (NEXT_PUBLIC_LITE_TARGET),
* so every test imports the modules fresh with the env stubbed.
*/
async function loadStalwartModules(target: 'stalwart' | 'static' = 'stalwart') {
vi.resetModules();
vi.stubEnv('NEXT_PUBLIC_BULWARK_LITE', '1');
vi.stubEnv('NEXT_PUBLIC_LITE_TARGET', target);
vi.stubEnv('NEXT_PUBLIC_LITE_BUILD_ID', '1.10.0-abc1234-x1');
vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '');
const lite = await import('@/lib/lite');
const nav = await import('@/lib/browser-navigation');
const tokens = await import('@/lib/auth/lite-tokens');
return { lite, nav, tokens };
}
const w = window as unknown as Record<string, unknown>;
describe('Lite on Stalwart: runtime mount and client id', () => {
beforeEach(() => {
delete w.__BULWARK_LITE_MOUNT__;
delete w.__BULWARK_LITE_OAUTH_CLIENT_ID__;
localStorage.clear();
sessionStorage.clear();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.resetModules();
delete w.__BULWARK_LITE_MOUNT__;
delete w.__BULWARK_LITE_OAUTH_CLIENT_ID__;
});
it('reads the mount the entry document published, and only a single-segment one', async () => {
const { lite, nav } = await loadStalwartModules();
expect(lite.IS_LITE_STALWART).toBe(true);
expect(lite.getLiteMount()).toBeNull();
w.__BULWARK_LITE_MOUNT__ = '/webmail';
expect(lite.getLiteMount()).toBe('/webmail');
// getPathPrefix follows it even where the URL has no locale segment.
expect(nav.getPathPrefix()).toBe('/webmail');
expect(nav.withBasePath('/branding/x.svg')).toBe('/webmail/branding/x.svg');
w.__BULWARK_LITE_MOUNT__ = '';
expect(lite.getLiteMount()).toBe('');
for (const bad of ['/a/b', 'webmail', '/web mail', '/x"y', 42]) {
w.__BULWARK_LITE_MOUNT__ = bad;
expect(lite.getLiteMount()).toBeNull();
}
});
it('ignores the globals in the static-host build', async () => {
const { lite } = await loadStalwartModules('static');
w.__BULWARK_LITE_MOUNT__ = '/webmail';
w.__BULWARK_LITE_OAUTH_CLIENT_ID__ = 'custom';
expect(lite.IS_LITE_STALWART).toBe(false);
expect(lite.getLiteMount()).toBeNull();
expect(lite.getLiteInjectedClientId()).toBe('');
expect(lite.withLiteBuildId('/config.json')).toBe('/config.json');
});
it('stamps un-hashed files with the build id', async () => {
const { lite } = await loadStalwartModules();
expect(lite.withLiteBuildId('/config.json')).toBe('/config.json?v=1.10.0-abc1234-x1');
expect(lite.withLiteBuildId('/x?a=1')).toBe('/x?a=1&v=1.10.0-abc1234-x1');
});
it('points /_next assets at the mount and serves .mjs as .js', async () => {
const { lite } = await loadStalwartModules();
const origin = window.location.origin;
const worker = `${origin}/_next/static/media/pdf.worker.min.abc.mjs`;
// Not booted by the entry document (no mount): untouched.
expect(lite.liteMountedAssetUrl(worker)).toBe(worker);
w.__BULWARK_LITE_MOUNT__ = '/webmail';
expect(lite.liteMountedAssetUrl(`${origin}/_next/static/media/pdf.worker.min.abc.mjs`)).toBe(`${origin}/webmail/_next/static/media/pdf.worker.min.abc.js`);
expect(lite.liteMountedAssetUrl('https://cdn.example.com/_next/x.mjs')).toBe('https://cdn.example.com/_next/x.mjs');
expect(lite.liteMountedAssetUrl(`${origin}/other/x.mjs`)).toBe(`${origin}/other/x.mjs`);
});
it('uses the Application oauthClientId when Stalwart injected one, else bulwark-webmail', async () => {
const { tokens } = await loadStalwartModules();
expect(tokens.getLiteClientId()).toBe('bulwark-webmail');
w.__BULWARK_LITE_OAUTH_CLIENT_ID__ = ' ';
expect(tokens.getLiteClientId()).toBe('bulwark-webmail');
w.__BULWARK_LITE_OAUTH_CLIENT_ID__ = 'my-webmail';
expect(tokens.getLiteClientId()).toBe('my-webmail');
});
it('does not probe /api/auth on its own origin (Stalwart serves the bundle)', async () => {
const { tokens } = await loadStalwartModules();
const fetchMock = vi.fn(async () => new Response('{}', { status: 400 }));
vi.stubGlobal('fetch', fetchMock);
tokens.resetLiteProbeCache();
await expect(tokens.probeLiteTokenLogin(`${window.location.origin}/`)).resolves.toBe(true);
expect(fetchMock).not.toHaveBeenCalled();
// Another server (a custom build pointing elsewhere) is still probed.
await expect(tokens.probeLiteTokenLogin('https://other.example.com')).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('logs in and refreshes with the injected client id, and keeps using the one a token was issued to', async () => {
const { tokens } = await loadStalwartModules();
w.__BULWARK_LITE_OAUTH_CLIENT_ID__ = 'app-client';
const calls: { url: string; body: string }[] = [];
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ url, body: String(init?.body ?? '') });
if (url.endsWith('/api/auth')) return new Response(JSON.stringify({ type: 'authenticated', client_code: 'code' }), { status: 200 });
return new Response(JSON.stringify({ access_token: 'at', refresh_token: 'rt2', expires_in: 60 }), { status: 200 });
}));
await tokens.liteTokenLogin({ serverUrl: 'https://mail.example.com', username: 'a', password: 'p', redirectUri: 'https://mail.example.com/webmail/' });
expect(JSON.parse(calls[0].body).clientId).toBe('app-client');
expect(new URLSearchParams(calls[1].body).get('client_id')).toBe('app-client');
// A token issued before the admin changed oauthClientId refreshes with its own client.
tokens.saveLiteRefreshToken(0, { serverUrl: 'https://mail.example.com', username: 'a', refreshToken: 'rt', clientId: 'old-client' }, true);
await tokens.liteRefreshTokens(0);
expect(new URLSearchParams(calls[2].body).get('client_id')).toBe('old-client');
// The rotated token keeps its client id.
expect(tokens.readLiteRefreshToken(0)?.clientId).toBe('old-client');
// Tokens stored before this change carry no client id: fall back to the current one.
tokens.saveLiteRefreshToken(1, { serverUrl: 'https://mail.example.com', username: 'b', refreshToken: 'rt' }, false);
await tokens.liteRefreshTokens(1);
expect(new URLSearchParams(calls[3].body).get('client_id')).toBe('app-client');
});
});
+18 -2
View File
@@ -1,4 +1,5 @@
import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce';
import { IS_LITE_STALWART, getLiteInjectedClientId } from '@/lib/lite';
/**
* Cookie-free sessions for the static Lite build.
@@ -31,6 +32,15 @@ import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce';
*/
export const LITE_CLIENT_ID = 'bulwark-webmail';
/**
* The client id Lite presents to Stalwart: the `oauthClientId` of the
* Stalwart `Application` that serves the bundle (injected into the root
* index.html, see lib/lite.ts) when there is one, else `LITE_CLIENT_ID`.
*/
export function getLiteClientId(): string {
return getLiteInjectedClientId() || LITE_CLIENT_ID;
}
export type LiteLoginErrorCode =
| 'endpoint_missing'
| 'totp_required'
@@ -56,6 +66,8 @@ interface StoredRefreshToken {
serverUrl: string;
username: string;
refreshToken: string;
/** Client the token was issued to; refreshes must present the same one. */
clientId?: string;
}
interface StoredBasicSession {
@@ -209,7 +221,7 @@ export async function liteTokenLogin(params: {
clientId?: string;
}): Promise<LiteTokens> {
const base = trimUrl(params.serverUrl);
const clientId = params.clientId || LITE_CLIENT_ID;
const clientId = params.clientId || getLiteClientId();
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
@@ -274,9 +286,10 @@ export async function liteTokenLogin(params: {
* 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> {
export async function liteRefreshTokens(slot: number, clientIdOverride?: string): Promise<LiteTokens> {
const stored = readLiteRefreshToken(slot);
if (!stored) throw new LiteLoginError('refresh_rejected', 401, 'no refresh token');
const clientId = clientIdOverride || stored.clientId || getLiteClientId();
const response = await fetch(`${stored.serverUrl}/auth/token`, {
method: 'POST',
@@ -321,6 +334,9 @@ const probeCache = new Map<string, Promise<boolean | null>>();
export function probeLiteTokenLogin(serverUrl: string): Promise<boolean | null> {
const base = trimUrl(serverUrl);
if (!/^https?:\/\//.test(base)) return Promise.resolve(false);
// Served by Stalwart itself (an Application bundle, 0.16+): the page's own
// origin has /api/auth. Probing would only log a 400 for the empty body.
if (IS_LITE_STALWART && typeof window !== 'undefined' && base === window.location.origin) return Promise.resolve(true);
let pending = probeCache.get(base);
if (!pending) {
pending = fetch(`${base}/api/auth`, {
+5
View File
@@ -1,4 +1,5 @@
import { locales } from '@/i18n/routing';
import { getLiteMount } from '@/lib/lite';
export function replaceWindowLocation(url: string): void {
if (typeof window === 'undefined') {
@@ -30,6 +31,10 @@ const STATIC_BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$
export function getPathPrefix(locale?: string): string {
if (STATIC_BASE_PATH) return STATIC_BASE_PATH;
if (typeof window === 'undefined') return '';
// Lite on Stalwart: the entry document read the prefix from <base href>,
// which works for URLs without a locale segment too (`/webmail/`).
const liteMount = getLiteMount();
if (liteMount !== null) return liteMount;
const segments = window.location.pathname.split('/').filter(Boolean);
+13 -2
View File
@@ -76,10 +76,13 @@ function trimUrl(value: string): string {
* 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).
* - `defaults.jmapServerUrl` (the Stalwart build passes the page origin) fills
* an empty server URL, and the server field then stays hidden unless
* config.json explicitly allows it.
*/
export function applyLiteConfig(raw: unknown): ConfigData {
export function applyLiteConfig(raw: unknown, defaults?: { jmapServerUrl?: string }): ConfigData {
const input = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
const jmapServerUrl = trimUrl(str(input.jmapServerUrl, ''));
const jmapServerUrl = trimUrl(str(input.jmapServerUrl, '')) || trimUrl(defaults?.jmapServerUrl ?? '');
const jmapServers = Array.isArray(input.jmapServers)
? (input.jmapServers as unknown[])
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
@@ -126,6 +129,14 @@ export function applyLiteConfig(raw: unknown): ConfigData {
};
}
/**
* Zero-config defaults for a bundle served by Stalwart itself: the JMAP
* server is the page's own origin (so no CORS), and there is nothing to type.
*/
export function liteStalwartDefaults(): { jmapServerUrl: string } {
return { jmapServerUrl: typeof window === 'undefined' ? '' : window.location.origin };
}
/**
* 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.
+67
View File
@@ -0,0 +1,67 @@
import { locales } from '@/i18n/routing';
/**
* Href helpers for a Lite bundle mounted at a prefix nobody knew at build time
* (Stalwart `Application` with an admin-chosen `urlPrefix`).
*
* Next's router only knows the build-time `basePath`, and next-intl only adds
* the locale, so `router.push('/settings')` would leave `/webmail` and land
* on Stalwart's own routes. The Stalwart build routes every `@/i18n/navigation`
* call through these helpers instead (i18n/mounted-navigation.tsx): hrefs gain
* `<mount>/<locale>` on the way out, pathnames lose them on the way in, so the
* components keep comparing against `/settings` exactly as before.
*/
export type MountedHref =
| string
| {
pathname: string;
query?: Record<string, string | number | boolean | readonly (string | number | boolean)[] | null | undefined>;
hash?: string;
};
function isLocale(segment: string | undefined): boolean {
return !!segment && (locales as readonly string[]).includes(segment);
}
/** `{ pathname, query, hash }` (next-intl's object form) as a plain href string. */
export function hrefToString(href: MountedHref): string {
if (typeof href === 'string') return href;
const params = new URLSearchParams();
for (const [key, value] of Object.entries(href.query ?? {})) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) value.forEach((v) => params.append(key, String(v)));
else params.append(key, String(value));
}
const search = params.toString();
const hash = href.hash ? (href.hash.startsWith('#') ? href.hash : `#${href.hash}`) : '';
return `${href.pathname}${search ? `?${search}` : ''}${hash}`;
}
/**
* `/settings?tab=x` -> `<prefix>/<locale>/settings?tab=x`.
*
* External, protocol-relative and relative hrefs pass through, and so does a
* path that already carries the prefix (or, at the site root, a locale), so
* wrapping twice is harmless.
*/
export function mountedHref(href: MountedHref, locale: string, prefix: string): string {
const raw = hrefToString(href);
if (!raw.startsWith('/') || raw.startsWith('//')) return raw;
if (prefix && (raw === prefix || raw.startsWith(`${prefix}/`))) return raw;
if (!prefix && isLocale(raw.split(/[/?#]/)[1])) return raw;
return `${prefix}/${locale}${raw}`;
}
/**
* Browser pathname -> the locale-less path next-intl's `usePathname` returns:
* `/webmail/en/settings/` -> `/settings`, `/webmail/en/` -> `/`.
*/
export function stripMountedPathname(pathname: string, prefix: string): string {
let path = pathname || '/';
if (prefix && (path === prefix || path.startsWith(`${prefix}/`))) path = path.slice(prefix.length) || '/';
const parts = path.split('/');
if (isLocale(parts[1])) path = `/${parts.slice(2).join('/')}`;
if (path.length > 1) path = path.replace(/\/+$/, '');
return path || '/';
}
+88
View File
@@ -16,6 +16,94 @@
*/
export const IS_LITE = process.env.NEXT_PUBLIC_BULWARK_LITE === '1';
/**
* Which host the Lite export was built for (`npm run build:lite -- --target=`):
*
* - `static` (default): any static web host. Each surface is its own HTML
* shell, the host's rewrite rules (or 404.html) route deep links to it, and
* `config.json` is edited after unzipping.
* - `stalwart`: a Stalwart `Application` bundle. Stalwart answers every
* unknown path under the mount prefix with the root `index.html` (after
* rewriting its `<base href>` to the prefix), so that file boots the right
* shell in place and the app learns its mount prefix at runtime
* (`getLiteMount`). Same origin as JMAP: no server URL, no CORS.
*/
export const LITE_TARGET: 'static' | 'stalwart' =
IS_LITE && process.env.NEXT_PUBLIC_LITE_TARGET === 'stalwart' ? 'stalwart' : 'static';
export const IS_LITE_STALWART = LITE_TARGET === 'stalwart';
/**
* Identifies one Lite build. The Stalwart target appends it as `?v=` to every
* un-hashed file it fetches (shells, config.json, policy.json, manifest),
* because Stalwart serves everything except the root index.html with an
* immutable cache header: a new bundle must mean new URLs.
*/
export const LITE_BUILD_ID = process.env.NEXT_PUBLIC_LITE_BUILD_ID ?? '';
/**
* Globals the Stalwart entry document (scripts/lite/lib.mjs,
* buildStalwartEntry) sets before it writes a shell into the page: the mount
* prefix Stalwart put into `<base href>`, and the Application's
* `oauthClientId` from `<meta name="oauth-client-id">`. Both only exist in
* the root document, which the shell replaces.
*/
export const LITE_MOUNT_GLOBAL = '__BULWARK_LITE_MOUNT__';
export const LITE_OAUTH_CLIENT_ID_GLOBAL = '__BULWARK_LITE_OAUTH_CLIENT_ID__';
/**
* A Stalwart mount prefix: empty, or one path segment (Stalwart matches
* `urlPrefix` against the first segment only). Mirrors the check in the entry
* document, which refuses to boot under anything else.
*/
export const LITE_MOUNT_PATTERN = /^(\/[A-Za-z0-9._~-]+)?$/;
function readStringGlobal(name: string): string | null {
if (typeof window === 'undefined') return null;
const value = (window as unknown as Record<string, unknown>)[name];
return typeof value === 'string' ? value : null;
}
/**
* The runtime mount prefix of a Stalwart-hosted Lite bundle (`/webmail`), or
* `null` when the page was not booted by the Stalwart entry document.
*/
export function getLiteMount(): string | null {
if (!IS_LITE_STALWART) return null;
const mount = readStringGlobal(LITE_MOUNT_GLOBAL);
return mount !== null && LITE_MOUNT_PATTERN.test(mount) ? mount : null;
}
/** The Application's `oauthClientId` as Stalwart injected it, or '' when unset. */
export function getLiteInjectedClientId(): string {
if (!IS_LITE_STALWART) return '';
return (readStringGlobal(LITE_OAUTH_CLIENT_ID_GLOBAL) ?? '').trim();
}
/**
* An asset URL Turbopack baked as `/_next/...` (`new URL(..., import.meta.url)`),
* pointed at the Stalwart mount. `.mjs` becomes `.js`: Stalwart serves `.mjs`
* as application/octet-stream, which browsers refuse for module workers, so
* the Stalwart build ships a `.js` copy of every `.mjs` asset.
*/
export function liteMountedAssetUrl(url: string): string {
const mount = getLiteMount();
if (mount === null || typeof window === 'undefined') return url;
try {
const parsed = new URL(url, window.location.href);
if (parsed.origin !== window.location.origin || !parsed.pathname.startsWith('/_next/')) return url;
parsed.pathname = `${mount}${parsed.pathname.replace(/\.mjs$/, '.js')}`;
return parsed.toString();
} catch {
return url;
}
}
/** Appends the build id to an un-hashed Lite file URL (Stalwart target only). */
export function withLiteBuildId(path: string): string {
if (!IS_LITE_STALWART || !LITE_BUILD_ID) return path;
return `${path}${path.includes('?') ? '&' : '?'}v=${encodeURIComponent(LITE_BUILD_ID)}`;
}
/** Runtime config file a deployer edits after unzipping (relative to the mount prefix). */
export const LITE_CONFIG_PATH = '/config.json';
+1
View File
@@ -27,6 +27,7 @@
"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",
"test:lite-smoke:stalwart": "playwright test -c scripts/lite/smoke/playwright.stalwart.config.ts",
"start": "next start",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
+32 -8
View File
@@ -5,15 +5,19 @@
// 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/
// (Stalwart target: the entry document, then the zip)
// 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).
// Inputs (env): LITE_TARGET (static | stalwart), LITE_JMAP_SERVER_URL,
// LITE_APP_NAME, LITE_ALLOW_CUSTOM_ENDPOINT, LITE_REMEMBER_ME, LITE_DEMO_MODE,
// LITE_LOCALES, NEXT_PUBLIC_BASE_PATH (static target only),
// NEXT_PUBLIC_DEFAULT_LOCALE, GIT_COMMIT. Flags: --target=stalwart,
// --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 { readFileSync } from "node:fs";
import { join } from "node:path";
import { makeBuildId, normalizeBasePath, parseLiteTarget, resolveRepoRoot } from "./lib.mjs";
import { runPrepare } from "./prepare.mjs";
import { runPostbuild } from "./postbuild.mjs";
import { verifyExport } from "./verify.mjs";
@@ -21,6 +25,20 @@ import { verifyExport } from "./verify.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
const argv = process.argv.slice(2);
const dryRun = argv.includes("--dry-run");
const target = parseLiteTarget(argv, process.env);
if (target === "stalwart" && normalizeBasePath(process.env.NEXT_PUBLIC_BASE_PATH)) {
console.error("[lite] --target=stalwart detects its mount prefix at runtime (Stalwart's urlPrefix); unset NEXT_PUBLIC_BASE_PATH");
process.exit(1);
}
let version = "0.0.0";
try {
version = readFileSync(join(repoRoot, "VERSION"), "utf8").trim();
} catch {
// no VERSION file
}
const commit = (process.env.GIT_COMMIT ?? "").trim().slice(0, 7) || "local";
const env = {
...process.env,
@@ -29,11 +47,17 @@ const env = {
// in every URL.
NEXT_PUBLIC_LOCALE_PREFIX: "always",
NEXT_TELEMETRY_DISABLED: process.env.NEXT_TELEMETRY_DISABLED ?? "1",
LITE_TARGET: target,
NEXT_PUBLIC_LITE_TARGET: target,
// One id for the bundle and the entry document that fetches from it.
NEXT_PUBLIC_LITE_BUILD_ID: process.env.NEXT_PUBLIC_LITE_BUILD_ID?.trim() || makeBuildId({ version, commit }),
};
if (dryRun) {
runPrepare({ argv: ["--dry-run"], env });
console.log("[lite] would run: next build --turbopack with", {
LITE_TARGET: target,
LITE_BUILD_ID: env.NEXT_PUBLIC_LITE_BUILD_ID,
LITE_JMAP_SERVER_URL: env.LITE_JMAP_SERVER_URL ?? "",
LITE_APP_NAME: env.LITE_APP_NAME ?? "",
LITE_LOCALES: env.LITE_LOCALES ?? "(all)",
@@ -49,7 +73,7 @@ if (!argv.includes("--skip-prepare")) {
const require = createRequire(import.meta.url);
const nextBin = require.resolve("next/dist/bin/next", { paths: [repoRoot] });
console.log("[lite] next build --turbopack (static export)");
console.log(`[lite] next build --turbopack (static export, ${target} target, build ${env.NEXT_PUBLIC_LITE_BUILD_ID})`);
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}`);
@@ -58,10 +82,10 @@ if (build.status !== 0) {
runPostbuild({ env });
const { problems, locales, apiStrings } = verifyExport();
const { problems, locales, apiStrings, zipBytes } = verifyExport({ target });
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`);
console.log(`[lite] done: out/ holds ${locales.length} locales; ${apiStrings.length} documented /api strings in chunks${zipBytes ? `; zip ${(zipBytes / 1024 / 1024).toFixed(1)} MB` : ""}`);
+830 -4
View File
@@ -4,6 +4,7 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { crc32, deflateRawSync, inflateRawSync } from "node:zlib";
/**
* Repository root for a script under scripts/lite/. Falls back to the current
@@ -131,14 +132,21 @@ export function normalizeBasePath(raw) {
return trimmed;
}
/** config.json contents from the LITE_* build inputs. */
export function buildLiteConfig(env = {}) {
/**
* config.json contents from the LITE_* build inputs. The Stalwart target is
* served by the mail server itself: an empty server URL means "this origin"
* there, so the server field stays hidden unless a build asks for it.
*/
export function buildLiteConfig(env = {}, { target = "static" } = {}) {
const jmapServerUrl = (env.LITE_JMAP_SERVER_URL ?? "").trim().replace(/\/+$/, "");
const stalwart = target === "stalwart";
return {
_comment: "Bulwark Lite runtime configuration. Edit and re-upload; no rebuild needed. See LITE-README.md.",
_comment: stalwart
? "Bulwark Lite for Stalwart. Read-only inside the Application bundle: to change it, build your own zip (see LITE-README.md). An empty jmapServerUrl means the Stalwart server that serves this page."
: "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 === ""),
allowCustomJmapEndpoint: parseBool(env.LITE_ALLOW_CUSTOM_ENDPOINT, !stalwart && jmapServerUrl === ""),
rememberMeEnabled: parseBool(env.LITE_REMEMBER_ME, true),
demoMode: parseBool(env.LITE_DEMO_MODE, false),
loginShowTotp: true,
@@ -532,3 +540,821 @@ export function collectApiStrings(chunksDir) {
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))));
}
// ---------------------------------------------------------------------------
// Stalwart `Application` target (`npm run build:lite -- --target=stalwart`)
// ---------------------------------------------------------------------------
//
// Stalwart 0.16 downloads a zip from the Application's `resourceUrl` and
// serves it under `urlPrefix` (crates/common/src/manager/application.rs,
// crates/http/src/request.rs). The rules that shape this target:
//
// - lookup is an exact match of the path after `/<prefix>/` against a zip
// entry name; everything else (directories, deep links, typos) gets the
// root index.html - there is no directory index and no 404;
// - only that root index.html is rewritten (`<base href="/"` -> the prefix,
// `<meta name="oauth-client-id" content=""` -> the Application's client
// id) and sent `no-cache`; every other entry is `immutable` for a year;
// - content types come from a fixed extension map (below), the rest is
// application/octet-stream;
// - the bundle is same-origin with /jmap, /api/auth and /auth/token.
export const LITE_TARGETS = ["static", "stalwart"];
/** `--target=stalwart` (or `--target stalwart`) wins over LITE_TARGET. */
export function parseLiteTarget(argv = [], env = {}) {
let value = env.LITE_TARGET;
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith("--target=")) value = argv[i].slice("--target=".length);
else if (argv[i] === "--target" && argv[i + 1]) value = argv[i + 1];
}
const target = (value ?? "").trim().toLowerCase() || "static";
if (!LITE_TARGETS.includes(target)) throw new Error(`unknown Lite target ${JSON.stringify(value)} (expected ${LITE_TARGETS.join(" or ")})`);
return target;
}
/** Release asset name; `releases/latest/download/<this>` is the stable resourceUrl. */
export const STALWART_ZIP_NAME = "bulwark-lite-stalwart.zip";
/** The mount prefix the docs and the one-line install use. */
export const STALWART_DEFAULT_PREFIX = "/webmail";
/**
* First path segments Stalwart routes itself before it ever looks at an
* Application (crates/http/src/request.rs, 0.16.22), plus the WebUI's own
* default Applications. An Application mounted at one of these is
* unreachable (`mail` and `calendar` included: Stalwart answers
* /mail/config-v1.1.xml and /calendar/rsvp and 404s everything else there).
*/
export const STALWART_RESERVED_SEGMENTS = [
"jmap", "dav", ".well-known", "auth", "scim", "api", "mail", "calendar",
"autodiscover", "robots.txt", "healthz", "metrics", "logo", "form", "login", "device",
"admin", "account",
];
/** Case-insensitive for autodiscover, which Stalwart matches in three spellings. */
export function isReservedStalwartSegment(segment) {
const s = String(segment ?? "");
return STALWART_RESERVED_SEGMENTS.includes(s) || s.toLowerCase() === "autodiscover";
}
/** Problems with an admin-chosen `urlPrefix` (empty list = fine). */
export function stalwartPrefixProblems(prefix) {
const raw = String(prefix ?? "");
const trimmed = raw.replace(/^\/+/, "").replace(/\/+$/, "");
if (!trimmed) return ["the prefix is empty (Stalwart needs one path segment, e.g. /webmail)"];
if (trimmed.includes("/")) return [`${raw} has more than one segment; Stalwart matches the first segment only`];
if (!/^[A-Za-z0-9._~-]+$/.test(trimmed)) return [`${raw} contains characters outside [A-Za-z0-9._~-]`];
if (isReservedStalwartSegment(trimmed)) return [`/${trimmed} is routed by Stalwart itself and would never reach the Application`];
return [];
}
/** Stalwart's extension -> content type map (application.rs). No charset. */
export const STALWART_CONTENT_TYPES = {
html: "text/html",
css: "text/css",
wasm: "application/wasm",
js: "application/javascript",
json: "application/json",
png: "image/png",
svg: "image/svg+xml",
ico: "image/x-icon",
};
export function stalwartContentType(name) {
const text = String(name);
const at = text.lastIndexOf(".");
const ext = at === -1 ? "" : text.slice(at + 1);
return STALWART_CONTENT_TYPES[ext] ?? "application/octet-stream";
}
/** Stalwart refuses bundles above this (MAX_APP_SIZE, compressed). */
export const STALWART_MAX_BUNDLE_BYTES = 100 * 1024 * 1024;
/** verify.mjs fails the build above this, leaving headroom for growth. */
export const STALWART_SAFE_BUNDLE_BYTES = 90 * 1024 * 1024;
/** The two literals Stalwart replaces in the root index.html. */
export const STALWART_BASE_HREF_LITERAL = '<base href="/"';
export const STALWART_OAUTH_META_LITERAL = '<meta name="oauth-client-id" content=""';
/** Marks the entry document so it never writes itself into the page. */
export const STALWART_ENTRY_MARKER = "data-bulwark-lite-entry";
/** Files of the static-host target that make no sense inside a Stalwart bundle. */
export const STALWART_EXCLUDED_FILES = ["404.html", "_redirects", "_headers", "nginx.conf.example", "Caddyfile.example", "manifest.webmanifest"];
export const STALWART_EXCLUDED_DIRS = ["404"];
/** A build id that changes with every build: version, commit, build time. */
export function makeBuildId({ version = "0.0.0", commit = "local", now = Date.now() } = {}) {
const safe = (s) => String(s).replace(/[^A-Za-z0-9.-]/g, "");
return `${safe(version)}-${safe(commit).slice(0, 7) || "local"}-${Math.floor(now / 1000).toString(36)}`;
}
/**
* Directories below `<outDir>/<locale>/` that hold an index.html: the shells
* the entry can boot ('' is the locale root, i.e. the mail client).
*/
export function discoverShellDirs(outDir, locale) {
const root = join(outDir, locale);
const found = [];
const walk = (dir, rel) => {
if (existsSync(join(dir, "index.html"))) found.push(rel);
for (const entry of readdirSync(dir)) {
if (entry.startsWith("__next") || entry.startsWith("_")) continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) walk(full, rel ? `${rel}/${entry}` : entry);
}
};
if (existsSync(root)) walk(root, "");
return found.sort();
}
/*
* The three functions below run in the browser: buildStalwartEntry embeds
* them with Function#toString, and lib/__tests__/lite-scripts.test.ts tests
* the very same code. Keep them self-contained (no imports, no module scope).
*/
/**
* Which shell to boot for `pathname` under `mount`. Returns
* `{ locale, shell, canonical }`: `shell` is the zip entry to fetch
* (`en/calendar/index.html`), `canonical` a URL to `replaceState` to first
* (no locale in the address, or a path no shell owns) or null to keep the
* address. Returns null when `pathname` is not below `mount`.
*/
export function resolveStalwartBoot(pathname, mount, locales, shells, preferredLocale) {
var rest = pathname;
if (mount) {
if (rest !== mount && rest.indexOf(mount + "/") !== 0) return null;
rest = rest.slice(mount.length) || "/";
}
var parts = rest.split("/").filter(function (p) { return p.length > 0; });
var locale = locales.indexOf(parts[0]) !== -1 ? parts[0] : null;
if (!locale) {
return { locale: preferredLocale, shell: preferredLocale + "/index.html", canonical: mount + "/" + preferredLocale + "/" };
}
var best = null;
for (var i = 0; i < shells.length; i++) {
var dir = shells[i];
if (!dir) continue;
var segs = dir.split("/");
var match = true;
for (var j = 0; j < segs.length; j++) if (parts[1 + j] !== segs[j]) { match = false; break; }
if (match && (!best || segs.length > best.split("/").length)) best = dir;
}
if (best) return { locale: locale, shell: locale + "/" + best + "/index.html", canonical: null };
if (parts.length === 1) return { locale: locale, shell: locale + "/index.html", canonical: null };
return { locale: locale, shell: locale + "/index.html", canonical: mount + "/" + locale + "/" };
}
/**
* Points a prerendered shell at the runtime mount. Next wrote root-absolute
* URLs (`/_next/...`, the manifest, the favicon) into the HTML and into the
* inline RSC payload; both spellings (`"/_next/` and `\"/_next/`) contain
* `"/_next/`, so one split/join covers them. `bootScript` goes first into
* <head> so the globals exist before any chunk runs.
*/
export function rewriteShellForMount(html, mount, buildId, bootScript) {
// The needles are assembled so the entry itself holds no root-absolute
// literal (verify.mjs checks for them).
var q = '"';
var out = html
.split(q + "/_next/").join(q + mount + "/_next/")
.split(q + "/manifest.webmanifest" + q).join(q + mount + "/manifest.json?v=" + encodeURIComponent(buildId) + q)
.split(q + "/branding/").join(q + mount + "/branding/");
var at = out.indexOf("<head>");
return at === -1 ? bootScript + out : out.slice(0, at + 6) + bootScript + out.slice(at + 6);
}
/**
* The same mount rewrite for a prerendered RSC payload (`*.txt`), which the
* router fetches on client-side navigation and whose module rows name their
* chunks as `/_next/static/chunks/...` (Turbopack loads those URLs as they
* are). Rows are `<id>:<json>\n`, except `<id>:T<hex byte length>,<text>`
* rows, which are length-prefixed and copied untouched so the lengths stay
* right (verify.mjs checks that no text row ever holds one of the needles).
*/
export function rewriteFlightForMount(text, mount, buildId) {
var q = '"';
function fix(part) {
return part
.split(q + "/_next/").join(q + mount + "/_next/")
.split(q + "/manifest.webmanifest" + q).join(q + mount + "/manifest.json?v=" + encodeURIComponent(buildId) + q)
.split(q + "/branding/").join(q + mount + "/branding/");
}
var out = "";
var i = 0;
var n = text.length;
while (i < n) {
var colon = text.indexOf(":", i);
var newline = text.indexOf("\n", i);
if (colon !== -1 && (newline === -1 || colon < newline) && /^[0-9a-f]*$/.test(text.slice(i, colon)) && text.charAt(colon + 1) === "T") {
var comma = text.indexOf(",", colon);
var bytes = comma === -1 ? NaN : parseInt(text.slice(colon + 2, comma), 16);
if (!(bytes >= 0)) {
out += text.slice(i);
break;
}
var j = comma + 1;
var seen = 0;
while (j < n && seen < bytes) {
var code = text.charCodeAt(j);
if (code < 0x80) seen += 1;
else if (code < 0x800) seen += 2;
else if (code >= 0xd800 && code <= 0xdbff) { seen += 4; j++; }
else seen += 3;
j++;
}
out += text.slice(i, j);
i = j;
continue;
}
var end = newline === -1 ? n : newline + 1;
out += fix(text.slice(i, end));
i = end;
}
return out;
}
/**
* Makes Next's client-side navigation work on Stalwart. For RSC payloads
* (same origin, below the mount, `.txt`):
*
* - Stalwart labels them application/octet-stream, and Next's router only
* accepts text/plain: anything else turns every navigation into a full
* page load. They are relabeled.
* - their chunk URLs are pointed at the mount (`rewriteFlight`).
*
* HTML (Stalwart's answer for a payload the bundle lacks) and everything
* else pass through untouched.
*/
export function installStalwartFetchShim(win, mount, buildId, rewriteFlight) {
if (!win || !win.fetch || win.__bulwarkLiteFetchShim) return;
var original = win.fetch;
win.__bulwarkLiteFetchShim = true;
win.fetch = function () {
return original.apply(this, arguments).then(function (res) {
var url;
try {
var type = res.headers.get("content-type") || "";
if (type.indexOf("application/octet-stream") !== 0 && type.indexOf("text/plain") !== 0) return res;
url = new URL(res.url);
if (url.origin !== win.location.origin || !/\.txt$/.test(url.pathname)) return res;
if (mount && url.pathname.indexOf(mount + "/") !== 0) return res;
} catch {
return res;
}
return res.text().then(function (body) {
var headers = new Headers(res.headers);
headers.set("content-type", "text/plain; charset=utf-8");
headers.delete("content-length");
var relabeled = new Response(rewriteFlight(body, mount, buildId), { status: res.status, statusText: res.statusText, headers: headers });
Object.defineProperty(relabeled, "url", { value: res.url });
Object.defineProperty(relabeled, "redirected", { value: res.redirected });
return relabeled;
});
});
};
}
// Built from char codes: the literal escapes would be unescaped by editors and tools.
const BACKSLASH = String.fromCharCode(92);
const LINE_SEPARATOR = new RegExp(String.fromCharCode(0x2028), "g");
const PARAGRAPH_SEPARATOR = new RegExp(String.fromCharCode(0x2029), "g");
/** Serialises a value for an inline <script> (no `</script>` breakout). */
export function inlineJson(value) {
return JSON.stringify(value)
.replace(/</g, BACKSLASH + "u003c")
.replace(LINE_SEPARATOR, BACKSLASH + "u2028")
.replace(PARAGRAPH_SEPARATOR, BACKSLASH + "u2029");
}
/**
* The root index.html of the Stalwart bundle, the only document Stalwart
* ever serves for a route. It reads the mount from the rewritten
* `<base href>` and the client id from the rewritten meta tag, fetches the
* matching prerendered shell (build-id URL, since shells are cached
* immutably), points it at the mount and writes it into the page in place:
* the address bar keeps the deep link and the shell hydrates exactly as it
* would behind an nginx SPA fallback.
*/
export function buildStalwartEntry({ locales, shells, defaultLocale = "en", buildId, appName = "Bulwark Webmail" }) {
const fallback = locales.includes(defaultLocale) ? defaultLocale : locales[0];
const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const script = `
(function () {
var BUILD_ID = ${inlineJson(buildId)};
var LOCALES = ${inlineJson(locales)};
var SHELLS = ${inlineJson(shells)};
var FALLBACK = ${inlineJson(fallback)};
var MOUNT_GLOBAL = "__BULWARK_LITE_MOUNT__";
var CLIENT_ID_GLOBAL = "__BULWARK_LITE_OAUTH_CLIENT_ID__";
var ENTRY_MARKER = ${inlineJson(STALWART_ENTRY_MARKER)};
var resolveStalwartBoot = ${resolveStalwartBoot.toString()};
var rewriteShellForMount = ${rewriteShellForMount.toString()};
var rewriteFlightForMount = ${rewriteFlightForMount.toString()};
var installStalwartFetchShim = ${installStalwartFetchShim.toString()};
try {
var theme = JSON.parse(localStorage.getItem("theme-storage") || "null");
var chosen = theme && theme.state && theme.state.theme;
if (chosen === "dark" || chosen === "light") document.documentElement.className = chosen;
} catch (e) {}
function fail(message) {
function show() {
var box = document.getElementById("bulwark-lite-boot");
if (!box) return;
var p = document.createElement("p");
p.textContent = message;
box.innerHTML = "";
box.appendChild(p);
box.removeAttribute("aria-busy");
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", show); else show();
}
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) {
if (/zh-(tw|hk|mo|hant)/.test(lower) && 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 preferred = null;
try {
var stored = JSON.parse(localStorage.getItem(${inlineJson(LOCALE_STORAGE_KEY)}) || "null");
preferred = pick(stored && stored.state && stored.state.locale);
} catch (e) {}
var langs = navigator.languages || [navigator.language];
for (var j = 0; !preferred && j < langs.length; j++) preferred = pick(langs[j]);
preferred = preferred || FALLBACK;
// Stalwart rewrote the base element to point at the Application's prefix.
var mount = new URL(document.baseURI).pathname.replace(/[/]+$/, "");
if (!/^([/][A-Za-z0-9._~-]+)?$/.test(mount)) {
return fail("Bulwark Webmail cannot run under " + mount + "/: the mount prefix must be a single path segment such as /webmail.");
}
var metaTag = document.querySelector('meta[name="oauth-client-id"]');
var clientId = (metaTag && metaTag.getAttribute("content")) || "";
var boot = resolveStalwartBoot(location.pathname, mount, LOCALES, SHELLS, preferred);
if (!boot) {
return fail("This page was opened at " + location.pathname + ", but the bundle is mounted at " + (mount || "/") + ".");
}
if (boot.canonical) {
try { history.replaceState(history.state, "", boot.canonical + location.search + location.hash); } catch (e) {}
}
window[MOUNT_GLOBAL] = mount;
window[CLIENT_ID_GLOBAL] = clientId;
window.TURBOPACK_CHUNK_BASE_PATH = mount + "/_next/";
installStalwartFetchShim(window, mount, BUILD_ID, rewriteFlightForMount);
// Repeated inside the written shell, in case a browser gives it a fresh global.
var bootScript = "<scr" + "ipt>(function(w){w[" + JSON.stringify(MOUNT_GLOBAL) + "]=" + JSON.stringify(mount) +
";w[" + JSON.stringify(CLIENT_ID_GLOBAL) + "]=" + JSON.stringify(clientId).replace(/</g, "\\\\u003c") +
";w.TURBOPACK_CHUNK_BASE_PATH=" + JSON.stringify(mount + "/_next/") +
";(" + installStalwartFetchShim.toString() + ")(w," + JSON.stringify(mount) + "," + JSON.stringify(BUILD_ID) + "," + rewriteFlightForMount.toString() + ");})(window)</scr" + "ipt>";
fetch(mount + "/" + boot.shell + "?v=" + encodeURIComponent(BUILD_ID), { credentials: "same-origin" })
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
return res.text();
})
.then(function (html) {
// A shell missing from the zip is answered with this very document.
if (html.indexOf(ENTRY_MARKER) !== -1) throw new Error(boot.shell + " is not part of this bundle");
var page = rewriteShellForMount(html, mount, BUILD_ID, bootScript);
document.open();
document.write(page);
document.close();
})
.catch(function (err) {
fail("Bulwark Webmail could not start (" + (err && err.message ? err.message : err) + "). Reload the page; if this keeps happening, the installed bundle may be incomplete.");
});
})();
`;
return `<!doctype html>
<html lang="en" ${STALWART_ENTRY_MARKER}="">
<head>
<meta charset="utf-8" />
${STALWART_BASE_HREF_LITERAL} />
${STALWART_OAUTH_META_LITERAL} />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="robots" content="noindex" />
<title>${esc(appName)}</title>
<style>
html, body { margin: 0; min-height: 100vh; background: #fff; color: #111; font-family: system-ui, sans-serif; }
html.dark, html.dark body { background: #0a0a0a; color: #ededed; }
@media (prefers-color-scheme: dark) { html:not(.light), html:not(.light) body { background: #0a0a0a; color: #ededed; } }
#bulwark-lite-boot { display: flex; min-height: 100vh; align-items: center; justify-content: center; padding: 1rem; box-sizing: border-box; text-align: center; }
#bulwark-lite-boot p { max-width: 36rem; opacity: .8; line-height: 1.5; }
</style>
<script>${script}</script>
</head>
<body>
<div id="bulwark-lite-boot" aria-busy="true"><noscript><p>Bulwark Webmail needs JavaScript.</p></noscript></div>
</body>
</html>
`;
}
/** manifest.json with relative URLs: they resolve against the manifest's own (mounted) URL. */
export function buildStalwartManifest({ appName }) {
return {
name: appName,
short_name: appName,
description: `${appName} - webmail for Stalwart`,
start_url: "./",
scope: "./",
display: "standalone",
background_color: "#ffffff",
theme_color: "#ffffff",
icons: [
{ src: "icon-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "icon-512x512.png", sizes: "512x512", type: "image/png" },
{ src: "icon-maskable-light-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: "icon-maskable-light-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
};
}
/** Problems with the root index.html of a Stalwart bundle (empty = fine). */
export function stalwartEntryProblems(html) {
const problems = [];
const count = (needle) => html.split(needle).length - 1;
if (count(STALWART_BASE_HREF_LITERAL) !== 1) problems.push(`index.html must contain ${STALWART_BASE_HREF_LITERAL} exactly once (found ${count(STALWART_BASE_HREF_LITERAL)})`);
if (count(STALWART_OAUTH_META_LITERAL) !== 1) problems.push(`index.html must contain ${STALWART_OAUTH_META_LITERAL} exactly once (found ${count(STALWART_OAUTH_META_LITERAL)})`);
if (!html.includes(STALWART_ENTRY_MARKER)) problems.push("index.html is not the Stalwart entry document");
// Root-absolute URLs would escape the mount prefix Stalwart put into <base>.
for (const attr of html.replace(STALWART_BASE_HREF_LITERAL, "").match(/\b(?:src|href)="\/(?!\/)[^"]*"/g) ?? []) {
problems.push(`index.html has a root-absolute ${attr}; it must follow the runtime mount`);
}
for (const absolute of ['"/_next/static', '"/config.json', '"/policy.json']) {
if (html.includes(absolute)) problems.push(`index.html references ${absolute.slice(1)} absolutely; it must follow the runtime mount`);
}
return problems;
}
/** Problems with the entry list of a Stalwart zip (empty = fine). */
export function stalwartZipEntryProblems(names) {
const problems = [];
if (!names.includes("index.html")) problems.push("index.html is not at the archive root (no wrapping folder allowed)");
if (names.length >= 0xffff) problems.push(`${names.length} entries need ZIP64, which this writer does not produce`);
const reserved = new Set();
for (const name of names) {
const first = name.split("/")[0];
if (isReservedStalwartSegment(first)) reserved.add(first);
if (STALWART_EXCLUDED_FILES.includes(name) || (name.includes("/") && STALWART_EXCLUDED_DIRS.includes(first))) {
problems.push(`${name} belongs to the static-host target and must not ship in the Stalwart bundle`);
}
if (name.includes("\\") || name.startsWith("/") || name.split("/").includes("..")) problems.push(`unsafe entry name ${JSON.stringify(name)}`);
}
for (const first of reserved) problems.push(`top-level entry ${first} shares its name with a Stalwart route; keep top-level names app-specific`);
return problems;
}
// --- zip ---------------------------------------------------------------------
function dosDateTime(date) {
const d = new Date(date);
const year = Math.max(1980, d.getFullYear());
return {
time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
day: ((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
};
}
/**
* Minimal zip writer (no dependencies, no ZIP64): deflate when it helps,
* store otherwise, UTF-8 names, entries in the given order, a fixed
* timestamp so identical inputs give identical archives.
*/
export function buildZip(entries, { date = new Date(2000, 0, 1) } = {}) {
const { time, day } = dosDateTime(date);
const locals = [];
const centrals = [];
let offset = 0;
for (const { name, data } of entries) {
const nameBuf = Buffer.from(name, "utf8");
const raw = Buffer.isBuffer(data) ? data : Buffer.from(data);
const deflated = deflateRawSync(raw, { level: 9 });
const useDeflate = deflated.length < raw.length;
const body = useDeflate ? deflated : raw;
const crc = crc32(raw) >>> 0;
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(useDeflate ? 8 : 0, 8);
local.writeUInt16LE(time, 10);
local.writeUInt16LE(day, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(body.length, 18);
local.writeUInt32LE(raw.length, 22);
local.writeUInt16LE(nameBuf.length, 26);
local.writeUInt16LE(0, 28);
locals.push(local, nameBuf, body);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE((3 << 8) | 20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(useDeflate ? 8 : 0, 10);
central.writeUInt16LE(time, 12);
central.writeUInt16LE(day, 14);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(body.length, 20);
central.writeUInt32LE(raw.length, 24);
central.writeUInt16LE(nameBuf.length, 28);
central.writeUInt32LE((0o100644 << 16) >>> 0, 38);
central.writeUInt32LE(offset, 42);
centrals.push(central, nameBuf);
offset += 30 + nameBuf.length + body.length;
}
const centralDir = Buffer.concat(centrals);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralDir.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...locals, centralDir, end]);
}
/** Reads a zip's central directory: `[{ name, method, compressedSize, size, offset }]`. */
export function listZipEntries(buffer) {
let eocd = -1;
for (let i = buffer.length - 22; i >= Math.max(0, buffer.length - 22 - 0xffff); i--) {
if (buffer.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
}
if (eocd === -1) throw new Error("not a zip archive (no end-of-central-directory record)");
const count = buffer.readUInt16LE(eocd + 10);
let at = buffer.readUInt32LE(eocd + 16);
const entries = [];
for (let n = 0; n < count; n++) {
if (buffer.readUInt32LE(at) !== 0x02014b50) throw new Error("corrupt central directory");
const nameLen = buffer.readUInt16LE(at + 28);
const extraLen = buffer.readUInt16LE(at + 30);
const commentLen = buffer.readUInt16LE(at + 32);
entries.push({
name: buffer.toString("utf8", at + 46, at + 46 + nameLen),
method: buffer.readUInt16LE(at + 10),
compressedSize: buffer.readUInt32LE(at + 20),
size: buffer.readUInt32LE(at + 24),
offset: buffer.readUInt32LE(at + 42),
});
at += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
/** Extracts one entry listed by `listZipEntries`. */
export function readZipEntry(buffer, entry) {
const nameLen = buffer.readUInt16LE(entry.offset + 26);
const extraLen = buffer.readUInt16LE(entry.offset + 28);
const start = entry.offset + 30 + nameLen + extraLen;
const body = buffer.subarray(start, start + entry.compressedSize);
if (entry.method === 0) return Buffer.from(body);
if (entry.method === 8) return inflateRawSync(body);
throw new Error(`unsupported compression method ${entry.method} for ${entry.name}`);
}
/** Every file below `dir` as a zip entry, `/`-separated and sorted. */
export function collectZipEntries(dir) {
const out = [];
const walk = (full, rel) => {
for (const entry of readdirSync(full).sort()) {
const path = join(full, entry);
const name = rel ? `${rel}/${entry}` : entry;
if (statSync(path).isDirectory()) walk(path, name);
else out.push({ name, data: readFileSync(path) });
}
};
walk(dir, "");
return out;
}
/** LITE-README.md for the Stalwart bundle. */
export function buildStalwartReadme({ version, commit, locales, buildId, demoMode = false }) {
const url = `https://github.com/bulwarkmail/webmail/releases/latest/download/${STALWART_ZIP_NAME}`;
return `# Bulwark Lite ${version} (${commit}) for Stalwart
Bulwark Webmail as a Stalwart \`Application\`: Stalwart downloads this zip,
serves it under a path of its own HTTP listener, and the webmail talks to the
same server's JMAP, login and token endpoints. There is nothing to configure:
no server URL, no CORS, no second web server.
Build: ${buildId}
Locales: ${locales.join(", ")}
Requires: Stalwart 0.16.0 or later (0.16.19 or later to use \`oauthClientId\`).
${demoMode ? "\nDemo mode is ON in this build: the login page offers the built-in demo account.\n" : ""}
## Install
An administrator creates one Application and then runs the \`UpdateApps\`
action: Stalwart only downloads and mounts Applications on that action or at
startup, so creating the object alone serves nothing. Permissions:
\`sysApplicationCreate\` (plus \`sysApplicationGet\`/\`sysApplicationQuery\` to
list, \`sysApplicationUpdate\` to change and \`sysApplicationDestroy\` to remove
it) and \`actionUpdateApps\`. Any of these is equivalent:
- Web admin: **Settings > Web Applications > Create**, description
\`Bulwark Webmail\`, resource URL \`${url}\`, URL prefix \`/webmail\`; then run
the update-applications action (or restart Stalwart).
- CLI:
\`\`\`bash
stalwart-cli create Application \\
--field description='Bulwark Webmail' \\
--field resourceUrl='${url}' \\
--field 'urlPrefix={"/webmail":true}'
stalwart-cli create Action/UpdateApps
\`\`\`
- JMAP (\`urn:stalwart:jmap\`), as an administrator:
\`\`\`json
{
"using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"],
"methodCalls": [
["x:Application/set", { "create": { "webmail": {
"description": "Bulwark Webmail",
"resourceUrl": "${url}",
"urlPrefix": { "/webmail": true }
} } }, "0"],
["x:Action/set", { "create": { "u": { "@type": "UpdateApps" } } }, "1"]
]
}
\`\`\`
Then open \`https://<your stalwart host>/webmail/\` and sign in with a mail
account.
## Mount prefix
\`urlPrefix\` is matched against the first path segment only (\`/webmail\`,
not \`/apps/webmail\`), and several prefixes may serve the same bundle. The
bundle works under any prefix: it reads it from the \`<base href>\` Stalwart
writes into index.html. Stalwart routes these first segments itself, so an
Application mounted there is never reached: \`jmap\`, \`dav\`, \`.well-known\`,
\`auth\`, \`api\`, \`scim\`, \`mail\`, \`calendar\`, \`autodiscover\`, \`login\`,
\`device\`, \`metrics\`, \`healthz\`, \`logo\`, \`form\`, \`robots.txt\`, and the web
admin's own \`admin\` and \`account\`.
## Updates
Stalwart keeps the downloaded zip for \`autoUpdateFrequency\` (default 90
days) and downloads \`resourceUrl\` again at the first restart or
\`UpdateApps\` after that, so the \`latest/download\` URL above follows new
releases on that schedule. \`UpdateApps\` always downloads again: run it to
update right away. A failed download keeps the previous bundle online.
Open tabs move to the new build on their next navigation (one full page
load); a freshly opened tab gets it immediately. Nothing stays stuck on old
files although Stalwart sends a year-long cache header for every file but
index.html: every file the app fetches without a content hash carries the
build id.
To pin a version, point \`resourceUrl\` at a tagged release instead:
\`https://github.com/bulwarkmail/webmail/releases/download/v${version}/${STALWART_ZIP_NAME}\`.
## Sign-in
Passwords (and TOTP codes) go to Stalwart's \`/api/auth\` and are exchanged at
\`/auth/token\` for an access token and a refresh token, all on the same
origin. "Remember me" keeps the refresh token in localStorage, otherwise it
lives with the tab.
The OAuth client id is the Application's \`oauthClientId\` (Stalwart writes it
into index.html) or \`bulwark-webmail\` when that field is empty. If your
server requires registered OAuth clients, register that client id with the
redirect URI \`https://<your stalwart host>/<prefix>/\` (for example
\`https://mail.example.com/webmail/\`), once per prefix you mount.
## Branding and settings
The bundle is read-only once Stalwart has unpacked it. To change the app
name, turn "remember me" off or build any other variant, run the
**Build Static Lite** workflow (or \`npm run build:lite -- --target=stalwart\`)
with your settings, publish the resulting \`${STALWART_ZIP_NAME}\` at a URL
you control, and use that URL as \`resourceUrl\`. \`config.json\` and
\`policy.json\` inside the zip take the same keys as in the static-host build.
## Differences from the static-host zip
- One entry document (\`index.html\`) boots every route: Stalwart has no
rewrite rules and answers every unknown path with it.
- No \`_redirects\`, \`_headers\`, nginx/Caddy examples or \`404.html\`.
- \`config.json\` defaults to the serving Stalwart and hides the server field;
no CORS settings are needed.
- The mount prefix is detected at runtime instead of being baked in.
- \`manifest.json\` replaces \`manifest.webmanifest\` (Stalwart would serve the
latter as \`application/octet-stream\`).
Everything the static-host build leaves out (admin console, plugins,
settings sync, OAuth/SSO via an external identity provider, web push, office
editing, ICS subscriptions) is also missing here.
`;
}
/** Stalwart's escaping of the client id for the meta attribute (application.rs). */
export function stalwartOauthMeta(clientId) {
const escaped = String(clientId).replace(/[&"<>]/g, (ch) => ({ "&": "&amp;", '"': "&quot;", "<": "&lt;", ">": "&gt;" })[ch]);
return `<meta name="oauth-client-id" content="${escaped}"`;
}
/**
* Stalwart's rewrite of the root index.html (0.16.19+: first occurrence of
* each literal; 0.16.0-0.16.18 replaced every `<base href="/"` and had no
* client id support - `legacy: true` emulates that).
*/
export function stalwartRewriteIndex(html, prefix, oauthClientId, { legacy = false } = {}) {
const base = `<base href="/${prefix}/"`;
if (legacy) return html.split(STALWART_BASE_HREF_LITERAL).join(base);
let out = html.replace(STALWART_BASE_HREF_LITERAL, () => base);
if (oauthClientId) out = out.replace(STALWART_OAUTH_META_LITERAL, () => stalwartOauthMeta(oauthClientId));
return out;
}
/**
* One request as Stalwart's HTTP listener answers it for Applications
* (the `external =>` arm of crates/http/src/request.rs plus
* WebApplications::serve). `entries` maps zip entry names to contents,
* `prefixes` are the mounted urlPrefix values.
*
* @param {{ pathname: string, entries: Map<string, Buffer>, prefixes: string[], oauthClientId?: string, legacy?: boolean }} request
* @returns {{ status: number, headers: Record<string, string>, body: Buffer }}
*/
export function emulateStalwartRequest({ pathname, entries, prefixes, oauthClientId = "", legacy = false }) {
const mounted = prefixes.map((p) => String(p).replace(/^\/+|\/+$/g, ""));
const segments = pathname.split("/");
const first = segments[1] ?? "";
const notFound = { status: 404, headers: { "content-type": "application/problem+json" }, body: Buffer.from('{"type":"about:blank","status":404,"title":"Not Found"}') };
if (!first || isReservedStalwartSegment(first) || !mounted.includes(first)) return notFound;
// HttpResponse::redirect answers 302 (verified against 0.16.22).
if (segments.length === 2) return { status: 302, headers: { location: `/${first}/` }, body: Buffer.alloc(0) };
const path = pathname.slice(first.length + 2);
const direct = entries.get(path);
const isIndex = direct ? path === "index.html" : true;
const name = direct ? path : "index.html";
const contents = direct ?? entries.get("index.html");
if (!contents) return notFound;
const body = isIndex ? Buffer.from(stalwartRewriteIndex(contents.toString("utf8"), first, oauthClientId, { legacy })) : contents;
return {
status: 200,
headers: {
"content-type": stalwartContentType(name),
"cache-control": isIndex ? "no-cache" : "public, max-age=31536000, immutable",
},
body,
};
}
/**
* RSC text rows (`<id>:T<hex length>,...`) that contain a string the runtime
* mount rewrite replaces. Those rows are length-prefixed, so
* rewriteFlightForMount leaves them alone; a hit here would mean a URL that
* is never pointed at the mount. Returns the offending row ids.
*/
export function flightTextRowsWithNeedles(text) {
const needles = ['"/_next/', '"/manifest.webmanifest"', '"/branding/'];
const hits = [];
const buffer = Buffer.from(text, "utf8");
const pattern = /(?:^|\n)([0-9a-f]+):T([0-9a-f]+),/g;
let match;
while ((match = pattern.exec(text))) {
const start = Buffer.byteLength(text.slice(0, match.index + match[0].length), "utf8");
const body = buffer.subarray(start, start + parseInt(match[2], 16)).toString("utf8");
if (needles.some((needle) => body.includes(needle))) hits.push(match[1]);
}
return hits;
}
/**
* Next 16.3 exports segment-prefetch payloads as nested directories
* (`<route>/__next.<a>/<b>/__PAGE__.txt`) but requests them with dots
* (`<route>/__next.<a>.<b>.__PAGE__.txt`). On Stalwart the dotted request is
* answered with the entry index.html, which the router ignores before
* fetching `index.txt`, so the nested trees are never read: the Stalwart
* target drops them (about 40% of the zip). `__next._tree.txt` and
* `__next._full.txt` are plain files and stay.
*/
export function findSegmentPrefetchDirs(outDir) {
const found = [];
const walk = (dir) => {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (!statSync(full).isDirectory()) continue;
if (entry.startsWith("__next.")) found.push(full);
else if (entry !== "_next") walk(full);
}
};
if (existsSync(outDir)) walk(outDir);
return found;
}
+56 -11
View File
@@ -2,10 +2,17 @@
// 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";
//
// The Stalwart target (`LITE_TARGET=stalwart`) writes a different set: the
// entry document Stalwart serves for every route, config/policy/manifest that
// follow the runtime mount, no host snippets, no 404.html - and packs out/
// into bulwark-lite-stalwart.zip.
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
buildCaddyExample, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, buildNotFoundShim, buildReadme, buildRedirects, buildRootRedirect, discoverBuiltLocales, isMainModule, normalizeBasePath, resolveRepoRoot,
STALWART_EXCLUDED_DIRS, STALWART_EXCLUDED_FILES, STALWART_ZIP_NAME,
buildCaddyExample, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, buildNotFoundShim, buildReadme, buildRedirects, buildRootRedirect,
buildStalwartEntry, buildStalwartManifest, buildStalwartReadme, buildZip, collectZipEntries, discoverBuiltLocales, discoverShellDirs, findSegmentPrefetchDirs, isMainModule, makeBuildId, normalizeBasePath, parseLiteTarget, resolveRepoRoot,
} from "./lib.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
@@ -14,7 +21,9 @@ export function runPostbuild({ root = repoRoot, env = process.env, log = console
const outDir = join(root, "out");
if (!existsSync(outDir)) throw new Error(`[lite] ${outDir} does not exist - run the export first`);
const target = parseLiteTarget([], env);
const basePath = normalizeBasePath(env.NEXT_PUBLIC_BASE_PATH);
if (target === "stalwart" && basePath) throw new Error("[lite] the Stalwart target learns its mount prefix at runtime; unset 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";
@@ -26,11 +35,33 @@ export function runPostbuild({ root = repoRoot, env = process.env, log = console
// no VERSION file
}
const commit = (env.GIT_COMMIT ?? "").trim().slice(0, 7) || "local";
const buildId = (env.NEXT_PUBLIC_LITE_BUILD_ID ?? "").trim() || makeBuildId({ version, commit });
const config = buildLiteConfig(env);
const config = buildLiteConfig(env, { target });
const files = target === "stalwart"
? stalwartFiles({ outDir, config, locales, defaultLocale, version, commit, buildId })
: staticFiles({ config, basePath, locales, defaultLocale, version, commit });
files["lite-build.json"] = JSON.stringify({ version, commit, target, buildId, 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/ (${target} target, ${locales.length} locales, base path ${target === "stalwart" ? "runtime" : basePath || "/"})`);
let zipPath = null;
if (target === "stalwart") {
zipPath = join(root, STALWART_ZIP_NAME);
const zip = buildZip(collectZipEntries(outDir));
writeFileSync(zipPath, zip);
log(`[lite] packed ${STALWART_ZIP_NAME}: ${(zip.length / 1024 / 1024).toFixed(1)} MB`);
}
return { outDir, locales, basePath, target, buildId, zipPath, files: Object.keys(files) };
}
function staticFiles({ config, basePath, locales, defaultLocale, version, commit }) {
const connectSrc = config.allowCustomJmapEndpoint || !config.jmapServerUrl ? "*" : new URL(config.jmapServerUrl).origin;
const files = {
return {
"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",
@@ -43,15 +74,29 @@ export function runPostbuild({ root = repoRoot, env = process.env, log = console
"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);
function stalwartFiles({ outDir, config, locales, defaultLocale, version, commit, buildId }) {
// Next's default not-found page and the static-host helpers have no place
// in a bundle Stalwart serves: unknown paths always get the entry document.
for (const name of STALWART_EXCLUDED_FILES) rmSync(join(outDir, name), { force: true });
for (const name of STALWART_EXCLUDED_DIRS) rmSync(join(outDir, name), { recursive: true, force: true });
for (const dir of findSegmentPrefetchDirs(outDir)) rmSync(dir, { recursive: true, force: true });
// Stalwart serves .mjs as application/octet-stream, which browsers refuse
// for module workers (pdf.js); lib/lite.ts liteMountedAssetUrl asks for these.
for (const { name, data } of collectZipEntries(join(outDir, "_next"))) {
if (name.endsWith(".mjs")) writeFileSync(join(outDir, "_next", `${name.slice(0, -4)}.js`), data);
}
log(`[lite] wrote ${Object.keys(files).length} files into out/ (${locales.length} locales, base path ${basePath || "/"})`);
return { outDir, locales, basePath, files: Object.keys(files) };
const shellLocale = locales.includes(defaultLocale) ? defaultLocale : locales[0];
const shells = discoverShellDirs(outDir, shellLocale);
return {
"index.html": buildStalwartEntry({ locales, shells, defaultLocale, buildId, appName: config.appName }),
"config.json": JSON.stringify(config, null, 2) + "\n",
"policy.json": JSON.stringify(buildLitePolicy(), null, 2) + "\n",
"manifest.json": JSON.stringify(buildStalwartManifest({ appName: config.appName }), null, 2) + "\n",
"LITE-README.md": buildStalwartReadme({ version, commit, locales, buildId, demoMode: config.demoMode }),
};
}
if (isMainModule(import.meta.url)) {
+83 -13
View File
@@ -8,14 +8,32 @@
// `--plain` switches the SPA fallback off and answers unknown paths with
// 404.html (status 404), the way GitHub Pages or an S3 bucket would; that is
// the path the 404 shim's park-and-replay exists for.
import { createServer } from "node:http";
import { createReadStream, existsSync, statSync } from "node:fs";
//
// `--stalwart=/webmail[,/other]` emulates a Stalwart Application instead
// (emulateStalwartRequest in lib.mjs): exact entry lookup, the root
// index.html for everything else with <base href> and the OAuth meta tag
// rewritten, no-cache vs immutable, Stalwart's extension -> MIME map, its
// reserved first segments. The source may be out/ or the zip itself:
//
// node scripts/lite/serve.mjs bulwark-lite-stalwart.zip 4180 --stalwart=/webmail,/bulwark
// [--oauth-client-id=my-client] [--legacy] [--upstream=http://127.0.0.1:18081]
//
// `--upstream` forwards Stalwart's own routes (/jmap, /api, /auth,
// /.well-known, ...) to a real server, so the app stays same-origin with it.
import { createServer, request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
import { extname, join, normalize, resolve } from "node:path";
import { LITE_SURFACES } from "./lib.mjs";
import { LITE_SURFACES, collectZipEntries, emulateStalwartRequest, isReservedStalwartSegment, listZipEntries, readZipEntry } from "./lib.mjs";
const flags = process.argv.slice(2).filter((a) => a.startsWith("--"));
const args = process.argv.slice(2).filter((a) => !a.startsWith("--"));
const plain = flags.includes("--plain");
const flag = (name) => {
const hit = flags.find((f) => f === `--${name}` || f.startsWith(`--${name}=`));
return hit === undefined ? undefined : hit.includes("=") ? hit.slice(hit.indexOf("=") + 1) : "";
};
const plain = flag("plain") !== undefined;
const stalwartPrefixes = flag("stalwart");
const root = resolve(args[0] || "out");
const port = Number(args[1] || 4173);
const basePath = (args[2] || "").replace(/\/+$/, "");
@@ -71,12 +89,64 @@ export function resolveRequest(pathname) {
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}/${plain ? " (plain: no SPA fallback, 404.html for unknown paths)" : ""}`);
});
/** Zip entries (or the files of an unpacked out/) by entry name. */
function loadEntries(source) {
if (source.endsWith(".zip")) {
const buffer = readFileSync(source);
return new Map(listZipEntries(buffer).map((entry) => [entry.name, readZipEntry(buffer, entry)]));
}
return new Map(collectZipEntries(source).map(({ name, data }) => [name, data]));
}
function proxy(req, res, upstream) {
const target = new URL(req.url ?? "/", upstream);
const send = target.protocol === "https:" ? httpsRequest : httpRequest;
const headers = { ...req.headers, host: target.host };
const out = send(target, { method: req.method, headers }, (up) => {
res.writeHead(up.statusCode ?? 502, up.headers);
up.pipe(res);
});
out.on("error", (err) => {
res.writeHead(502, { "content-type": "text/plain" });
res.end(`upstream error: ${err.message}`);
});
req.pipe(out);
}
if (stalwartPrefixes !== undefined) {
const prefixes = stalwartPrefixes.split(",").map((p) => p.trim()).filter(Boolean);
if (prefixes.length === 0) throw new Error("--stalwart needs at least one prefix, e.g. --stalwart=/webmail");
const oauthClientId = flag("oauth-client-id") ?? "";
const legacy = flag("legacy") !== undefined;
const upstream = flag("upstream");
let entries = loadEntries(root);
let loadedAt = Date.now();
createServer((req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
// Reload when the source changes, like Stalwart picking up a new bundle.
try {
if (statSync(root).mtimeMs > loadedAt) {
entries = loadEntries(root);
loadedAt = Date.now();
}
} catch {
// keep the previous bundle, as Stalwart does on a failed refresh
}
if (upstream && isReservedStalwartSegment(url.pathname.split("/")[1] ?? "")) return proxy(req, res, upstream);
const { status, headers, body } = emulateStalwartRequest({ pathname: url.pathname, entries, prefixes, oauthClientId, legacy });
res.writeHead(status, headers);
res.end(req.method === "HEAD" ? undefined : body);
}).listen(port, () => {
console.log(`[lite] Stalwart emulator: ${root} (${entries.size} entries) at ${prefixes.map((p) => `http://localhost:${port}${p.replace(/\/+$/, "")}/`).join(", ")}${upstream ? `, upstream ${upstream}` : ""}${legacy ? " (0.16.0-0.16.18 rewrite)" : ""}`);
});
} else {
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}/${plain ? " (plain: no SPA fallback, 404.html for unknown paths)" : ""}`);
});
}
+115
View File
@@ -0,0 +1,115 @@
import { test, expect, type Page } from "@playwright/test";
import { stalwartClientId, stalwartOrigin, stalwartPrefixes } from "./playwright.stalwart.config";
/**
* Drives the Stalwart Application bundle (demo build) through the Stalwart
* emulator in scripts/lite/serve.mjs. Real-server verification is recorded
* in the PR; this keeps the serving contract from regressing.
*
* What it proves, under two different prefixes of the same zip:
* - /<prefix> redirects, /<prefix>/ boots without a redirect loop and ends
* on the login page of a real locale,
* - Stalwart's rewrites reach the app (mount prefix, OAuth client id),
* - the demo account signs in and surface switches stay client-side
* (no document load), although Stalwart labels RSC payloads
* application/octet-stream,
* - deep links and unknown paths survive a full reload,
* - nothing is requested outside the prefix, no chunk is answered with
* the index.html fallback, and the console stays clean.
*/
interface Watch {
outside: string[];
htmlAssets: string[];
documents: string[];
errors: string[];
}
function watch(page: Page, prefix: string): Watch {
const seen: Watch = { outside: [], htmlAssets: [], documents: [], errors: [] };
page.on("request", (req) => {
const url = new URL(req.url());
if (url.origin !== stalwartOrigin) return;
if (!url.pathname.startsWith(`${prefix}/`)) seen.outside.push(url.pathname);
if (req.isNavigationRequest() && req.frame() === page.mainFrame()) seen.documents.push(url.pathname);
});
page.on("response", (res) => {
const url = new URL(res.url());
if (url.pathname.includes("/_next/") && (res.headers()["content-type"] ?? "").startsWith("text/html")) seen.htmlAssets.push(url.pathname);
});
page.on("console", (msg) => {
if (msg.type() === "error" || /hydrat/i.test(msg.text())) seen.errors.push(msg.text());
});
page.on("pageerror", (err) => seen.errors.push(err.message));
return seen;
}
for (const prefix of stalwartPrefixes) {
test.describe(`Stalwart bundle mounted at ${prefix}`, () => {
test("serves the entry for every route with both rewrites applied", async ({ request }) => {
const bare = await request.get(prefix, { maxRedirects: 0 });
expect(bare.status()).toBe(302);
expect(bare.headers().location).toBe(`${prefix}/`);
for (const path of [`${prefix}/`, `${prefix}/en/mail/thread/abc`, `${prefix}/nope`]) {
const res = await request.get(path);
expect(res.headers()["cache-control"]).toBe("no-cache");
const html = await res.text();
expect(html).toContain(`<base href="${prefix}/" />`);
expect(html).toContain(`<meta name="oauth-client-id" content="${stalwartClientId}" />`);
}
const shell = await request.get(`${prefix}/en/mail/index.html`);
expect(shell.headers()["cache-control"]).toContain("immutable");
const rsc = await request.get(`${prefix}/en/mail/index.txt`);
expect(rsc.headers()["content-type"]).toBe("application/octet-stream");
});
test("boots, signs in, switches surfaces client-side and survives reloads", async ({ page }) => {
const seen = watch(page, prefix);
await page.goto(`${prefix}/`);
await page.waitForURL(new RegExp(`${prefix}/en/login$`), { timeout: 30_000 });
expect(await page.evaluate(() => (window as unknown as Record<string, unknown>).__BULWARK_LITE_MOUNT__)).toBe(prefix);
expect(await page.evaluate(() => (window as unknown as Record<string, unknown>).__BULWARK_LITE_OAUTH_CLIENT_ID__)).toBe(stalwartClientId);
const demo = page.getByRole("button", { name: /demo/i }).first();
await expect(demo).toBeVisible({ timeout: 30_000 });
await demo.click();
await page.waitForURL(new RegExp(`${prefix}/en/(mail/?.*)?$`), { timeout: 30_000 });
await expect(page.locator("body")).toContainText(/inbox/i, { timeout: 30_000 });
seen.documents.length = 0;
for (const [name, url] of [["calendar", /\/calendar/], ["contacts", /\/contacts/], ["settings", /\/settings/]] as const) {
await page.getByRole("link", { name: new RegExp(`^${name}`, "i") }).first().click();
await page.waitForURL(url, { timeout: 30_000 });
expect(new URL(page.url()).pathname.startsWith(`${prefix}/en/`)).toBe(true);
}
// Client-side: the RSC payloads were accepted despite octet-stream.
expect(seen.documents).toEqual([]);
await page.goto(`${prefix}/en/calendar/week/2026-09-17`);
await expect(page.locator("body")).toContainText(/September 2026/, { timeout: 30_000 });
expect(new URL(page.url()).pathname).toMatch(new RegExp(`^${prefix}/en/calendar/\\w+/2026-09-17$`));
await page.goto(`${prefix}/nope/at/all`);
await page.waitForURL(new RegExp(`${prefix}/en/(mail/.*)?$`), { timeout: 30_000 });
expect(seen.outside).toEqual([]);
expect(seen.htmlAssets).toEqual([]);
expect(seen.errors).toEqual([]);
});
});
}
test("a prefix the bundle was never told about still works (no build-time base path)", async ({ page }) => {
// Both prefixes above come from the same zip; this pins that the entry did
// not fall back to a baked path by checking every chunk URL it used.
const chunkPrefixes = new Set<string>();
page.on("request", (req) => {
const url = new URL(req.url());
if (url.pathname.includes("/_next/static/")) chunkPrefixes.add(url.pathname.slice(0, url.pathname.indexOf("/_next/")));
});
await page.goto("/bulwark/en/login");
await expect(page.getByRole("button", { name: /demo/i }).first()).toBeVisible({ timeout: 30_000 });
expect([...chunkPrefixes]).toEqual(["/bulwark"]);
});
+2 -1
View File
@@ -10,7 +10,8 @@ export const plainPort = Number(process.env.LITE_SMOKE_PLAIN_PORT || port + 1);
export default defineConfig({
testDir: ".",
testMatch: /.*\.spec\.ts$/,
// lite-stalwart.spec.ts needs the Stalwart build (playwright.stalwart.config.ts).
testMatch: /lite-demo\.spec\.ts$/,
timeout: 60_000,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list",
@@ -0,0 +1,35 @@
import { defineConfig } from "@playwright/test";
// Smoke test for the Stalwart Application bundle (`npm run build:lite --
// --target=stalwart` with LITE_DEMO_MODE=true). serve.mjs --stalwart emulates
// how Stalwart serves an Application (exact zip-entry lookup, the root
// index.html for everything else with <base href> and the OAuth meta tag
// rewritten, immutable vs no-cache, Stalwart's MIME map) from the zip itself,
// mounted at two prefixes to prove the bundle does not care which.
const port = Number(process.env.LITE_STALWART_SMOKE_PORT || 4183);
export const stalwartOrigin = `http://localhost:${port}`;
export const stalwartPrefixes = ["/webmail", "/bulwark"];
export const stalwartClientId = "smoke-client";
export default defineConfig({
testDir: ".",
testMatch: /lite-stalwart\.spec\.ts$/,
timeout: 90_000,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list",
use: {
baseURL: stalwartOrigin,
locale: "en-US",
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
projects: [{ name: "chromium", use: { browserName: "chromium" } }],
webServer: [
{
command: `node scripts/lite/serve.mjs bulwark-lite-stalwart.zip ${port} --stalwart=${stalwartPrefixes.join(",")} --oauth-client-id=${stalwartClientId}`,
port,
reuseExistingServer: !process.env.CI,
cwd: process.cwd(),
},
],
});
+67 -12
View File
@@ -2,17 +2,36 @@
// 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, readFileSync } from "node:fs";
//
// The Stalwart target additionally checks the entry document (both literals
// Stalwart rewrites, exactly once; nothing root-absolute), the zip layout and
// its size against Stalwart's 100 MB limit.
import { existsSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import {
LITE_PENDING_PATH_KEY, collectApiStrings, discoverBuiltLocales, isMainModule, resolveRepoRoot, unexpectedApiStrings,
LITE_PENDING_PATH_KEY, STALWART_EXCLUDED_FILES, STALWART_MAX_BUNDLE_BYTES, STALWART_SAFE_BUNDLE_BYTES, STALWART_ZIP_NAME,
collectApiStrings, collectZipEntries, discoverBuiltLocales, flightTextRowsWithNeedles, isMainModule, listZipEntries, parseLiteTarget, resolveRepoRoot, stalwartEntryProblems, stalwartZipEntryProblems, unexpectedApiStrings,
} from "./lib.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
export function verifyExport({ root = repoRoot } = {}) {
/** The target recorded by postbuild, so `node verify.mjs` needs no flags. */
function recordedTarget(outDir) {
try {
return JSON.parse(readFileSync(join(outDir, "lite-build.json"), "utf8")).target ?? "static";
} catch {
return "static";
}
}
/**
* @param {{ root?: string, target?: "static" | "stalwart", safeBundleBytes?: number }} [options]
* `target` defaults to the one postbuild recorded in out/lite-build.json.
*/
export function verifyExport({ root = repoRoot, target, safeBundleBytes = STALWART_SAFE_BUNDLE_BYTES } = {}) {
const outDir = join(root, "out");
const problems = [];
const resolvedTarget = target ?? recordedTarget(outDir);
const locales = discoverBuiltLocales(outDir);
if (locales.length === 0) problems.push("no locale shells found (out/<locale>/mail/index.html)");
@@ -24,33 +43,69 @@ export function verifyExport({ root = repoRoot } = {}) {
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"]) {
const required = resolvedTarget === "stalwart"
? ["index.html", "config.json", "policy.json", "manifest.json", "LITE-README.md", "lite-build.json"]
: ["index.html", "404.html", "config.json", "policy.json", "manifest.webmanifest", "_redirects", "_headers", "LITE-README.md", "lite-build.json"];
for (const file of required) {
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}`);
}
// Next writes its own default not-found page as 404.html; postbuild must
// have replaced it with the shim that parks and replays deep links.
const notFound = join(outDir, "404.html");
if (existsSync(notFound) && !readFileSync(notFound, "utf8").includes(LITE_PENDING_PATH_KEY)) {
problems.push("out/404.html is not the Lite shim (deep links on hosts without rewrites would dead-end); run postbuild");
if (resolvedTarget !== "stalwart") {
// Next writes its own default not-found page as 404.html; postbuild must
// have replaced it with the shim that parks and replays deep links.
const notFound = join(outDir, "404.html");
if (existsSync(notFound) && !readFileSync(notFound, "utf8").includes(LITE_PENDING_PATH_KEY)) {
problems.push("out/404.html is not the Lite shim (deep links on hosts without rewrites would dead-end); run postbuild");
}
}
const apiStrings = collectApiStrings(join(outDir, "_next", "static"));
const unexpected = unexpectedApiStrings(apiStrings);
for (const s of unexpected) problems.push(`client chunk references undocumented server endpoint ${s} (add an IS_LITE gate or document it in scripts/lite/lib.mjs)`);
return { problems, locales, apiStrings };
let zipBytes = 0;
if (resolvedTarget === "stalwart") {
const entry = join(outDir, "index.html");
if (existsSync(entry)) problems.push(...stalwartEntryProblems(readFileSync(entry, "utf8")).map((p) => `out/${p}`));
for (const file of STALWART_EXCLUDED_FILES) {
if (existsSync(join(outDir, file))) problems.push(`out/${file} belongs to the static-host target`);
}
// The runtime mount rewrite skips length-prefixed RSC text rows.
for (const { name, data } of existsSync(outDir) ? collectZipEntries(outDir) : []) {
if (!name.endsWith(".txt")) continue;
const rows = flightTextRowsWithNeedles(data.toString("utf8"));
if (rows.length) problems.push(`out/${name}: RSC text rows ${rows.join(", ")} hold root-absolute URLs the mount rewrite cannot reach`);
}
const zipPath = join(root, STALWART_ZIP_NAME);
if (!existsSync(zipPath)) {
problems.push(`missing ${STALWART_ZIP_NAME} (postbuild packs it)`);
} else {
zipBytes = statSync(zipPath).size;
const mb = (n) => `${(n / 1024 / 1024).toFixed(1)} MB`;
if (zipBytes > STALWART_MAX_BUNDLE_BYTES) problems.push(`${STALWART_ZIP_NAME} is ${mb(zipBytes)}; Stalwart refuses bundles above ${mb(STALWART_MAX_BUNDLE_BYTES)}`);
else if (zipBytes > safeBundleBytes) problems.push(`${STALWART_ZIP_NAME} is ${mb(zipBytes)}, above the ${mb(safeBundleBytes)} safety margin under Stalwart's ${mb(STALWART_MAX_BUNDLE_BYTES)} limit`);
try {
problems.push(...stalwartZipEntryProblems(listZipEntries(readFileSync(zipPath)).map((e) => e.name)).map((p) => `${STALWART_ZIP_NAME}: ${p}`));
} catch (err) {
problems.push(`${STALWART_ZIP_NAME} cannot be read: ${err instanceof Error ? err.message : err}`);
}
}
}
return { problems, locales, apiStrings, target: resolvedTarget, zipBytes };
}
if (isMainModule(import.meta.url)) {
const { problems, locales, apiStrings } = verifyExport();
const argv = process.argv.slice(2);
const target = argv.some((a) => a.startsWith("--target")) || process.env.LITE_TARGET ? parseLiteTarget(argv, process.env) : undefined;
const { problems, locales, apiStrings, target: checked, zipBytes } = verifyExport({ target });
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`);
console.log(`[lite] ${checked} export verified: ${locales.length} locales, ${apiStrings.length} known /api strings in chunks${zipBytes ? `, zip ${(zipBytes / 1024 / 1024).toFixed(1)} MB` : ""}`);
}
+2 -1
View File
@@ -126,7 +126,8 @@ describe('auth-store in the static Lite build', () => {
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' });
// The client the token was issued to rides along, so refreshes present the same one.
expect(readLiteRefreshToken(0)).toEqual({ serverUrl: SERVER, username: 'alice', refreshToken: 'RT-1', clientId: 'bulwark-webmail' });
expect(localStorage.getItem('bulwark-lite:refresh:0')).not.toBeNull();
// No password anywhere in web storage.
const dump = JSON.stringify({ ...localStorage, ...sessionStorage });
+10 -5
View File
@@ -19,12 +19,13 @@ 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 { IS_LITE, IS_LITE_STALWART } from '@/lib/lite';
import {
LiteLoginError,
clearAllLiteSessions,
clearLiteRefreshToken,
clearLiteSlot,
getLiteClientId,
liteRefreshTokens,
liteTokenLogin,
readLiteBasicSession,
@@ -284,7 +285,7 @@ async function exchangePasswordForTokens(params: {
// "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);
saveLiteRefreshToken(slot, { serverUrl, username, refreshToken: tokens.refreshToken, clientId: getLiteClientId() }, rememberMe);
} else {
clearLiteRefreshToken(slot);
}
@@ -864,9 +865,13 @@ export const useAuthStore = create<AuthState>()(
// The callback URL the OAuth client already registers; the route
// needs an identical redirect URI for the login + token-exchange
// steps (and registered when require_client_registration is on).
const redirectUri = typeof window !== 'undefined'
? `${window.location.origin}${getPathPrefix()}/${getLocaleFromPath()}/auth/callback`
: '';
// Lite on Stalwart uses the mount root instead: one locale-free
// URI per Application for the admin to register.
const redirectUri = typeof window === 'undefined'
? ''
: IS_LITE_STALWART
? `${window.location.origin}${getPathPrefix()}/`
: `${window.location.origin}${getPathPrefix()}/${getLocaleFromPath()}/auth/callback`;
const tokenRes = await exchangePasswordForTokens({
serverUrl, username, password, totp, slot: cookieSlot, redirectUri, rememberMe: !!rememberMe,
});
+5 -2
View File
@@ -2,7 +2,7 @@ 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 { IS_LITE, IS_LITE_STALWART, LITE_POLICY_PATH, withLiteBuildId } from '@/lib/lite';
import { applyLitePolicy } from '@/lib/lite-config';
interface PolicyState {
@@ -32,7 +32,10 @@ export const usePolicyStore = create<PolicyState>()((set, get) => ({
// the server stay pinned off either way (lib/lite-config.ts).
const fallback = IS_LITE ? { policy: applyLitePolicy({}) } : {};
try {
const res = await apiFetch(IS_LITE ? LITE_POLICY_PATH : '/api/admin/policy', IS_LITE ? { cache: 'no-store' } : undefined);
// Stalwart serves bundle files immutably: a build-id URL instead of no-store.
const res = IS_LITE_STALWART
? await apiFetch(withLiteBuildId(LITE_POLICY_PATH))
: 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: IS_LITE ? applyLitePolicy(data) : data, loaded: true });