Files
Stirling-PDF/frontend/editor/vite.config.ts
T
ConnorYohandJames Brunton 732ef18ae5 feat(account-link): redirect-based connect handshake for self-hosted linking (#7494)
Links a self-hosted instance to a SaaS team over an ordinary redirect,
and leaves the admin's browser holding a Stirling session at the same
time.

## The problem

A self-hosted server needs a device credential bound to a SaaS team, and
the admin's Supabase JWT must never reach the instance backend. Three
things ruled out the obvious approaches:

- **A customer hostname can never be in Supabase's redirect
allow-list**, so the sign-in cannot happen on the instance's own origin.
That is why SSO and sign-up did not work for linking at all.
- **A device credential identifies a server, not a person.** Every
attended portal read (Usage, Billing, Documents, Infrastructure) goes
through `getPortalSaasToken()` and needs a *user* session, so a
credential-only link left all of them asking for a second sign-in.
- **The previous design relayed a JWT** from the browser into the
instance, which is the thing we wanted to avoid. That path is deleted
here.

## The solution

Redirect and nonce, modelled on desktop's
`authService.loginWithSelfHostedOAuth`: mint a nonce, hand the browser
off, accept only a callback carrying that nonce back. Desktop has the OS
route the reply; self-hosted has no OS hop, so our own approval page
performs it. That is the point — the human half happens on an origin we
control.

```
instance                     SaaS                        admin's browser
   |  POST connect/request     |                                |
   |  (name, callback, nonce,  |                                |
   |   claim-secret hash)      |                                |
   |-------------------------->|                                |
   |  <- requestId + authorizeUrl                               |
   |                           |      GET /link?request=...     |
   |                           |<-------------------------------|
   |                           |  sign in (SSO works here),     |
   |                           |  see ACCOUNT + ORIGIN, approve |
   |                           |------------------------------->|
   |                           |   302 callback#nonce+session    |
   |  POST connect/claim       |                                |
   |  (requestId, claim secret)|                                |
   |-------------------------->|                                |
   |  <- device credential     |                                |
```

Four properties carry the safety, and each is stated in the code because
each is easy to lose in a refactor:

- **The redirect target is never caller-supplied.** Validated once at
creation, then read back from the stored row, so nothing in the approval
page's URL can steer the token elsewhere.
- **Approval and minting are separate.** Approval records the team and
hands out nothing usable; the credential is minted only on claim,
authenticated by a secret that never entered a browser.
- **A re-authentication cannot move a server between teams.** The team
is pinned at creation from the credential only that instance holds, so
an approver from another team gets `WRONG_TEAM` instead of a rebind.
- **The approver has to confirm what they are binding.** The page shows
the address and the signed-in account, with a way to switch, and a
checkbox naming the address gates the approve button. The name the
server reports is deliberately not shown: the requester picks it on an
unauthenticated endpoint, and its honest value is the hostname already
in the address.

The session rides the URL fragment, so it stays out of access logs and
`Referer`, and is stripped before anything awaits. The claim is
row-locked, so one approval mints once. A request lives 30 minutes; a
settled one is not offered again, since approving it fails server-side.

Signing in mid-flow no longer loses the request. The id is kept on the
SaaS origin and resumed after any sign-in, which is what makes creating
an account work: the confirmation email opens a new tab, where the
`next` parameter is gone. Reading it does not consume it — the request
may be open in two tabs — and only a recorded decision retires it.

The result lands as a modal over the portal the admin started from, and
the portal re-reads its link status so the page behind agrees with the
modal.

Plaintext `http://` callbacks are accepted rather than refused, because
many self-hosted instances legitimately run plain HTTP on a private
network; the address carries a warning icon explaining the risk, derived
server-side so a requester cannot suppress it. Hard-refusing `http://`
to a public IP literal is a reasonable follow-up; a bare hostname can't
be classified without a DNS lookup, so the warning stays the general
mechanism.

## Configuration

Four surfaces. Placeholders below, not values.

**SaaS backend**

| Setting | Needed | Why |
|---|---|---|
| `stirling.billing.account-link.enabled` | Yes, `true` | The connect
controller and service are `@ConditionalOnProperty` with no default, so
without it the endpoints do not exist. |
| `system.frontendUrl` | Only when the approval page is not on the API's
own origin | Where the approver is sent. Must include the app's base
path if it is served under one, or the redirect misses `/link`. |

**SaaS frontend**

| Setting | Needed | Why |
|---|---|---|
| `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes |
Its own sign-in. Must be the project the SaaS backend validates tokens
against. |
| `RUN_SUBPATH` | Only if served under a subpath | Moves the approval
page to `<base>/<subpath>/link`, so `system.frontendUrl` has to agree. |

**Self-hosted backend**

| Setting | Needed | Why |
|---|---|---|
| `stirling.billing.account-link.enabled` | Yes, `true` | Defaults to
`false`. |
| `stirling.billing.account-link.saas-base-url` | Yes | Origin of the
SaaS API it links to. Not the SaaS frontend. |
| `system.frontendUrl` | Optional | Externally reachable base URL for
the callback. Otherwise derived from the request's `Origin`, which is
right for ordinary deployments and wrong behind a rewriting proxy. |

**Self-hosted frontend**

| Setting | Needed | Why |
|---|---|---|
| `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes |
Accepts the session handed over in the callback fragment. |
| `VITE_SAAS_API_URL` | For Usage and Billing | Attended reads go to the
SaaS API with the admin's token. Absent, those surfaces stay on the
mock. |
| `VITE_INCLUDE_PORTAL` | Production builds | Dev builds include the
portal automatically; without it there is no link UI and no callback
route. |

Two things worth stating because neither fails loudly:

- **Both frontends must use the URL *and* key of the same Supabase
project**, and the same one the SaaS backend validates against. A key
from one project with a URL from another is accepted by the browser and
rejected by Supabase, which surfaces much later as "session expired" on
Usage rather than as an error at hand-over.
- **The Supabase redirect allow-list must contain the SaaS app's
`/auth/callback`**, since a confirmation email returns through it.
Entries are matched exactly.

- **`system.frontendUrl` is the existing setting for this**, not a new
one, so each side reads its own value and there is nothing extra to
configure. It also gates share links, so on a stack with storage and
sharing already on, setting it here turns those on too.

The self-hosted side deliberately does **not** configure where the
approval page lives — SaaS answers that in the connect-request reply,
being the only party that knows.

Also here, because testing this needs two stacks side by side:
`linked:staging` / `linked:dev` (which derive `system.frontendUrl` and
`RUN_SUBPATH` themselves), the missing `frontend:staging:saas`, and a
per-mode vite `cacheDir` — two dev servers in different modes otherwise
re-optimise over one shared dep cache.

## How to test

Automated and green: `task frontend:check:all` plus both backend
modules. `ConnectRequestServiceTest` covers callback validation, the
per-IP cap, single-use approval, claim outcomes, expiry, `WRONG_TEAM`
and reauth confirming without minting; `ConnectServiceTest` covers
callback-resolution precedence including a foreign-origin callback being
discarded; `ConnectControllerTest` covers the authorize URL, including
the forwarded-header path and only the first hop being trusted;
`ConnectCallback.test.tsx` covers the fragment being stripped
synchronously and malformed fragments refused;
`LinkAccountModal.test.tsx` covers link and reauth hitting different
endpoints.

Manual walkthrough:

1. `task linked:staging` — added here; brings up a SaaS stack and a
self-hosted instance pointed at it, on discovered ports, and prints the
four addresses.
2. Open the link-account modal in the self-hosted portal and continue.
Expect the SaaS approval page at `/link?request=<id>`.
3. Sign in as a team leader, or create an account and confirm the email.
Either way you should come back to the approval page.
4. Tick the acknowledgement and approve. Expect the fragment gone from
the address bar immediately, a result modal over the portal, the portal
showing linked without a reload, and attended reads (Usage, Billing)
working without a second sign-in.
5. Repeat, approving as a member of a different team. Expect a refusal,
not a rebind.

## Outstanding

- #7415 to be reworked against this design once this lands.
- **No SaaS-side UI to disconnect a server.** `GET
/account-link/instances` and `POST /account-link/instances/{id}/revoke`
are already team-scoped and leader-gated, and the portal has a panel
that uses them, but
`portal-saas/components/settings/accountLinkSettings.tsx` exports `null`
on the reasoning that "SaaS has no account-link concept". That held when
linking was a self-hosted admin managing their own instance; here a
leader approves a server they may not administer, and has no way to
withdraw it. The seam to fill is that one file. Expected to land with
the CTA work in #7415.

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-08-27 10:32:32 +00:00

430 lines
16 KiB
TypeScript

import react from "@vitejs/plugin-react-swc";
import { compression, defineAlgorithm } from "vite-plugin-compression2";
import fs from "node:fs/promises";
import path, { resolve } from "node:path";
import { constants, brotliCompress, gzip } from "node:zlib";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { defineConfig, loadEnv } from "vite";
import type { Connect, PluginOption } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { viteStaticCopy } from "vite-plugin-static-copy";
const gzipPromise = promisify(gzip);
const brotliPromise = promisify(brotliCompress);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function compressStaticCopyPlugin(): PluginOption {
return {
name: "compress-static-copy",
apply: "build" as const,
async closeBundle() {
const distDir = path.resolve(__dirname, "dist");
const targets = ["pdfium", "vendor", "pdfjs"];
const excludedExtensions = [
".gz",
".br",
".png",
".jpg",
".jpeg",
".gif",
".webp",
".woff",
".woff2",
];
async function walkAndCompress(dirOrFile: string) {
let stat;
try {
stat = await fs.stat(dirOrFile);
} catch {
return;
}
if (stat.isFile()) {
const ext = path.extname(dirOrFile).toLowerCase();
if (stat.size >= 1024 && !excludedExtensions.includes(ext)) {
const content = await fs.readFile(dirOrFile);
// Gzip (level 9)
const gzipped = await gzipPromise(content, { level: 9 });
await fs.writeFile(`${dirOrFile}.gz`, gzipped);
// Brotli (quality 11)
const brotlied = await brotliPromise(content, {
params: {
[constants.BROTLI_PARAM_QUALITY]: 11,
},
});
await fs.writeFile(`${dirOrFile}.br`, brotlied);
}
} else if (stat.isDirectory()) {
const files = await fs.readdir(dirOrFile);
for (const file of files) {
await walkAndCompress(path.join(dirOrFile, file));
}
}
}
for (const target of targets) {
await walkAndCompress(path.join(distDir, target));
}
},
};
}
// Bake per-route Open Graph / Twitter Card tags into static HTML at build time.
//
// The SPA sets these client-side for real browsers, but link-unfurling crawlers
// (Slack, Facebook, X, LinkedIn, iMessage, ...) do not run JavaScript. Prerendering
// flat per-route files (e.g. dist/compress.html) means every static host - Cloudflare
// Pages, Docker's bundled static dir, desktop - serves correct previews with NO
// server-side rendering. Cloudflare Pages serves `compress.html` at `/compress`
// automatically (clean URLs), and the Spring backend serves the same file.
//
// Absolute URLs (best for Facebook/X) are used when a canonical base is known:
// VITE_OG_BASE_URL (custom domain) or CF_PAGES_URL (set automatically by Cloudflare
// Pages). Otherwise URLs stay root-relative, which still resolves against whatever
// origin serves the page (correct for self-hosted Docker). Logic lives in
// scripts/og-prerender.mjs so it can be unit-tested without a full build.
function prerenderOgPlugin(isSaas: boolean): PluginOption {
// SaaS (stirling.com) prerenders the marketing cards from a dedicated
// manifest; every other flavour uses the tool-registry manifest.
const manifestFile = isSaas
? "public/og-metadata.saas.json"
: "public/og-metadata.json";
return {
name: "prerender-og",
apply: "build" as const,
async closeBundle() {
// oxlint-disable-next-line no-restricted-imports -- vite config runs before path aliases resolve, so a relative import is required here
const { prerenderOg } = await import("./scripts/og-prerender.mjs");
const ogBase = (
process.env.VITE_OG_BASE_URL ||
process.env.CF_PAGES_URL ||
""
).replace(/\/+$/, "");
// Absolute deploy base for nested routes' <base href> (matches vite `base`).
const subpath = (process.env.RUN_SUBPATH || "").replace(/^\/+|\/+$/g, "");
const baseHref = subpath ? `/${subpath}/` : "/";
let manifest;
try {
manifest = JSON.parse(
await fs.readFile(path.resolve(__dirname, manifestFile), "utf8"),
);
} catch {
console.warn(
`[prerender-og] ${manifestFile} missing; skipping OG prerender. ` +
"Run `node scripts/generate-og-metadata.mjs`.",
);
return;
}
const distDir = path.resolve(__dirname, "dist");
const count = await prerenderOg({ distDir, manifest, ogBase, baseHref });
console.log(
`[prerender-og] wrote ${count} prerendered route pages` +
(ogBase
? ` (absolute URLs, base=${ogBase})`
: " (root-relative URLs)"),
);
},
};
}
/**
* When the app is served under a subpath (RUN_SUBPATH → base like "/app/"), Vite
* serves index.html at "/app/" and redirects "/" → the base, but a bare "/app"
* (no trailing slash) 404s. This middleware redirects "/app" → "/app/" so either
* form loads the app in dev and `vite preview`. Query strings are preserved.
*/
function subpathBareRedirectPlugin(subpath: string): PluginOption {
const bare = `/${subpath}`;
const withSlash = `${bare}/`;
const redirect: Connect.NextHandleFunction = (req, res, next) => {
const url = req.url ?? "";
const q = url.indexOf("?");
const pathname = q === -1 ? url : url.slice(0, q);
if (pathname === bare) {
res.statusCode = 301;
res.setHeader("Location", withSlash + (q === -1 ? "" : url.slice(q)));
res.end();
return;
}
next();
};
return {
name: "subpath-bare-redirect",
configureServer(server) {
server.middlewares.use(redirect);
},
configurePreviewServer(server) {
server.middlewares.use(redirect);
},
};
}
// NOTE: cloud/ is a SHARED layer, not a runnable build flavor — it's compiled
// into the saas and desktop builds. It has no entry here and no vite tsconfig;
// it is only typechecked standalone via editor/src/cloud/tsconfig.json
// (task frontend:typecheck:cloud) to prove it carries no saas/desktop-only deps.
const VALID_MODES = [
"core",
"proprietary",
"saas",
"desktop",
"prototypes",
] as const;
type BuildMode = (typeof VALID_MODES)[number];
const TSCONFIG_MAP: Record<BuildMode, string> = {
core: "./tsconfig.core.vite.json",
proprietary: "./tsconfig.proprietary.vite.json",
saas: "./tsconfig.saas.vite.json",
desktop: "./tsconfig.desktop.vite.json",
prototypes: "./tsconfig.prototypes.vite.json",
};
export default defineConfig(async ({ mode, command }) => {
// Dev-only browser-tab label (worktree folder basename) surfaced by the
// top-level dev tasks so concurrent worktrees have distinguishable tabs.
// Only injected during `vite` (dev serve) — never baked into a production
// build — and carries only the folder name, no path/host/user info.
const devWorktreeLabel =
command === "serve" ? (process.env.STIRLING_DEV_LABEL ?? "") : "";
// Load env files relative to this config (frontend/editor/), regardless of
// where the build was invoked from. The previous `process.cwd()` worked when
// this file lived at frontend/, but after the editor was moved under
// frontend/editor/ the cwd-based lookup would miss editor/.env*.
const env = loadEnv(mode, import.meta.dirname, "");
const parentEnv = loadEnv(mode, resolve(import.meta.dirname, ".."), "");
// Effective mode: --mode > STIRLING_FLAVOR > ENABLE_SAAS > DISABLE_ADDITIONAL_FEATURES > proprietary.
const explicitMode = (VALID_MODES as readonly string[]).includes(mode)
? (mode as BuildMode)
: null;
const flavor = (process.env.STIRLING_FLAVOR ?? "").toLowerCase();
const flavorMode: BuildMode | null =
flavor === "core" || flavor === "proprietary" || flavor === "saas"
? (flavor as BuildMode)
: null;
const effectiveMode: BuildMode =
explicitMode ??
flavorMode ??
(process.env.ENABLE_SAAS === "true"
? "saas"
: process.env.DISABLE_ADDITIONAL_FEATURES === "true"
? "core"
: "proprietary");
const tsconfigProject = TSCONFIG_MAP[effectiveMode];
// Subpath the app is served under (base becomes "/<runSubpath>/"). Empty = root.
const runSubpath = (env.RUN_SUBPATH || "").replace(/^\/+|\/+$/g, "");
// Backend proxy target: default localhost:8080. Override via BACKEND_URL env var
// so the top-level dev launcher can wire a dynamically-assigned backend port.
const backendUrl = process.env.BACKEND_URL || "http://localhost:8080";
// Allow host header checks to be configured via env so LAN/reverse-proxy
// dev setups don't require editing this file for each machine.
const allowedHostsRaw =
process.env.FRONTEND_ALLOWED_HOSTS ||
env.FRONTEND_ALLOWED_HOSTS ||
parentEnv.FRONTEND_ALLOWED_HOSTS ||
"";
const allowedHosts = allowedHostsRaw
.split(",")
.map((host) => host.trim())
.filter(Boolean);
const backendProxy = {
target: backendUrl,
changeOrigin: true,
secure: false,
xfwd: true,
};
// Shared between `vite` (dev) and `vite preview` (production-build serve, used
// in CI/E2E) so the live test suite still resolves /api → :8080.
const backendProxyConfig =
effectiveMode === "desktop"
? undefined
: {
"/api": backendProxy,
"/oauth2": backendProxy,
"/saml2": backendProxy,
"/login/oauth2": backendProxy,
"/login/saml2": backendProxy,
"/swagger-ui": backendProxy,
"/v1/api-docs": backendProxy,
};
return {
// Per-mode: the default is one shared node_modules/.vite, so two dev servers in
// different modes re-optimize over each other and the browser 504s on a stale dep
// hash. Anchored to frontend/ because a relative path resolves against the vite
// root (editor/) and would create a second node_modules there.
cacheDir: resolve(
import.meta.dirname,
"..",
"node_modules",
`.vite-${effectiveMode}`,
),
define: {
__DEV_WORKTREE_LABEL__: JSON.stringify(devWorktreeLabel),
},
plugins: [
react(),
...(runSubpath ? [subpathBareRedirectPlugin(runSubpath)] : []),
tsconfigPaths({
projects: [tsconfigProject],
}),
compression({
threshold: 1024,
exclude: [/\.(png|jpg|jpeg|gif|webp|woff|woff2)$/],
algorithms: [
defineAlgorithm("gzip", { level: 9 }),
defineAlgorithm("brotliCompress", {
params: {
[constants.BROTLI_PARAM_QUALITY]: 11,
},
}),
],
}),
// Set ANALYZE=true to emit dist/stats.html (treemap) alongside the
// build; rollup-plugin-visualizer is ESM-only so we import dynamically.
...(process.env.ANALYZE === "true"
? [
(await import("rollup-plugin-visualizer")).visualizer({
filename: "dist/stats.html",
template: "treemap",
gzipSize: true,
brotliSize: true,
emitFile: false,
}) as PluginOption,
]
: []),
viteStaticCopy({
targets: [
{
// node_modules is hoisted to the workspace root (frontend/), so
// these paths walk up one level from editor/.
src: "../node_modules/@embedpdf/pdfium/dist/pdfium.wasm",
dest: "pdfium",
},
{
// Copy jscanify vendor files to dist
src: "public/vendor/jscanify/*",
dest: "vendor/jscanify",
},
{
// pdfjs-dist CMap data for CJK / non-latin glyph mapping. Required
// when rendering PDFs inside workers where the default DOM fetch paths
// aren't available.
src: "../node_modules/pdfjs-dist/cmaps/*",
dest: "pdfjs/cmaps",
},
{
// pdfjs-dist standard font data (Helvetica/Times/etc.) needed so
// workers can substitute non-embedded base 14 fonts without DOM access.
src: "../node_modules/pdfjs-dist/standard_fonts/*",
dest: "pdfjs/standard_fonts",
},
{
// Brand assets live in core; the editor serves them by URL per
// variant, so copy each set to the /{variant}-logo path its
// manifests, index.html and useLogoAssets resolve against.
src: "src/core/assets/brand/classic-logo/*",
dest: "classic-logo",
},
{
src: "src/core/assets/brand/modern-logo/*",
dest: "modern-logo",
},
],
}),
compressStaticCopyPlugin(),
prerenderOgPlugin(effectiveMode === "saas"),
],
// Worker bundles are a separate Rollup pass and do NOT inherit `plugins`,
// so without this `@app/*` resolves in the app and fails in a worker.
worker: {
plugins: () => [tsconfigPaths({ projects: [tsconfigProject] })],
},
server: {
host: true,
allowedHosts: allowedHosts.length > 0 ? allowedHosts : undefined,
// make sure this port matches the devUrl port in tauri.conf.json file
port: 5173,
// Tauri expects a fixed port, fail if that port is not available
strictPort: true,
watch: {
// tell vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
// Only use proxy in web mode - Tauri handles backend connections directly
proxy: backendProxyConfig,
},
preview: {
host: true,
port: 5173,
strictPort: true,
proxy: backendProxyConfig,
},
build: {
target: "esnext",
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes("material-symbols-icons.json"))
return "vendor-iconset";
if (id.includes("node_modules")) {
if (id.includes("pdfjs-dist")) return "vendor-pdfjs";
if (id.includes("@embedpdf")) return "vendor-embedpdf";
if (
id.includes("react") ||
id.includes("@mantine") ||
id.includes("@emotion") ||
id.includes("@mui") ||
id.includes("@iconify")
) {
return "vendor-ui";
}
if (id.includes("@supabase")) return "vendor-supabase";
if (id.includes("posthog-js") || id.includes("@posthog"))
return "vendor-posthog";
if (id.includes("@cantoo/pdf-lib") || id.includes("pdf-lib"))
return "vendor-pdflib";
if (
id.includes("recharts") ||
id.includes("d3") ||
id.includes("decimal.js")
)
return "vendor-charts";
if (id.includes("jszip") || id.includes("pako"))
return "vendor-zip";
if (id.includes("i18next")) return "vendor-i18n";
}
},
},
},
},
optimizeDeps: {
exclude: ["@embedpdf/pdfium"],
},
// base: "./" produces relative asset URLs which work when dist/ is served
// at any path (e.g. Spring Boot bundling the frontend at /). But under
// `vite preview` for deep SPA routes (e.g. /workflow/sign/<token>), the
// browser resolves ./assets/X.js relative to the current path → 404, then
// SPA fallback returns index.html as text/html and React never mounts.
// VITE_BUILD_FOR_PREVIEW=1 (set by the CI playwright steps) overrides to
// an absolute base so deep-route asset paths resolve to /assets/...
// Trailing slash required: it becomes `<base href>`, and browsers resolve
// relative URLs (manifest.json, favicon) against the base's *directory*.
base: runSubpath
? `/${runSubpath}/`
: process.env.VITE_BUILD_FOR_PREVIEW === "1"
? "/"
: "./",
};
});