From 32ce684c2c784fd69fa4ae95b4d9bd8caa4f3ae9 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 17 Sep 2026 17:24:02 +0800 Subject: [PATCH 1/5] chore: add prods.ini to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 8e5ac39..37a977c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ /dist/ *.test coverage.out + +# Prods configuration +prods.ini \ No newline at end of file From 31ae48dda3e5d57355bae98713d359a7ed9fa68c Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 17 Sep 2026 17:24:18 +0800 Subject: [PATCH 2/5] feat: add Vite development configuration and HTML rewrite utilities --- web/ui/package.json | 1 + web/ui/scripts/dev-html-rewrite.test.ts | 58 +++++++++ web/ui/scripts/dev-html-rewrite.ts | 84 +++++++++++++ web/ui/src/dev/hmr-preamble.js | 6 + web/ui/tsconfig.json | 2 +- web/ui/vite.dev.config.ts | 159 ++++++++++++++++++++++++ 6 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 web/ui/scripts/dev-html-rewrite.test.ts create mode 100644 web/ui/scripts/dev-html-rewrite.ts create mode 100644 web/ui/src/dev/hmr-preamble.js create mode 100644 web/ui/vite.dev.config.ts diff --git a/web/ui/package.json b/web/ui/package.json index 64cb4e9..7bc89e2 100644 --- a/web/ui/package.json +++ b/web/ui/package.json @@ -4,6 +4,7 @@ "version": "0.1.0", "type": "module", "scripts": { + "dev": "vite --config vite.dev.config.ts", "build": "vite build --config vite.system.config.ts && node scripts/build-styles.mjs && vite build --config vite.public.config.ts && vite build --config vite.admin.config.ts", "build:styles": "node scripts/build-styles.mjs", "build:public": "node scripts/build-styles.mjs && vite build --config vite.public.config.ts", diff --git a/web/ui/scripts/dev-html-rewrite.test.ts b/web/ui/scripts/dev-html-rewrite.test.ts new file mode 100644 index 0000000..e39ed2d --- /dev/null +++ b/web/ui/scripts/dev-html-rewrite.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + DEV_ORIGIN, + GO_ORIGIN, + rewriteDevHtml, + rewriteLocationHeader, + rewriteUpstreamBrowserOrigin, +} from "./dev-html-rewrite"; + +describe("rewriteDevHtml", () => { + it("rewrites admin shell for Vite HMR", () => { + const html = ` + +
+`; + const out = rewriteDevHtml(html); + expect(out).not.toContain("/static/admin/style.css"); + expect(out).not.toContain("/static/admin/admin.js"); + expect(out).toContain('src="/src/admin/main.tsx"'); + expect(out).toContain("/@vite/client"); + expect(out).toContain("/src/dev/hmr-preamble.js"); + expect(out).not.toContain("injectIntoGlobalHook"); + }); + + it("rewrites public islands and stylesheet", () => { + const html = ` + +`; + const out = rewriteDevHtml(html); + expect(out).toContain('href="/src/public/style.css"'); + expect(out).toContain('src="/src/public/main.tsx"'); + expect(out).toContain("/@vite/client"); + expect(out).toContain("/src/dev/hmr-preamble.js"); + }); + + it("leaves unrelated HTML unchanged", () => { + const html = `

ok

