Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ingest|enrich|serve|…>` 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.
Expand All @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -52,7 +57,7 @@ function flush(): Promise<void> {
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}`);
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"private": true,
"type": "module",
"engines": {
"node": ">=20"
"node": ">=20.17.0"
},
"scripts": {
"postinstall": "node scripts/ensure-config.mjs",
Expand All @@ -24,8 +24,9 @@
"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",
"build": "tsc -p tsconfig.build.json",
"test": "node --import tsx --test test/*.test.ts",
"security-gates": "bash scripts/security-gates.sh"
},
Expand Down
47 changes: 47 additions & 0 deletions scripts/build-ext.mjs
Original file line number Diff line number Diff line change
@@ -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.");
45 changes: 38 additions & 7 deletions scripts/install-weekly.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;

Expand Down Expand Up @@ -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"];
Expand Down
11 changes: 11 additions & 0 deletions scripts/run-with-node.sh
Original file line number Diff line number Diff line change
@@ -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" "$@"
10 changes: 10 additions & 0 deletions scripts/security-gates.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 26 additions & 11 deletions scripts/weekly.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`);
62 changes: 62 additions & 0 deletions src/captureSecret.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading