From 8e7b5dadaf746f1861c5f200451a3adc014a437b Mon Sep 17 00:00:00 2001 From: Howie Young Date: Sat, 8 Aug 2026 22:38:15 +0800 Subject: [PATCH 1/3] i18n: localize the UI chrome into Japanese, Korean, Spanish, and French Add full UIStrings sets for ja/ko/es/fr to the table (previously they fell back to English). Dates use Intl.DateTimeFormat per locale; labels, signal/device strings, figures, colophon, archive, and email subject are translated. en and zh-TW unchanged. Resolves the "English content inside English chrome" gap for non-CJK/non-English readers. Co-Authored-By: Claude Opus 4.8 --- src/i18n.ts | 166 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 1 deletion(-) diff --git a/src/i18n.ts b/src/i18n.ts index f4ddf9e..2668862 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -136,7 +136,171 @@ const zhTW: UIStrings = { : `Browstack №${n}${title ? " — " + title : ""}|你的一週閱讀,成刊了`, }; -const TABLE: Record = { en, "zh-tw": zhTW }; +const ja: UIStrings = { + deepReads: "今週のじっくり読み", + socialEchoes: "ソーシャルの残響", + weekInFigures: "数字で見る一週間", + statDeepReads: "じっくり読み", + statSocial: "ソーシャルの残響", + statMinutes: "コンテンツ時間(分)", + viewOriginal: "元記事を見る", + otherTopic: "その他", + socialSource: "ソーシャル", + device: (d) => (d === "both" ? "デスクトップ + モバイル" : d === "mobile" ? "モバイル" : "デスクトップ"), + signal: (activeMin, minutes, capped) => + activeMin > 0 + ? `⚡ 今週はこれを${activeMin}分じっくり読みました` + : `今週はこれに${minutes}${capped ? "+" : ""}分を費やしました`, + issueNote: (footprint, arts, socials) => + footprint == null + ? `過去7日間の閲覧から厳選 — じっくり読み${arts}本とソーシャルの残響${socials}件、編集部の要約付き。` + : `過去7日間の${footprint.toLocaleString()}回のページ閲覧から厳選 — じっくり読み${arts}本とソーシャルの残響${socials}件、編集部の要約付き。`, + figFootprint: (n) => `ページ閲覧数 ${n.toLocaleString()}`, + figMobile: (pct) => `モバイル比率 ${pct}%`, + figPages: (n) => `コンテンツページ ${n}`, + figMinutes: (n) => `コンテンツ時間(分) ${n}`, + colophonAuto: "あなたの閲覧記録から自動編集", + colophonAudience: "あなたのデータはこの端末から一切外に出ていません · PUBLISHED FOR AN AUDIENCE OF ONE", + date: (sec) => new Intl.DateTimeFormat("ja-JP", { month: "short", day: "numeric" }).format(new Date(sec * 1000)), + archiveTagline: "アーカイブ · あなたのパーソナル週刊ダイジェスト", + counts: (arts, socials) => `じっくり読み${arts}本 · ソーシャルの残響${socials}件`, + statusSent: (dateStr) => `${dateStr}に配信`, + statusEditing: "編集中", + coverAlt: (n) => `第${n}号の表紙`, + archiveFooter: "あなたのデータはこの端末から一切外に出ていません · PUBLISHED FOR AN AUDIENCE OF ONE", + inauguralTitle: "創刊プレビュー", + emailArchiveButton: "ブラウザでアーカイブを開く →", + emailArchiveCaption: "Browstackの起動中に、このMacで開きます", + emailSubject: (n, title, digest) => + digest + ? `Browstack №${n} — ${digest}` + : `Browstack №${n}${title ? " — " + title : ""} — 今週の読書を、誌面で`, +}; + +const ko: UIStrings = { + deepReads: "이번 주 깊이 읽은 글", + socialEchoes: "소셜의 울림", + weekInFigures: "숫자로 보는 한 주", + statDeepReads: "깊이 읽은 글", + statSocial: "소셜의 울림", + statMinutes: "콘텐츠 시간(분)", + viewOriginal: "원문 보기", + otherTopic: "기타", + socialSource: "소셜", + device: (d) => (d === "both" ? "데스크톱 + 모바일" : d === "mobile" ? "모바일" : "데스크톱"), + signal: (activeMin, minutes, capped) => + activeMin > 0 + ? `⚡ 이번 주에 이 글을 ${activeMin}분 동안 집중해서 읽었습니다` + : `이번 주에 이 글에 ${minutes}${capped ? "+" : ""}분을 보냈습니다`, + issueNote: (footprint, arts, socials) => + footprint == null + ? `지난 7일간의 브라우징에서 골라낸 글 — 깊이 읽은 글 ${arts}편과 소셜의 울림 ${socials}건, 편집자 요약과 함께.` + : `지난 7일간 방문한 ${footprint.toLocaleString()}개 페이지에서 골라낸 글 — 깊이 읽은 글 ${arts}편과 소셜의 울림 ${socials}건, 편집자 요약과 함께.`, + figFootprint: (n) => `방문한 페이지 ${n.toLocaleString()}`, + figMobile: (pct) => `모바일 비중 ${pct}%`, + figPages: (n) => `콘텐츠 페이지 ${n}`, + figMinutes: (n) => `콘텐츠 시간(분) ${n}`, + colophonAuto: "브라우징 기록에서 자동 편집됨", + colophonAudience: "당신의 데이터는 이 기기를 떠나지 않았습니다 · 단 한 명의 독자를 위해 발행됨", + date: (sec) => new Intl.DateTimeFormat("ko-KR", { month: "short", day: "numeric" }).format(new Date(sec * 1000)), + archiveTagline: "아카이브 · 나만의 주간 다이제스트", + counts: (arts, socials) => `깊이 읽은 글 ${arts}편 · 소셜의 울림 ${socials}건`, + statusSent: (dateStr) => `${dateStr} 발송됨`, + statusEditing: "편집 중", + coverAlt: (n) => `제${n}호 표지`, + archiveFooter: "당신의 데이터는 이 기기를 떠나지 않았습니다 · 단 한 명의 독자를 위해 발행됨", + inauguralTitle: "창간 미리보기", + emailArchiveButton: "브라우저에서 아카이브 열기 →", + emailArchiveCaption: "Browstack이 실행 중일 때 이 Mac에서 열립니다", + emailSubject: (n, title, digest) => + digest + ? `Browstack №${n} — ${digest}` + : `Browstack №${n}${title ? " — " + title : ""} — 활자로 담은 이번 주 나의 읽기`, +}; + +const es: UIStrings = { + deepReads: "Lecturas de fondo de la semana", + socialEchoes: "Ecos sociales", + weekInFigures: "La semana en cifras", + statDeepReads: "lecturas de fondo", + statSocial: "ecos sociales", + statMinutes: "minutos de contenido", + viewOriginal: "Ver original", + otherTopic: "Otros", + socialSource: "Social", + device: (d) => (d === "both" ? "escritorio + móvil" : d === "mobile" ? "móvil" : "escritorio"), + signal: (activeMin, minutes, capped) => + activeMin > 0 + ? `⚡ Lo leíste con atención durante ${activeMin} min esta semana` + : `Le dedicaste ${minutes}${capped ? "+" : ""} min esta semana`, + issueNote: (footprint, arts, socials) => + footprint == null + ? `Seleccionado de tu navegación de los últimos siete días: ${arts} lecturas de fondo y ${socials} ecos sociales, con resúmenes de la redacción.` + : `Seleccionado de tus ${footprint.toLocaleString()} páginas visitadas en los últimos siete días: ${arts} lecturas de fondo y ${socials} ecos sociales, con resúmenes de la redacción.`, + figFootprint: (n) => `Páginas visitadas ${n.toLocaleString()}`, + figMobile: (pct) => `En el móvil ${pct}%`, + figPages: (n) => `Páginas de contenido ${n}`, + figMinutes: (n) => `Minutos de contenido ${n}`, + colophonAuto: "editado automáticamente a partir de tu registro de navegación", + colophonAudience: "TUS DATOS NUNCA SALIERON DE ESTE EQUIPO · PUBLICADO PARA UN PÚBLICO DE UNO", + date: (sec) => new Intl.DateTimeFormat("es-ES", { month: "short", day: "numeric" }).format(new Date(sec * 1000)), + archiveTagline: "Archivo · Tu boletín semanal personal", + counts: (arts, socials) => `${arts} lecturas de fondo · ${socials} ecos sociales`, + statusSent: (dateStr) => `Enviado ${dateStr}`, + statusEditing: "En curso", + coverAlt: (n) => `Portada del número ${n}`, + archiveFooter: "TUS DATOS NUNCA SALIERON DE ESTE EQUIPO · PUBLICADO PARA UN PÚBLICO DE UNO", + inauguralTitle: "Avance inaugural", + emailArchiveButton: "Abre tu archivo en el navegador →", + emailArchiveCaption: "Se abre en este Mac mientras Browstack está en ejecución", + emailSubject: (n, title, digest) => + digest + ? `Browstack №${n} — ${digest}` + : `Browstack №${n}${title ? " — " + title : ""} — tu semana de lecturas, impresa`, +}; + +const fr: UIStrings = { + deepReads: "Les lectures de fond de la semaine", + socialEchoes: "Échos sociaux", + weekInFigures: "La semaine en chiffres", + statDeepReads: "lectures de fond", + statSocial: "échos sociaux", + statMinutes: "minutes de lecture", + viewOriginal: "Voir l'original", + otherTopic: "Autre", + socialSource: "Social", + device: (d) => (d === "both" ? "ordinateur + mobile" : d === "mobile" ? "mobile" : "ordinateur"), + signal: (activeMin, minutes, capped) => + activeMin > 0 + ? `⚡ Vous l'avez lu activement pendant ${activeMin} min cette semaine` + : `Vous y avez consacré ${minutes}${capped ? "+" : ""} min cette semaine`, + issueNote: (footprint, arts, socials) => + footprint == null + ? `Sélection issue de votre navigation des sept derniers jours — ${arts} lectures de fond et ${socials} échos sociaux, avec les résumés de la rédaction.` + : `Sélection issue de vos ${footprint.toLocaleString()} pages consultées ces sept derniers jours — ${arts} lectures de fond et ${socials} échos sociaux, avec les résumés de la rédaction.`, + figFootprint: (n) => `Pages consultées ${n.toLocaleString()}`, + figMobile: (pct) => `Sur mobile ${pct}%`, + figPages: (n) => `Pages de contenu ${n}`, + figMinutes: (n) => `Minutes de lecture ${n}`, + colophonAuto: "édité automatiquement à partir de votre historique de navigation", + colophonAudience: "VOS DONNÉES N'ONT JAMAIS QUITTÉ CETTE MACHINE · UN JOURNAL POUR UN LECTEUR UNIQUE", + date: (sec) => new Intl.DateTimeFormat("fr-FR", { month: "short", day: "numeric" }).format(new Date(sec * 1000)), + archiveTagline: "Archives · Votre hebdomadaire personnel", + counts: (arts, socials) => `${arts} lectures de fond · ${socials} échos sociaux`, + statusSent: (dateStr) => `Envoyé le ${dateStr}`, + statusEditing: "En cours", + coverAlt: (n) => `Couverture du numéro ${n}`, + archiveFooter: "VOS DONNÉES N'ONT JAMAIS QUITTÉ CETTE MACHINE · UN JOURNAL POUR UN LECTEUR UNIQUE", + inauguralTitle: "Numéro inaugural", + emailArchiveButton: "Ouvrir vos archives dans le navigateur →", + emailArchiveCaption: "S'ouvre sur ce Mac tant que Browstack est actif", + emailSubject: (n, title, digest) => + digest + ? `Browstack №${n} — ${digest}` + : `Browstack №${n}${title ? " — " + title : ""} — votre semaine de lecture, en version imprimée`, +}; + +const TABLE: Record = { en, "zh-tw": zhTW, ja, ko, es, fr }; // Resolve UI strings for a BCP-47 code. zh-* → Traditional Chinese; everything else → English. export function ui(localeCode: string): UIStrings { From 46ede5441e5f39a96e2e8ca09bef3b5070bca23a Mon Sep 17 00:00:00 2001 From: Howie Young Date: Sat, 8 Aug 2026 22:42:38 +0800 Subject: [PATCH 2/3] Harden POST /capture with a per-install secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Host + Content-Type gates block a cross-origin webpage drive-by, but that rests entirely on browser CORS behaviour and does not stop a non-browser local process (e.g. another OS user on a shared Mac) from POSTing fabricated captures. Add a per-install shared secret: the extension holds it (baked in at `npm run build:ext` via an esbuild define, read from the Keychain `browstack-capture`) and sends it as `X-Browstack-Token`; the server validates it constant-time and fails closed. - src/captureSecret.ts: Keychain-backed secret (get/ensure/cached/check), sha256 + timingSafeEqual. - server.ts: /capture requires a valid token after the Host and content-type gates (401 otherwise); createBrowstackServer accepts an injectable getCaptureSecret for tests. - extension/background.ts: sends X-Browstack-Token (a compile-time __BROWSTACK_CAPTURE_TOKEN__). - scripts/build-ext.mjs: provisions the secret and injects it via `--define`; dist/ stays gitignored. - tests + grep-gate updated. BREAKING for extension users: after upgrading, run `npm run build:ext`, reload the unpacked extension in chrome://extensions, and kickstart the receiver — otherwise the old bundle's POSTs are rejected (401). Chrome-history ingest and non-extension flows are unaffected. Co-Authored-By: Claude Opus 4.8 --- extension/src/background.ts | 7 ++++- package.json | 2 +- scripts/build-ext.mjs | 47 ++++++++++++++++++++++++++++ scripts/security-gates.sh | 10 ++++++ src/captureSecret.ts | 62 +++++++++++++++++++++++++++++++++++++ src/server.ts | 13 +++++++- test/security.test.ts | 25 +++++++++++++-- 7 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 scripts/build-ext.mjs create mode 100644 src/captureSecret.ts diff --git a/extension/src/background.ts b/extension/src/background.ts index 9926b15..7fb79ff 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -10,6 +10,11 @@ import { SHARED } from "../../src/shared/settings.js"; const ENDPOINT = `http://127.0.0.1:${SHARED.serverPort}`; const MAX_QUEUE = 300; +// Per-install /capture secret, baked in at `npm run build:ext` (esbuild --define). The server +// validates it as X-Browstack-Token. Empty string if the bundle was built without provisioning. +declare const __BROWSTACK_CAPTURE_TOKEN__: string; +const CAPTURE_TOKEN = typeof __BROWSTACK_CAPTURE_TOKEN__ === "string" ? __BROWSTACK_CAPTURE_TOKEN__ : ""; + interface Stats { totalSent: number; lastFlushAt: number | null; @@ -52,7 +57,7 @@ function flush(): Promise { try { const res = await fetch(`${ENDPOINT}/capture`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", "x-browstack-token": CAPTURE_TOKEN }, body: JSON.stringify({ items: queue }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); diff --git a/package.json b/package.json index 43bf35f..f59af82 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "send": "tsx src/render/email.ts && tsx src/render/send.ts", "weekly": "node scripts/weekly.mjs", "schedule:weekly": "node scripts/install-weekly.mjs", - "build:ext": "esbuild extension/src/content.ts extension/src/background.ts extension/src/popup.ts --bundle --outdir=extension/dist --format=iife --target=chrome120 --log-level=warning", + "build:ext": "node scripts/build-ext.mjs", "typecheck": "tsc --noEmit && tsc -p extension --noEmit", "test": "node --import tsx --test test/*.test.ts", "security-gates": "bash scripts/security-gates.sh" diff --git a/scripts/build-ext.mjs b/scripts/build-ext.mjs new file mode 100644 index 0000000..77df0d0 --- /dev/null +++ b/scripts/build-ext.mjs @@ -0,0 +1,47 @@ +// Build the Chrome extension bundles, baking in this install's per-capture secret. +// The secret lives in the macOS Keychain (browstack-capture); esbuild injects it as a +// compile-time constant so background.ts can send it as X-Browstack-Token. dist/ is gitignored, +// so the secret never enters version control. The server reads the same Keychain value to validate. +import { execFileSync } from "node:child_process"; +import crypto from "node:crypto"; +import os from "node:os"; + +const SERVICE = "browstack-capture"; + +function ensureSecret() { + try { + const t = execFileSync("security", ["find-generic-password", "-s", SERVICE, "-w"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + if (/^[0-9a-f]{64}$/.test(t)) return t; + } catch { + // not provisioned yet + } + const secret = crypto.randomBytes(32).toString("hex"); + execFileSync( + "security", + ["add-generic-password", "-s", SERVICE, "-a", os.userInfo().username, "-w", secret, "-U"], + { stdio: ["ignore", "ignore", "ignore"] }, + ); + console.log("provisioned a new /capture secret in the Keychain (browstack-capture)"); + return secret; +} + +const secret = ensureSecret(); +execFileSync( + "node_modules/.bin/esbuild", + [ + "extension/src/content.ts", + "extension/src/background.ts", + "extension/src/popup.ts", + "--bundle", + "--outdir=extension/dist", + "--format=iife", + "--target=chrome120", + "--log-level=warning", + `--define:__BROWSTACK_CAPTURE_TOKEN__=${JSON.stringify(secret)}`, + ], + { stdio: "inherit" }, +); +console.log("built extension/dist (capture secret baked in). Reload the unpacked extension in chrome://extensions."); diff --git a/scripts/security-gates.sh b/scripts/security-gates.sh index f405938..3e0eb58 100644 --- a/scripts/security-gates.sh +++ b/scripts/security-gates.sh @@ -44,6 +44,16 @@ if [ -f src/archiveToken.ts ]; then deny "archive token has no hardcoded/default fallback" 'archive[_-]?token[^\n]*(\|\||\?\?)[[:space:]]*[\"'\''`]' src/ fi +# The /capture per-install secret must be constant-time compared, CSPRNG-only, and fail-closed. +if [ -f src/captureSecret.ts ]; then + if ! grep -q "timingSafeEqual" src/captureSecret.ts; then + echo "✗ GATE FAILED: captureSecret.ts must compare with crypto.timingSafeEqual" + fail=1 + else + echo "✓ captureSecret.ts uses timingSafeEqual" + fi +fi + # Personal data files must never be committed to version control tracked="$(git ls-files -- data/ out/ assets/covers/ src/shared/userConfig.ts 2>/dev/null || true)" if [ -n "$tracked" ]; then diff --git a/src/captureSecret.ts b/src/captureSecret.ts new file mode 100644 index 0000000..847d99c --- /dev/null +++ b/src/captureSecret.ts @@ -0,0 +1,62 @@ +import { execFileSync } from "node:child_process"; +import crypto from "node:crypto"; +import { userInfo } from "node:os"; + +/** + * Per-install shared secret for POST /capture. The extension holds it (baked in at + * `npm run build:ext`) and sends it as the X-Browstack-Token header; the server validates. + * + * Why: the Host + Content-Type gates block a cross-origin webpage drive-by, but that rests + * entirely on the browser's CORS behaviour, and it does NOT stop a non-browser local process + * (e.g. another OS user on a shared Mac) from POSTing fabricated captures. A per-install secret + * turns "every browser must behave" into "the attacker must also steal this machine's secret". + * Stored in the Keychain (`browstack-capture`), consistent with the other secrets; open source + * safe (Kerckhoffs) — the mechanism is public, only the per-install value matters. + */ + +const SERVICE = "browstack-capture"; +const RE = /^[0-9a-f]{64}$/; + +export function getCaptureSecret(): string | null { + try { + const t = execFileSync("security", ["find-generic-password", "-s", SERVICE, "-w"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return RE.test(t) ? t : null; + } catch { + return null; // no Keychain item, or non-macOS + } +} + +// Generate + store once. Called by the extension build (and any provisioning step), never by the HTTP handler. +export function ensureCaptureSecret(): string { + const existing = getCaptureSecret(); + if (existing) return existing; + const secret = crypto.randomBytes(32).toString("hex"); + execFileSync( + "security", + ["add-generic-password", "-s", SERVICE, "-a", userInfo().username, "-w", secret, "-U"], + { stdio: ["ignore", "ignore", "ignore"] }, + ); + return secret; +} + +// Short-TTL cache so validating each /capture batch doesn't fork `security` every time. +let cache: { value: string | null; at: number } | null = null; +const TTL_MS = 5000; +export function getCaptureSecretCached(nowMs: number = Date.now()): string | null { + if (cache && nowMs - cache.at < TTL_MS) return cache.value; + const value = getCaptureSecret(); + cache = { value, at: nowMs }; + return value; +} + +// Constant-time check; fail closed when no secret is provisioned. +export function checkCaptureSecret(presented: string | null | undefined, stored: string | null): boolean { + if (!stored || !RE.test(stored)) return false; + if (typeof presented !== "string" || presented.length === 0) return false; + const a = crypto.createHash("sha256").update(presented).digest(); + const b = crypto.createHash("sha256").update(stored).digest(); + return crypto.timingSafeEqual(a, b); +} diff --git a/src/server.ts b/src/server.ts index c8fb4a0..8479965 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { argv } from "node:process"; import { fileURLToPath } from "node:url"; import { checkArchiveKey, checkSessionCookie, getArchiveTokenCached, sessionCookieValue } from "./archiveToken.js"; +import { checkCaptureSecret, getCaptureSecretCached } from "./captureSecret.js"; import { classifyUrl } from "./classify/filter.js"; import { getDb, hardenPerms } from "./db.js"; import { findCover } from "./issue.js"; @@ -236,9 +237,12 @@ function readBody(req: http.IncomingMessage, limit: number): Promise { } // getToken is injectable (a fixed token for tests, avoiding the Keychain); defaults to reading from the Keychain. -export function createBrowstackServer(opts: { getToken?: () => string | null } = {}): http.Server { +export function createBrowstackServer( + opts: { getToken?: () => string | null; getCaptureSecret?: () => string | null } = {}, +): http.Server { // Default to the short-TTL cached version: avoids forking `security` on every request (including each cover on the index page) and blocking the event loop. const getToken = opts.getToken ?? getArchiveTokenCached; + const getCapture = opts.getCaptureSecret ?? getCaptureSecretCached; return http.createServer(async (req, res) => { // headersSent guard: a streaming/already-responded request must not be written again (avoids a crash-loop). const sendJson = (code: number, body: unknown) => { @@ -289,6 +293,13 @@ export function createBrowstackServer(opts: { getToken?: () => string | null } = if (ctype !== "application/json") { return sendJson(415, { ok: false, error: "content-type must be application/json" }); } + // Per-install secret: the extension holds it (baked at `npm run build:ext`) and sends X-Browstack-Token. + // Blocks a non-browser local process from injecting captures, and adds defense-in-depth if CORS ever fails. Fail closed. + const tokHeader = req.headers["x-browstack-token"]; + const tok = Array.isArray(tokHeader) ? tokHeader[0] : tokHeader; + if (!checkCaptureSecret(tok, getCapture())) { + return sendJson(401, { ok: false, error: "unauthorized" }); + } const raw = await readBody(req, 10 * 1024 * 1024); const parsed = JSON.parse(raw) as { items?: CaptureItem[] }; if (!Array.isArray(parsed.items)) return sendJson(400, { ok: false, error: "items required" }); diff --git a/test/security.test.ts b/test/security.test.ts index f33463f..5c3ad43 100644 --- a/test/security.test.ts +++ b/test/security.test.ts @@ -17,8 +17,10 @@ const GOOD_COOKIE = `${"bs"}=${sessionCookieValue(TEST_TOKEN)}`; let server: http.Server; let port: number; +const TEST_CAPTURE = "c".repeat(64); // injected /capture secret + before(async () => { - server = createBrowstackServer({ getToken: () => TEST_TOKEN }); + server = createBrowstackServer({ getToken: () => TEST_TOKEN, getCaptureSecret: () => TEST_CAPTURE }); await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); port = (server.address() as AddressInfo).port; }); @@ -41,6 +43,7 @@ function request(opts: { host?: string | null; contentType?: string; cookie?: string; + captureToken?: string; body?: string; }): Promise { return new Promise((resolve, reject) => { @@ -48,6 +51,7 @@ function request(opts: { headers.host = opts.host === undefined ? "127.0.0.1:8787" : (opts.host ?? ""); if (opts.contentType) headers["content-type"] = opts.contentType; if (opts.cookie) headers.cookie = opts.cookie; + if (opts.captureToken !== undefined) headers["x-browstack-token"] = opts.captureToken; if (opts.body !== undefined) headers["content-length"] = String(Buffer.byteLength(opts.body)); const req = http.request( { host: "127.0.0.1", port, method: opts.method ?? "GET", path: opts.path ?? "/", headers }, @@ -102,11 +106,28 @@ test("/capture:非 application/json 一律拒絕", async () => { assert.equal(noType.status, 415, "無 content-type 應被擋"); }); -test("/capture:application/json 通過 content-type 閘({} → 400 items required,未觸發 DB)", async () => { +test("/capture:缺 X-Browstack-Token → 401(即使 content-type 正確)", async () => { + const res = await request({ method: "POST", path: "/capture", contentType: "application/json", body: "{}" }); + assert.equal(res.status, 401); +}); + +test("/capture:錯誤 token → 401", async () => { + const res = await request({ + method: "POST", + path: "/capture", + contentType: "application/json", + captureToken: "d".repeat(64), + body: "{}", + }); + assert.equal(res.status, 401); +}); + +test("/capture:合法 token + application/json 通過閘({} → 400 items required,未觸發 DB)", async () => { const res = await request({ method: "POST", path: "/capture", contentType: "application/json", + captureToken: TEST_CAPTURE, body: "{}", }); assert.equal(res.status, 400); From ba4b1e4a1630d486287a56503662cd8ce3ca1110 Mon Sep 17 00:00:00 2001 From: Howie Young Date: Sat, 8 Aug 2026 22:57:11 +0800 Subject: [PATCH 3/3] Run the automation on compiled dist/ under a runtime-resolved node The LaunchAgents ran tsx off a node path baked in at install time. Two fragilities: a node upgrade orphaned the baked path, and better-sqlite3's native ABI could drift from the running node and crash-loop the resident receiver. - tsconfig.build.json + `npm run build`: emit runnable dist/. - scripts/run-with-node.sh: LaunchAgents exec through a wrapper that resolves node at runtime (launchd PATH lists the install-time node dir first), so a node upgrade no longer strands a stale path. - install-weekly.mjs: build + `npm rebuild better-sqlite3` before install, and a second preflight that refuses to install if the target node can't load the jsdom-backed compiled pipeline (fail loud now, not silently every Saturday). Serve plist now runs dist/server.js, not tsx. - weekly.mjs: run compiled dist/ entries via the resolved node, no npm/tsx indirection. jsdom's dep tree does require(ESM), unflagged only on Node 20.19 / 22.0+; on 20.17-20.18 or 21.x it opts in with --experimental-require-module, gated on the resolved node's version so a future node that drops the flag isn't handed it. - engines.node >= 20.17 (the floor where the flag exists); AGENTS.md gains the run-model note: editing src/ has no effect on the automation until `npm run build`. Interactive `npm run ` still use tsx; only the installed automation runs compiled. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 13 ++++++++++- package.json | 3 ++- scripts/install-weekly.mjs | 45 ++++++++++++++++++++++++++++++++------ scripts/run-with-node.sh | 11 ++++++++++ scripts/weekly.mjs | 37 +++++++++++++++++++++---------- tsconfig.build.json | 10 +++++++++ 6 files changed, 99 insertions(+), 20 deletions(-) create mode 100755 scripts/run-with-node.sh create mode 100644 tsconfig.build.json diff --git a/AGENTS.md b/AGENTS.md index 7dad744..d6d85dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,6 +108,16 @@ npm run schedule:weekly -- --day 1 --hour 9 # or any day/time (--day 0-6, 0=S Logs at `data/logs/weekly.log`. Uninstall: `launchctl bootout gui/$UID/com.browstack.weekly && rm ~/Library/LaunchAgents/com.browstack.weekly.plist` +**Run model (important for anyone editing `src/`):** the LaunchAgents run **compiled `dist/`** (no tsx +at runtime), under a node resolved at runtime by `scripts/run-with-node.sh`. `schedule:weekly` compiles +(`npm run build`) and aligns the native module (`npm rebuild better-sqlite3`) before installing, and its +two preflights refuse to install if that node can't load better-sqlite3 or the jsdom-backed pipeline. +So a `src/` edit has **no effect on the automation until you `npm run build`** (then kickstart the +resident server, or re-run `schedule:weekly`). Interactive `npm run ` still use +tsx for convenience — the compiled path is only the installed automation. Requires Node ≥ 20.17 (Node +20.19+ / 22+ recommended — `require(ESM)` is stable there; on 20.17–20.18 or 21.x the pipeline opts in +via `--experimental-require-module`, handled automatically). + ## Troubleshooting quick answers - `claude -p` says "Not logged in" → run `claude /login` in a normal Terminal. @@ -125,7 +135,8 @@ Logs at `data/logs/weekly.log`. Uninstall: for the Node that installed it; the current Node differs. The resident server & weekly are pinned to a specific Node — run DB-touching commands with that same Node (or `npm rebuild better-sqlite3` for the current one). Do NOT re-run `schedule:weekly` from a different Node than the one already pinned, or the - resident server will fail to load better-sqlite3. To pick up new server code, restart the agent instead: + resident server will fail to load better-sqlite3. To pick up new server code, **`npm run build` first** + (the agent runs compiled `dist/`, so a kickstart alone re-runs the stale build), then `launchctl kickstart -k gui/$UID/com.browstack.serve`. - Archive link dead / `archive:open` says the service isn't running → the receiver (`com.browstack.serve`) is down; the daily heartbeat also probes `/health`. Start it (`npm run serve`) or kickstart the agent. diff --git a/package.json b/package.json index f59af82..93912bc 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "private": true, "type": "module", "engines": { - "node": ">=20" + "node": ">=20.17.0" }, "scripts": { "postinstall": "node scripts/ensure-config.mjs", @@ -26,6 +26,7 @@ "schedule:weekly": "node scripts/install-weekly.mjs", "build:ext": "node scripts/build-ext.mjs", "typecheck": "tsc --noEmit && tsc -p extension --noEmit", + "build": "tsc -p tsconfig.build.json", "test": "node --import tsx --test test/*.test.ts", "security-gates": "bash scripts/security-gates.sh" }, diff --git a/scripts/install-weekly.mjs b/scripts/install-weekly.mjs index dbc8aeb..9398a3b 100644 --- a/scripts/install-weekly.mjs +++ b/scripts/install-weekly.mjs @@ -27,6 +27,17 @@ fs.mkdirSync(logDir, { recursive: true }); // PATH must include node/npm and the claude CLI (launchd's environment is minimal); includes Apple Silicon's /opt/homebrew const PATH = `${nodeDir}:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:${home}/.local/bin`; +// Compile to dist/ and align better-sqlite3's native ABI to THIS node, so the LaunchAgents run +// compiled JS under a runtime-resolved node (no tsx boot cost, no baked node path) with a matching binary. +const buildEnv = { ...process.env, PATH: `${nodeDir}:${process.env.PATH ?? ""}` }; +for (const step of [["run", "build"], ["rebuild", "better-sqlite3"]]) { + const r = spawnSync("npm", step, { cwd: repoRoot, stdio: "inherit", env: buildEnv }); + if (r.status !== 0) { + console.error(`npm ${step.join(" ")} failed; aborting install.`); + process.exit(1); + } +} + // Preflight check: better-sqlite3's native module must load under the exact node we're about to pin. // A version mismatch (e.g. run from a Node 22 shell but the module was built for Node 20) makes the resident server // silently crash-loop and lose landed data. Better to block it now with a clear fix than discover it later. @@ -46,6 +57,28 @@ if (probe.status !== 0) { process.exit(1); } +// Second preflight: the compiled pipeline must be able to load jsdom under the target node. +// jsdom's dep tree does require() of an ES module; native node only allows that once require(ESM) is +// unflagged (Node 20.19 / 22.0). On older 20.x or the interim 21.x, weekly.mjs opts in with a flag — +// mirror that gating here so a broken node fails loudly at install, not silently every Saturday. +const [nodeMajor, nodeMinor] = process.versions.node.split(".").map(Number); +const needsRequireModuleFlag = (nodeMajor === 20 && nodeMinor < 19) || nodeMajor === 21; +const requireModuleFlag = needsRequireModuleFlag ? ["--experimental-require-module"] : []; +const jsdomEntry = path.join(repoRoot, "dist", "fetch", "extract.js"); +const jsdomProbe = spawnSync( + nodeBin, + [...requireModuleFlag, "-e", `import(${JSON.stringify(jsdomEntry)}).then(() => process.exit(0)).catch((e) => { console.error(e.code || e.message); process.exit(1); })`], + { cwd: repoRoot, encoding: "utf8" }, +); +if (jsdomProbe.status !== 0) { + const hint = (jsdomProbe.stderr || "").split("\n").filter(Boolean).slice(-1)[0] || ""; + console.error("⚠ the compiled pipeline cannot load jsdom under this node; if you continue, the weekly run would fail:"); + console.error(` node: ${nodeBin} (${process.versions.node})`); + console.error(` ${hint.trim()}`); + console.error(" Fix: use Node 20.19+ or 22+ (require(ESM) is stable there), then rerun this command."); + process.exit(1); +} + // Publishing has two slots: the main run + a same-day retry 12 hours later (weekly.mjs is idempotent, so the retry auto-skips after success) const retryHour = (hour + 12) % 24; @@ -115,17 +148,15 @@ function installAgent(agentLabel, xml) { return plistPath; } -const nodeScript = (file) => [nodeBin, path.join(repoRoot, "scripts", file)]; +// Agents run through a wrapper that resolves node at runtime (no baked node path), executing +// compiled dist/ (the resident server) and the .mjs orchestrators. +const wrapper = path.join(repoRoot, "scripts", "run-with-node.sh"); +const nodeScript = (file) => [wrapper, path.join(repoRoot, "scripts", file)]; const weeklyPlistPath = installAgent(label, agentPlist(label, nodeScript("weekly.mjs"), weeklyCalendar, "weekly.log")); installAgent(heartbeatLabel, agentPlist(heartbeatLabel, nodeScript("heartbeat.mjs"), heartbeatCalendar, "heartbeat.log")); installAgent( serveLabel, - agentPlist( - serveLabel, - [nodeBin, path.join(repoRoot, "node_modules", ".bin", "tsx"), path.join(repoRoot, "src", "server.ts")], - serveSchedule, - "serve.log", - ), + agentPlist(serveLabel, [wrapper, path.join(repoRoot, "dist", "server.js")], serveSchedule, "serve.log"), ); const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; diff --git a/scripts/run-with-node.sh b/scripts/run-with-node.sh new file mode 100755 index 0000000..33fd420 --- /dev/null +++ b/scripts/run-with-node.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Resolve Node at runtime and exec it on the given script, so a Node upgrade doesn't leave a +# stale pinned path baked into the LaunchAgent. launchd sets a PATH that lists the install-time +# node dir first; command -v honors it, with a fallback list for a bare environment. +NODE="$(command -v node 2>/dev/null)" +if [ -z "$NODE" ]; then + for p in /opt/homebrew/bin/node /usr/local/bin/node /usr/bin/node; do + [ -x "$p" ] && NODE="$p" && break + done +fi +exec "$NODE" "$@" diff --git a/scripts/weekly.mjs b/scripts/weekly.mjs index a1bbdd3..47956af 100644 --- a/scripts/weekly.mjs +++ b/scripts/weekly.mjs @@ -35,30 +35,45 @@ try { /* DB doesn't exist yet (fresh install) → run as usual */ } -function run(script, { tolerate = false } = {}) { - console.log(`\n=== npm run ${script} ===`); - const result = spawnSync("npm", ["run", script], { stdio: "inherit" }); +// jsdom's dependency tree does require() of an ES module, which native node only allows once +// require(ESM) is unflagged — Node 20.19 and 22.0 onward. On older 20.x (and the interim 21.x line) +// the compiled pipeline needs the explicit opt-in; on newer node it's the default, so we DON'T pass +// the flag there (a future node could drop the now-obsolete flag name and reject it). +const [nodeMajor, nodeMinor] = process.versions.node.split(".").map(Number); +const needsRequireModuleFlag = (nodeMajor === 20 && nodeMinor < 19) || nodeMajor === 21; +const nodeFlags = needsRequireModuleFlag ? ["--experimental-require-module"] : []; + +// Run a compiled dist/ entry under the SAME node running this script (resolved by the LaunchAgent +// wrapper). No tsx, no npm indirection — plain node on pre-built JS. +function run(label, entry, entryArgs = [], { tolerate = false } = {}) { + console.log(`\n=== ${label} ===`); + const result = spawnSync(process.execPath, [...nodeFlags, path.join(repoRoot, "dist", entry), ...entryArgs], { + stdio: "inherit", + cwd: repoRoot, + }); if (result.status !== 0) { if (tolerate) { - console.warn(`[weekly] ${script} failed (exit ${result.status}), continuing`); + console.warn(`[weekly] ${label} failed (exit ${result.status}), continuing`); return; } - console.error(`[weekly] ${script} failed (exit ${result.status}), aborting the issue`); + console.error(`[weekly] ${label} failed (exit ${result.status}), aborting the issue`); notify( - `This week's issue failed at ${script}. Common cause: expired Claude CLI credentials (run claude /login). See data/logs/weekly.log`, + `This week's issue failed at ${label}. Common cause: expired Claude CLI credentials (run claude /login). See data/logs/weekly.log`, ); process.exit(result.status ?? 1); } } console.log(`[weekly] Browstack issue run started — ${new Date().toString()}`); -run("ingest"); +run("ingest", "cli.js", ["ingest"]); // An occasional enrich failure (LLM timeout, etc.) doesn't kill the whole issue: content enriched earlier this week can still publish; // if there's ultimately no content at all, email/send refuses to send an empty issue (see the safeguard in email.ts) -run("enrich", { tolerate: true }); +run("enrich", "cli.js", ["enrich"], { tolerate: true }); // A cover render failure (e.g. missing key) doesn't block publishing; reuse the previous cover -run("cover", { tolerate: true }); +run("cover", "render/cover.js", [], { tolerate: true }); // The week's reading sketch (the collection-showcase subtitle): LLM-generated; a failure doesn't block publishing, the issue just has no sketch subtitle -run("digest", { tolerate: true }); -run("send"); +run("digest", "render/digest.js", [], { tolerate: true }); +// send = render the email (aborts on an empty issue), then deliver it +run("email", "render/email.js"); +run("send", "render/send.js"); console.log(`[weekly] done — ${new Date().toString()}`); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..c139020 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "sourceMap": true + }, + "include": ["src"] +}