`; + expect(rewriteDevHtml(html)).toBe(html); + }); +}); + +describe("rewriteLocationHeader", () => { + it("maps Go origin to Vite origin", () => { + expect(rewriteLocationHeader(`${GO_ORIGIN}/admin`)).toBe(`${DEV_ORIGIN}/admin`); + expect(rewriteLocationHeader("http://localhost:3310/")).toBe(`${DEV_ORIGIN}/`); + }); +}); + +describe("rewriteUpstreamBrowserOrigin", () => { + it("maps Vite browser origins to the Go base URL origin", () => { + expect(rewriteUpstreamBrowserOrigin("http://localhost:5173")).toBe(GO_ORIGIN); + expect(rewriteUpstreamBrowserOrigin("http://127.0.0.1:5173")).toBe(GO_ORIGIN); + expect(rewriteUpstreamBrowserOrigin("http://localhost:5173/admin/login")).toBe( + `${GO_ORIGIN}/admin/login`, + ); + expect(rewriteUpstreamBrowserOrigin("https://evil.example")).toBe("https://evil.example"); + }); +}); diff --git a/web/ui/scripts/dev-html-rewrite.ts b/web/ui/scripts/dev-html-rewrite.ts new file mode 100644 index 0000000..8713315 --- /dev/null +++ b/web/ui/scripts/dev-html-rewrite.ts @@ -0,0 +1,84 @@ +/** HTML / Location / Origin rewrites for the Vite-fronted local HMR proxy. */ + +export const GO_ORIGIN = "http://127.0.0.1:3310"; +export const DEV_ORIGIN = "http://127.0.0.1:5173"; + +/** External only — Go CSP allows script-src 'self', not unsafe-inline. */ +const HMR_SCRIPTS = [ + ``, + ``, +].join(""); + +/** Rewrite Go-served HTML so Admin / Public load Vite source entries with HMR. */ +export function rewriteDevHtml(html: string): string { + let out = html; + const before = out; + + out = out.replace(//gi, ""); + out = out.replace( + /(<\/script>)/gi, + "$1/src/admin/main.tsx$2", + ); + out = out.replace( + /(<\/script>)/gi, + "$1/src/public/main.tsx$2", + ); + out = out.replace( + /()/gi, + "$1/src/public/style.css$2", + ); + + if (out === before) { + return out; + } + + if (!out.includes("/@vite/client")) { + if (/<\/head>/i.test(out)) { + out = out.replace(/<\/head>/i, `${HMR_SCRIPTS}`); + } else if (/]*>/i.test(out)) { + out = out.replace(/]*)>/i, `${HMR_SCRIPTS}`); + } else { + out = `${HMR_SCRIPTS}${out}`; + } + } + + return out; +} + +/** Keep redirects on the Vite origin during HMR sessions. */ +export function rewriteLocationHeader(location: string | undefined): string | undefined { + if (!location) return location; + return location + .replaceAll(GO_ORIGIN, DEV_ORIGIN) + .replaceAll("http://localhost:3310", DEV_ORIGIN) + .replaceAll("http://[::1]:3310", DEV_ORIGIN); +} + +/** + * Map browser Vite Origin/Referer to the Go base_url origin so EnforceHost + * accepts state-changing requests (e.g. POST /admin/login). + */ +export function rewriteUpstreamBrowserOrigin( + headerValue: string | undefined, + goOrigin: string = GO_ORIGIN, +): string | undefined { + if (!headerValue) return headerValue; + try { + const parsed = new URL(headerValue); + if (parsed.port !== "5173") return headerValue; + const host = parsed.hostname.toLowerCase(); + if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") { + return headerValue; + } + const go = new URL(goOrigin); + parsed.protocol = go.protocol; + parsed.host = go.host; + // Origin has no path; Referer keeps path/query. + if (parsed.pathname === "/" && parsed.search === "" && parsed.hash === "") { + return goOrigin; + } + return parsed.toString(); + } catch { + return headerValue; + } +} diff --git a/web/ui/src/dev/hmr-preamble.js b/web/ui/src/dev/hmr-preamble.js new file mode 100644 index 0000000..c27af71 --- /dev/null +++ b/web/ui/src/dev/hmr-preamble.js @@ -0,0 +1,6 @@ +import RefreshRuntime from "/@react-refresh"; + +RefreshRuntime.injectIntoGlobalHook(window); +window.$RefreshReg$ = () => {}; +window.$RefreshSig$ = () => (type) => type; +window.__vite_plugin_react_preamble_installed__ = true; diff --git a/web/ui/tsconfig.json b/web/ui/tsconfig.json index 785f968..b5ee5f7 100644 --- a/web/ui/tsconfig.json +++ b/web/ui/tsconfig.json @@ -16,5 +16,5 @@ "noEmit": true, "jsx": "react-jsx" }, - "include": ["src", "vite.*.config.ts"] + "include": ["src", "scripts", "vite.*.config.ts"] } diff --git a/web/ui/vite.dev.config.ts b/web/ui/vite.dev.config.ts new file mode 100644 index 0000000..1c45dc5 --- /dev/null +++ b/web/ui/vite.dev.config.ts @@ -0,0 +1,159 @@ +import react from "@vitejs/plugin-react"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Socket } from "node:net"; +import { defineConfig, type Plugin, type ProxyOptions } from "vite"; +import { + DEV_ORIGIN, + GO_ORIGIN, + rewriteDevHtml, + rewriteLocationHeader, + rewriteUpstreamBrowserOrigin, +} from "./scripts/dev-html-rewrite"; + +const goTarget = process.env.PRODS_DEV_PROXY_TARGET ?? GO_ORIGIN; + +function collectProxyBody(proxyRes: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + proxyRes.on("data", (chunk: Buffer | string) => { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + }); + proxyRes.on("end", () => resolve(Buffer.concat(chunks))); + proxyRes.on("error", reject); + }); +} + +function writeProxiedResponse( + proxyRes: IncomingMessage, + res: ServerResponse, + body: Buffer | undefined, + headers: Record, +): void { + const status = proxyRes.statusCode ?? 200; + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) continue; + if (key.toLowerCase() === "transfer-encoding") continue; + res.setHeader(key, value); + } + if (body) { + res.setHeader("content-length", Buffer.byteLength(body)); + res.writeHead(status); + res.end(body); + return; + } + res.writeHead(status); + proxyRes.pipe(res); +} + +function createGoProxy(): Record { + const options: ProxyOptions = { + target: goTarget, + changeOrigin: true, + // Keep WebSockets on Vite for HMR; Prods does not need a WS proxy here. + ws: false, + selfHandleResponse: true, + configure(proxy) { + proxy.on("proxyReq", (proxyReq, req) => { + const origin = rewriteUpstreamBrowserOrigin( + typeof req.headers.origin === "string" ? req.headers.origin : undefined, + goTarget, + ); + if (origin) proxyReq.setHeader("Origin", origin); + const referer = rewriteUpstreamBrowserOrigin( + typeof req.headers.referer === "string" ? req.headers.referer : undefined, + goTarget, + ); + if (referer) proxyReq.setHeader("Referer", referer); + }); + + proxy.on("proxyRes", (proxyRes, _req, res) => { + const headers: Record = { + ...proxyRes.headers, + }; + const location = headers.location; + if (typeof location === "string") { + headers.location = rewriteLocationHeader(location); + } else if (Array.isArray(location)) { + headers.location = location.map((value) => rewriteLocationHeader(value) ?? value); + } + + const contentType = String(headers["content-type"] ?? ""); + const encoding = String(headers["content-encoding"] ?? ""); + const canRewriteHtml = + contentType.includes("text/html") && + (!encoding || encoding === "identity"); + + if (!canRewriteHtml) { + writeProxiedResponse(proxyRes, res as ServerResponse, undefined, headers); + return; + } + + void collectProxyBody(proxyRes) + .then((raw) => { + delete headers["content-encoding"]; + delete headers["content-length"]; + // Local HMR only: drop Go CSP so Vite client / WS are not blocked. + delete headers["content-security-policy"]; + const rewritten = rewriteDevHtml(raw.toString("utf8")); + writeProxiedResponse( + proxyRes, + res as ServerResponse, + Buffer.from(rewritten, "utf8"), + headers, + ); + }) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + const response = res as ServerResponse; + if (!response.headersSent) { + response.writeHead(502, { "content-type": "text/plain; charset=utf-8" }); + } + response.end(`prods HMR proxy failed to read Go HTML: ${message}`); + }); + }); + + proxy.on("error", (error, _req, res) => { + const message = error instanceof Error ? error.message : String(error); + if ( + res && + "writeHead" in (res as ServerResponse | Socket) && + typeof (res as ServerResponse).writeHead === "function" + ) { + const response = res as ServerResponse; + if (!response.headersSent) { + response.writeHead(502, { "content-type": "text/plain; charset=utf-8" }); + } + response.end( + `prods HMR proxy cannot reach ${goTarget}. Start ./prods first.\n${message}`, + ); + } + }); + }, + }; + + return { + "^/(?!src/|@vite/|@id/|@react-refresh|node_modules/|@fs/).*": options, + }; +} + +function prodsDevBanner(): Plugin { + return { + name: "prods-dev-banner", + configureServer(server) { + server.httpServer?.once("listening", () => { + console.log(`[prods-dev] browse ${DEV_ORIGIN}/ and ${DEV_ORIGIN}/admin (Go target ${goTarget})`); + }); + }, + }; +} + +export default defineConfig({ + plugins: [react(), prodsDevBanner()], + server: { + host: "127.0.0.1", + port: 5173, + strictPort: true, + proxy: createGoProxy(), + }, + clearScreen: false, +}); From 75d2303da684c5e41fbc016941f0e0bcb3154e18 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 17 Sep 2026 17:24:30 +0800 Subject: [PATCH 3/5] docs: add local UI development instructions for Admin and Public HMR --- README-zh_TW.md | 25 +++++++++++++++++++++++++ README.md | 25 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/README-zh_TW.md b/README-zh_TW.md index 591cd56..483446f 100644 --- a/README-zh_TW.md +++ b/README-zh_TW.md @@ -194,6 +194,31 @@ Browser / crawler - Restore 使用 durable journal;`prepared` 之前可安全放棄,之後只能用同一 operation 依各 root 證據向前完成。 - Admin 使用不透明 server-side session、server-side authorization、same-origin CSRF 與可信任 Host/proxy 設定。 +## 本地 UI 開發(Admin + Public HMR) + +正式環境仍內嵌建置後的 Admin/Public 資產。本地 React HMR 請以 Vite 當瀏覽器入口,並反代到 Go: + +1. Terminal A — 啟動 Prods(`base_url` 維持指向 Go listen,例如 `http://127.0.0.1:3310`): + +```sh +./prods +``` + +Vite proxy 會把瀏覽器的 `Origin`/`Referer`(`:5173`)改寫成 Go 的 `base_url` origin,避免 Admin POST(例如 `/admin/login`)被 `EnforceHost` 回 403。 + +2. Terminal B — 啟動 Vite proxy(此流程才需要 Node.js/pnpm): + +```sh +pnpm --dir web/ui install --frozen-lockfile +pnpm --dir web/ui dev +``` + +3. 瀏覽 `http://127.0.0.1:5173/` 與 `http://127.0.0.1:5173/admin`(`http://localhost:5173` 也可)。不要用 `:3310` 做 UI 修改;該埠仍提供內嵌 bundle,沒有 HMR。 + +若 Go listen 不是 `http://127.0.0.1:3310`,可用 `PRODS_DEV_PROXY_TARGET` 覆寫 proxy 目標(須與 `prods.ini` 的 `base_url` 一致)。 + +限制:Public HMR 只涵蓋 islands JS 與 `public.css`;Go 模板/SSR HTML 變更仍需整頁重整。Admin Fast Refresh 在改到 root mount 或 provider 外層時可能整頁 remount。發行驗證仍以 `pnpm --dir web/ui build`、`go build` 與直接執行 binary 為準。 + ## 從原始碼建置與驗證 所需工具: diff --git a/README.md b/README.md index f80ff8b..b9124f4 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,31 @@ Key boundaries: - Restore uses a durable journal. Before `prepared` it can be abandoned safely; after `prepared` it only rolls forward through the same operation with per-root evidence. - Admin uses opaque server-side sessions, server-side authorization, same-origin CSRF protection, and trusted Host/proxy settings. +## Local UI development (Admin + Public HMR) + +Production still embeds built Admin and Public assets. For local React HMR, run Vite as the browser front door and proxy to the Go process: + +1. Terminal A — start Prods (keep `base_url` on the Go listen address, e.g. `http://127.0.0.1:3310`): + +```sh +./prods +``` + +The Vite proxy rewrites the browser `Origin` / `Referer` (`:5173`) to the Go `base_url` origin so Admin POSTs (for example `/admin/login`) are not rejected by `EnforceHost` with 403. + +2. Terminal B — start the Vite proxy (requires Node.js / pnpm only for this workflow): + +```sh +pnpm --dir web/ui install --frozen-lockfile +pnpm --dir web/ui dev +``` + +3. Browse `http://127.0.0.1:5173/` and `http://127.0.0.1:5173/admin` (`http://localhost:5173` also works). Do not use `:3310` for UI edits; that port still serves the embedded bundle without HMR. + +Override the Go proxy target with `PRODS_DEV_PROXY_TARGET` when listen is not `http://127.0.0.1:3310` (it must match `base_url` in `prods.ini`). + +Limits: Public HMR covers islands JS and `public.css` only — Go template / SSR HTML changes still need a full reload. Admin Fast Refresh can remount when you edit the root mount or provider shell. Release validation remains `pnpm --dir web/ui build`, `go build`, and running the binary directly. + ## Build and verify from source Required tools: From 2efdb7547650339842e1fd2680046b912c2088de Mon Sep 17 00:00:00 2001 From: thomas Date: Fri, 18 Sep 2026 13:33:22 +0800 Subject: [PATCH 4/5] Update license audit date and package records; add .pnpm-store to .gitignore --- .gitignore | 4 +++- LICENSE_POLICY.md | 4 ++-- licenses/audit.json | 8 ++++---- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 37a977c..2ac58b7 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ coverage.out # Prods configuration -prods.ini \ No newline at end of file +prods.ini + +.pnpm-store \ No newline at end of file diff --git a/LICENSE_POLICY.md b/LICENSE_POLICY.md index 22bd4d1..c50b56f 100644 --- a/LICENSE_POLICY.md +++ b/LICENSE_POLICY.md @@ -17,14 +17,14 @@ MIT, ISC, 0BSD, BSD-2-Clause, BSD-3-Clause, and Apache-2.0 dependencies are norm ## Audited dependency snapshot -Audit date: 2026-09-16. Exact inputs and expected counts are recorded in [`licenses/audit.json`](licenses/audit.json). +Audit date: 2026-09-18. Exact inputs and expected counts are recorded in [`licenses/audit.json`](licenses/audit.json). | Scope | Result | | --- | --- | | Linux/Windows/macOS Go release closure | 37 external modules: MIT, BSD-3-Clause, and Apache-2.0 only | | Full Go module graph | 60 external modules; one reviewed MPL-2.0 module is used by build/test paths and is verified absent from both release closures | | Embedded production UI graph | 106 package records: MIT, BSD-3-Clause, and 0BSD only | -| Full frontend build/test graph | 198 package records: production licenses plus Apache-2.0, ISC, and one CC-BY-4.0 build-data package | +| Full frontend build/test graph | 197 package records: production licenses plus Apache-2.0, ISC, and one CC-BY-4.0 build-data package | | GitHub Actions and release CLI | `checkout`, `setup-go`, `action-setup`, `setup-node`, `upload-artifact`, and GitHub CLI are MIT-licensed | | Fonts and remote browser assets | No bundled web font or remotely loaded font dependency was found | diff --git a/licenses/audit.json b/licenses/audit.json index 1a9d3b4..641acbc 100644 --- a/licenses/audit.json +++ b/licenses/audit.json @@ -1,10 +1,10 @@ { "schema_version": 1, - "audited_on": "2026-09-16", + "audited_on": "2026-09-18", "inputs": { "go.mod": "6da0f697298da66eb5925ec32aa0dc49e6b5999a93dd31b4ceb693882e9b620e", "go.sum": "1bc375f9ab523c32daa36c4facb0f968b8eb15c2c0eee05bae6d551a49a4d7ae", - "web/ui/package.json": "7e674c2007c94b857d125ea8ae2828f0ceed83f57aa8347a683fc64a4a19203e", + "web/ui/package.json": "3efb972712963df5e035b5ae0dc5544827e271792fbed6631344c2fead0654bc", "web/ui/pnpm-lock.yaml": "c8a6e141f6a610d07a8a57473f6b0233801b9e1aaf5954e97c9b123f86f07c65" }, "go": { @@ -23,14 +23,14 @@ } }, "npm": { - "all_package_records": 198, + "all_package_records": 197, "all_license_groups": { "0BSD": 1, "Apache-2.0": 3, "BSD-3-Clause": 2, "CC-BY-4.0": 1, "ISC": 6, - "MIT": 185 + "MIT": 184 }, "production_package_records": 106, "production_license_groups": { From 960a3d5eefd5927c0fea73ad4b7dbc267ef76906 Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Fri, 18 Sep 2026 02:39:39 -0400 Subject: [PATCH 5/5] fix: restore audited frontend license counts --- LICENSE_POLICY.md | 2 +- licenses/audit.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/LICENSE_POLICY.md b/LICENSE_POLICY.md index c50b56f..2b4c87f 100644 --- a/LICENSE_POLICY.md +++ b/LICENSE_POLICY.md @@ -24,7 +24,7 @@ Audit date: 2026-09-18. Exact inputs and expected counts are recorded in [`licen | Linux/Windows/macOS Go release closure | 37 external modules: MIT, BSD-3-Clause, and Apache-2.0 only | | Full Go module graph | 60 external modules; one reviewed MPL-2.0 module is used by build/test paths and is verified absent from both release closures | | Embedded production UI graph | 106 package records: MIT, BSD-3-Clause, and 0BSD only | -| Full frontend build/test graph | 197 package records: production licenses plus Apache-2.0, ISC, and one CC-BY-4.0 build-data package | +| Full frontend build/test graph | 198 package records: production licenses plus Apache-2.0, ISC, and one CC-BY-4.0 build-data package | | GitHub Actions and release CLI | `checkout`, `setup-go`, `action-setup`, `setup-node`, `upload-artifact`, and GitHub CLI are MIT-licensed | | Fonts and remote browser assets | No bundled web font or remotely loaded font dependency was found | diff --git a/licenses/audit.json b/licenses/audit.json index 641acbc..1848f11 100644 --- a/licenses/audit.json +++ b/licenses/audit.json @@ -23,14 +23,14 @@ } }, "npm": { - "all_package_records": 197, + "all_package_records": 198, "all_license_groups": { "0BSD": 1, "Apache-2.0": 3, "BSD-3-Clause": 2, "CC-BY-4.0": 1, "ISC": 6, - "MIT": 184 + "MIT": 185 }, "production_package_records": 106, "production_license_groups": {