From 44f6243c63c758ab339719e8935a233f98aca216 Mon Sep 17 00:00:00 2001 From: Allen Ahner Date: Sat, 16 May 2026 11:09:08 -0400 Subject: [PATCH 1/7] DEVX-314 Hotfix Plaud Cloudflare 403 + remove stale .mcp.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plaud's API sits behind Cloudflare bot protection. The self-identifying User-Agent `rootscribe/0.1.0 (+https://github.com/...)` started getting served a Cloudflare challenge page (HTTP 403) on 2026-05-15, breaking the sync poller. Token was valid; the request never reached Plaud. Confirmed via direct curl: bot-style UA → 403 Cloudflare HTML, browser UA → 200 JSON, curl-default UA → 200 JSON. The `name/version (+url)` shape is the trigger. This commit: * Swaps `USER_AGENT` in server/src/plaud/client.ts to a Chrome string. * Encodes the lesson as a regression test in client.test.ts that forbids any UA matching `^name/ver (+http...)`. * Removes the unused .mcp.json from the repo root — the inbox MCP is now provided via the seedkit plugin's plugin-level .mcp.json, so the workspace-root copy was stale. A proper Cloudflare-aware 403 branch + configurable UA env var is scoped under DEVX-314 for a follow-up PR. This commit is partial progress on that ticket: the hotfix unblocks the poller for the 0.1.0 release; the longer-lived fix lands later. Co-Authored-By: Claude Opus 4.7 --- .mcp.json | 8 -------- server/src/plaud/client.test.ts | 8 +++++++- server/src/plaud/client.ts | 7 ++++++- 3 files changed, 13 insertions(+), 10 deletions(-) delete mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 7ca54c7..0000000 --- a/.mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "rootscribe-inbox": { - "command": "pnpm", - "args": ["--silent", "run", "start:mcp"] - } - } -} diff --git a/server/src/plaud/client.test.ts b/server/src/plaud/client.test.ts index a15d275..39b57f0 100644 --- a/server/src/plaud/client.test.ts +++ b/server/src/plaud/client.test.ts @@ -122,7 +122,13 @@ describe("plaudFetch — URL + headers", () => { const headers = init.headers as Record; expect(headers.authorization).toBe("Bearer test-token"); expect(headers.accept).toBe("application/json"); - expect(headers["user-agent"]).toContain("rootscribe/"); + const ua = headers["user-agent"]; + expect(ua).toBeTruthy(); + // Plaud sits behind Cloudflare bot protection. A self-identifying UA of + // the form `name/version (+https://...)` triggered a 403 challenge on + // 2026-05-15. Keep this regression: never send a UA that advertises us + // as an automated client. See DEVX-314. + expect(ua).not.toMatch(/^\S+\/\S+\s+\(\+https?:\/\//); }); it("uses authOverride over the configured token when provided", async () => { diff --git a/server/src/plaud/client.ts b/server/src/plaud/client.ts index d5971c3..b0b6eb2 100644 --- a/server/src/plaud/client.ts +++ b/server/src/plaud/client.ts @@ -44,7 +44,12 @@ function getToken(): string { return cfg.token; } -const USER_AGENT = "rootscribe/0.1.0 (+https://github.com/Root-Functional-Medicine/rootscribe)"; +// Plaud's API sits behind Cloudflare bot protection. A self-identifying +// "rootscribe/X (+url)" UA gets a 403 challenge; the Plaud web app uses a +// normal browser UA, so we mirror that. Confirmed 2026-05-15: bot-style UA +// → 403 Cloudflare HTML; browser UA → 200 JSON. See PR/ticket for details. +const USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; export async function plaudFetch(pathOrUrl: string, init: FetchInit = {}): Promise { const url = pathOrUrl.startsWith("http") ? pathOrUrl : `${getPlaudApiBase()}${pathOrUrl}`; From cafd303d31cd545f04997ddee89d380f481ec511 Mon Sep 17 00:00:00 2001 From: Allen Ahner Date: Sat, 16 May 2026 11:13:53 -0400 Subject: [PATCH 2/7] DEVX-314 Add CHANGELOG.md for 0.1.0 release First proper changelog for RootScribe, covering everything since the fork from rsteckler/applaud@v0.5.6 (commit da7ae11). 168 commits across 7 DEVX tickets plus the foundational inbox-MCP work. Format follows Keep a Changelog 1.1 + SemVer. Grouped as Added / Changed / Fixed / Removed with sub-sections by DEVX ticket so each section is traceable to its ticket and PR history. The [0.1.0] reference link points at the GitHub release-tag URL, which will become live when v0.1.0 is pushed post-merge. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 138 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8b07a8a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,138 @@ +# Changelog + +All notable changes to RootScribe are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] — 2026-05-16 + +First RootScribe release. Forked from +[`rsteckler/applaud`](https://github.com/rsteckler/applaud) at `v0.5.6` +(upstream commit `da7ae11`). 168 commits across 7 DEVX tickets plus the +foundational inbox-MCP work that motivated the fork. + +### Added + +#### Inbox workflow + MCP server (foundational) + +- New `inbox-mcp/` workspace exposing the recordings inbox as a Model Context + Protocol stdio server (categorize, tag, snooze, archive, link-Jira, + list-new, search, mark-reviewed). Registered as a project-scoped MCP server + so Claude Code can manage the inbox without leaving the editor. +- v4 SQLite schema migration introducing categories, tags, snooze, review + state, and Jira links on the `recordings` table. Migration is transactional + with a nudge-overlap guard. +- Standardized snooze comparison (`<=`) across all query sites + (`unnotifiedNew`, `listNew`, tag-filtered branches). + +#### Inbox UI + Jira integration — DEVX-96, DEVX-99 + +- Surfaced the inbox workflow in the RootScribe web app (Dashboard, + RecordingDetail, Settings). +- Auto-linked Jira issue keys from a configurable `jiraBaseUrl`. +- Tag and category autocomplete on detail-page editors. +- DB index on `recordings.category` to accelerate facet DISTINCT scans. + +#### Testing + CI/CD infrastructure — DEVX-100 + +- Vitest workspace, ESLint flat config, Playwright, and GitHub Actions CI + scaffolded from scratch. +- Baseline Vitest unit tests across all four packages (`server/`, `web/`, + `inbox-mcp/`, `shared/`). +- Playwright smoke tests + per-PR workflow. +- Pre-push guard (`.githooks/pre-push`) and `scripts/dev-setup.sh` to prevent + PRs from being mis-targeted at the upstream `rsteckler/applaud` repo (see + the DEVX-100 incident on 2026-04-18). +- Testing, CI/CD, and TDD expectations documented in README. + +#### Coverage ratchets — DEVX-102 (ratchets #1–#7) + +- Seven incremental coverage-ratchet PRs bringing the suite to + **95.36 % statements / 86.59 % branches / 95.90 % functions / 97.10 % lines** + across 776 unit tests plus Playwright journey specs. +- Major test additions: + - `server/src/plaud/{client,detail,audio,list,transcript}.ts` + - `server/src/sync/{state,events,layout,poller}.ts` + - `server/src/routes/{recordings,media,sync,auth,config}.ts` + - `server/src/auth/{browser-watch,chrome-leveldb,profiles}.ts` + - `server/src/webhook/post.ts` + - All `web/src/components/*`, `web/src/routes/*`, and + `web/src/routes/setup/*` components + - `inbox-mcp` platform branches +- Playwright `globalSetup` seam that seeds state before `webServer` boots, + plus four end-to-end journey specs. +- Coverage thresholds pinned in `vitest.config.ts` just below the achieved + baseline so future regressions fail CI. + +### Changed + +#### Major dependency upgrades — DEVX-101 + +| Package | From | To | +| --- | --- | --- | +| React | 18 | 19 | +| Vite | 5 | 8 | +| TypeScript | 5.6 | 6 | +| Tailwind | 3 | 4 | +| Express | 4 | 5 | +| Zod | 3 | 4 | +| ESLint | 9 | 10 | +| Vitest | 2 | 4 | +| better-sqlite3 | 11 | 12 | + +Notable compatibility fixes that rode along with the upgrade: + +- SPA fallback rewritten for Express 5's stricter dotfile rejection. +- Vite dev host pinned to IPv4 to work around Vite 8's IPv6-default + regression. + +#### Rebrand — DEVX-103 + +- Renamed `applaud` / `Applaud` to `rootscribe` / `RootScribe` across + packages, identifiers, paths, and user-facing copy. + +#### Hardening — DEVX-96 + +- `jiraBaseUrl` restricted to `http://` and `https://` schemes. +- Defense-in-depth href-scheme check on user-supplied URLs. +- List pagination clamped to safe integer ranges. +- `PATCH /status` notes contract tightened. + +### Fixed + +- **Plaud Cloudflare 403 challenge — DEVX-314 (this release).** The + self-identifying `rootscribe/0.1.0 (+url)` User-Agent started triggering + Cloudflare's bot WAF on 2026-05-15, returning a 403 challenge page to the + sync poller. Token auth was unaffected; the request never reached Plaud's + origin. Swapped `USER_AGENT` in `server/src/plaud/client.ts` to a Chrome + string and added a regression test forbidding any UA matching + `^name/ver (+http...)`. The full fix — a `PlaudEdgeBlockedError` branch + and `ROOTSCRIBE_PLAUD_USER_AGENT` env-var override — is scheduled as a + follow-up under DEVX-314. +- Settings save-error now clears on field edits. +- Suppress the server's first-run browser popup during Playwright e2e runs. + +### Removed + +- Workspace-root `.mcp.json`. The inbox MCP is now provided via the seedkit + plugin's plugin-level `.mcp.json`, so the workspace-root copy was stale. +- Unused `@applaud/shared` dependency from `inbox-mcp`. + +### Repository conventions established this release + +- **Canonical home:** + [`Root-Functional-Medicine/rootscribe`](https://github.com/Root-Functional-Medicine/rootscribe). + `origin` points here. `upstream` is `rsteckler/applaud` and is read-only; + the pre-push hook aborts any push to `upstream`. +- **Branch naming:** + `/DEVX--`. +- **Commit messages:** start with the ticket key — `DEVX- Short description`. +- **PR titles:** mirror commit subjects — `DEVX-: Short description`. +- **Jira project:** + [DEVX](https://rootfunctionalmedicine.atlassian.net/browse/DEVX). +- `gh repo set-default Root-Functional-Medicine/rootscribe` runs once per + clone (handled by `scripts/dev-setup.sh`) to prevent `gh` from defaulting + to the upstream fork. + +[0.1.0]: https://github.com/Root-Functional-Medicine/rootscribe/releases/tag/v0.1.0 From 851f84d352676d563eaf92336376eab70fc9e920 Mon Sep 17 00:00:00 2001 From: Allen Ahner Date: Sat, 16 May 2026 11:49:26 -0400 Subject: [PATCH 3/7] DEVX-314 Lock Plaud user-agent past caller-header overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #16 review feedback from Copilot: the previous merge order in plaudFetch spread `init.headers` AFTER the default user-agent, so a caller passing their own `user-agent` (including a bot-pattern UA) would override the safe default — and the regression test added in 44f6243 only exercised the no-headers default path, so it would not have caught such a caller. Move `"user-agent": USER_AGENT` to AFTER the `...init.headers` spread so the lock applies structurally regardless of caller input. Keep the other defaults (accept, authorization) overridable per existing behavior — only the UA needs to be locked. Add a second regression test that calls plaudFetch with a bot-pattern `user-agent` in `init.headers` and asserts the outgoing UA does NOT match the bot pattern and does NOT contain "rootscribe/". No current caller passes a UA in init.headers (verified by grep across server/src/plaud/* and server/src/routes/auth.ts), so this is a forward-looking guard rather than a behavior change. Co-Authored-By: Claude Opus 4.7 --- server/src/plaud/client.test.ts | 18 ++++++++++++++++++ server/src/plaud/client.ts | 4 +++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/server/src/plaud/client.test.ts b/server/src/plaud/client.test.ts index 39b57f0..5af65cd 100644 --- a/server/src/plaud/client.test.ts +++ b/server/src/plaud/client.test.ts @@ -131,6 +131,24 @@ describe("plaudFetch — URL + headers", () => { expect(ua).not.toMatch(/^\S+\/\S+\s+\(\+https?:\/\//); }); + it("locks the User-Agent even when a caller passes a bot-pattern override", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response("ok", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await plaudFetch("/x", { + headers: { + "user-agent": "rootscribe/0.1.0 (+https://github.com/Root-Functional-Medicine/rootscribe)", + }, + }); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + const ua = headers["user-agent"]; + expect(ua).not.toMatch(/^\S+\/\S+\s+\(\+https?:\/\//); + expect(ua).not.toContain("rootscribe/"); + }); + it("uses authOverride over the configured token when provided", async () => { const fetchMock = vi .fn() diff --git a/server/src/plaud/client.ts b/server/src/plaud/client.ts index b0b6eb2..faf0bfe 100644 --- a/server/src/plaud/client.ts +++ b/server/src/plaud/client.ts @@ -56,9 +56,11 @@ export async function plaudFetch(pathOrUrl: string, init: FetchInit = {}): Promi const token = init.authOverride ?? getToken(); const headers: Record = { accept: "application/json", - "user-agent": USER_AGENT, authorization: `Bearer ${token}`, ...init.headers, + // user-agent is locked AFTER the spread so callers cannot override it + // back to a bot-pattern UA that Cloudflare would block. See DEVX-314. + "user-agent": USER_AGENT, }; // Default JSON content type for methods that likely send a body. if (init.body && !headers["content-type"]) { From cb8ad5f0722d21f481e6280a5dc69f548de84ed6 Mon Sep 17 00:00:00 2001 From: Allen Ahner Date: Sat, 16 May 2026 12:06:12 -0400 Subject: [PATCH 4/7] DEVX-314 Bump to 0.1.1 and split CHANGELOG (hotfix release) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package version `0.1.0` was already baked into the broken self-identifying UA (`rootscribe/0.1.0 (+url)`), so 0.1.0 is the unreleased pre-Cloudflare baseline. This hotfix is the patch release on top, per SemVer 2.0.0: - Bump version from 0.1.0 to 0.1.1 across all five package.json files (root, server, web, inbox-mcp, shared). - Split CHANGELOG.md: [0.1.1] section gets the Cloudflare hotfix + structural UA lock + .mcp.json removal + CHANGELOG.md itself. [0.1.0] section becomes the unreleased baseline documenting the 135 commits across 6 DEVX tickets (DEVX-96/99/100/101/102/103) since the fork from rsteckler/applaud@v0.5.6. - Update CHANGELOG reference links to point [0.1.1] at the tag URL and [0.1.0] at a fork-point→tag compare URL. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 71 +++++++++++++++++++++++++++++++++--------- inbox-mcp/package.json | 2 +- package.json | 2 +- server/package.json | 2 +- shared/package.json | 2 +- web/package.json | 2 +- 6 files changed, 61 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b07a8a..eaf5997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,58 @@ All notable changes to RootScribe are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.1.0] — 2026-05-16 +## [0.1.1] — 2026-05-16 -First RootScribe release. Forked from +First tagged release. Hotfix on top of the unreleased `0.1.0` baseline so +the Plaud sync poller works against Cloudflare's bot WAF, plus repository +hygiene needed to publish. + +### Fixed + +- **Plaud Cloudflare 403 challenge — DEVX-314.** The self-identifying + `rootscribe/0.1.0 (+url)` User-Agent started triggering Cloudflare's bot + WAF on 2026-05-15, returning a 403 challenge page to the sync poller. + Token auth was unaffected; the request never reached Plaud's origin. + Swapped `USER_AGENT` in `server/src/plaud/client.ts` to a Chrome string. +- **Structural UA lock — DEVX-314.** `user-agent` is now applied AFTER the + `init.headers` spread inside `plaudFetch`, so callers cannot override it + back to a bot-pattern UA. The previous merge order would have let a + future caller defeat the regression test by passing their own header. +- Two regression tests in `server/src/plaud/client.test.ts` forbid any UA + matching `^name/ver (+http...)` on **both** the default-headers path + AND the caller-override path. Intentionally stricter than pinning the + Chrome string so DEVX-314's follow-up env-var work doesn't break them. + +### Added + +- `CHANGELOG.md` (this file). Format follows + [Keep a Changelog 1.1](https://keepachangelog.com/en/1.1.0/) + + [SemVer 2.0](https://semver.org/spec/v2.0.0.html). + +### Removed + +- Workspace-root `.mcp.json`. The inbox MCP is now provided via the + seedkit plugin's plugin-level `.mcp.json`, so the workspace-root copy + was stale. + +### Deferred to a follow-up under DEVX-314 + +- `PlaudEdgeBlockedError` — a dedicated error class for Cloudflare 403 + challenges, distinct from generic `PlaudApiError` and from + `PlaudAuthError`, so operators can immediately tell an edge block from + an auth problem. +- `ROOTSCRIBE_PLAUD_USER_AGENT` env var — operator-overridable UA so + future Cloudflare rule changes can be worked around without a code + change. The locked default stays the Chrome string above. + +## [0.1.0] — unreleased baseline + +The pre-hotfix RootScribe baseline. Never formally tagged but the version +identifier was live in code (the broken UA self-identified as +`rootscribe/0.1.0`). Forked from [`rsteckler/applaud`](https://github.com/rsteckler/applaud) at `v0.5.6` -(upstream commit `da7ae11`). 168 commits across 7 DEVX tickets plus the +(upstream commit `da7ae11`). 135 commits across 6 DEVX tickets +(DEVX-96, DEVX-99, DEVX-100, DEVX-101, DEVX-102, DEVX-103) plus the foundational inbox-MCP work that motivated the fork. ### Added @@ -101,22 +148,15 @@ Notable compatibility fixes that rode along with the upgrade: ### Fixed -- **Plaud Cloudflare 403 challenge — DEVX-314 (this release).** The - self-identifying `rootscribe/0.1.0 (+url)` User-Agent started triggering - Cloudflare's bot WAF on 2026-05-15, returning a 403 challenge page to the - sync poller. Token auth was unaffected; the request never reached Plaud's - origin. Swapped `USER_AGENT` in `server/src/plaud/client.ts` to a Chrome - string and added a regression test forbidding any UA matching - `^name/ver (+http...)`. The full fix — a `PlaudEdgeBlockedError` branch - and `ROOTSCRIBE_PLAUD_USER_AGENT` env-var override — is scheduled as a - follow-up under DEVX-314. - Settings save-error now clears on field edits. - Suppress the server's first-run browser popup during Playwright e2e runs. +> The Plaud Cloudflare 403 hotfix and `.mcp.json` removal land in +> [0.1.1](#011--2026-05-16), not here. They're the reason 0.1.0 was +> never published. + ### Removed -- Workspace-root `.mcp.json`. The inbox MCP is now provided via the seedkit - plugin's plugin-level `.mcp.json`, so the workspace-root copy was stale. - Unused `@applaud/shared` dependency from `inbox-mcp`. ### Repository conventions established this release @@ -135,4 +175,5 @@ Notable compatibility fixes that rode along with the upgrade: clone (handled by `scripts/dev-setup.sh`) to prevent `gh` from defaulting to the upstream fork. -[0.1.0]: https://github.com/Root-Functional-Medicine/rootscribe/releases/tag/v0.1.0 +[0.1.1]: https://github.com/Root-Functional-Medicine/rootscribe/releases/tag/v0.1.1 +[0.1.0]: https://github.com/Root-Functional-Medicine/rootscribe/compare/da7ae11...v0.1.1 diff --git a/inbox-mcp/package.json b/inbox-mcp/package.json index 0e1d068..2790133 100644 --- a/inbox-mcp/package.json +++ b/inbox-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@rootscribe/inbox-mcp", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/package.json b/package.json index 9d61331..f48548b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rootscribe", - "version": "0.1.0", + "version": "0.1.1", "private": true, "description": "Self-hosted server that mirrors Plaud recordings to local disk and triggers webhooks on new uploads.", "type": "module", diff --git a/server/package.json b/server/package.json index 9c510f6..da01894 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@rootscribe/server", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/shared/package.json b/shared/package.json index c2a5588..9945bc2 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@rootscribe/shared", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/web/package.json b/web/package.json index 90bbbba..98d5dd4 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@rootscribe/web", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "scripts": { From e9abdbec89fba958d0b90341e3e0e24547a206f3 Mon Sep 17 00:00:00 2001 From: Allen Ahner Date: Sat, 16 May 2026 12:10:15 -0400 Subject: [PATCH 5/7] DEVX-314 Sanitize caller user-agent case-insensitively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #16 round-2 review feedback from Copilot: HTTP header names are case-insensitive (RFC 7230 §3.2) but JS object keys are case-sensitive strings. The previous "spread first, then lock" merge order left both the caller's casing variant (e.g. "User-Agent") AND the locked "user-agent" in the merged headers object — and fetch's Headers init concatenates same-named entries, so the outgoing request would carry the bot-pattern UA combined with the locked value (e.g. "rootscribe/0.1.0 (+url), Mozilla/..."). Fix: strip ALL case variants of "user-agent" from `init.headers` BEFORE the spread, then add the locked lowercase key. The lock is now structural across every possible caller casing. Add a third regression test that passes both "User-Agent" and "USER-AGENT" with bot-pattern values and asserts: 1. Only the locked lowercase "user-agent" key remains in the outgoing headers (no case-variant leaks). 2. No header value anywhere in the merged object contains the "rootscribe/" or `name/ver (+http...)` pattern. The new test plus the existing two regressions now cover three attack paths: default-headers, lowercase-override, and case-variant override. Co-Authored-By: Claude Opus 4.7 --- server/src/plaud/client.test.ts | 35 +++++++++++++++++++++++++++++++++ server/src/plaud/client.ts | 17 +++++++++++++--- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/server/src/plaud/client.test.ts b/server/src/plaud/client.test.ts index 5af65cd..dfea4ac 100644 --- a/server/src/plaud/client.test.ts +++ b/server/src/plaud/client.test.ts @@ -149,6 +149,41 @@ describe("plaudFetch — URL + headers", () => { expect(ua).not.toContain("rootscribe/"); }); + it("locks the User-Agent across all caller casings (case-insensitive sanitization)", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response("ok", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + // HTTP header names are case-insensitive but JS object keys are not. A + // caller passing "User-Agent" (or "USER-AGENT") would leave a stray + // bot-pattern UA in the merged object; fetch's Headers init concatenates + // same-named entries, so Cloudflare would see the bot pattern in the + // combined value. The sanitization must strip ALL case variants. + await plaudFetch("/x", { + headers: { + "User-Agent": "rootscribe/0.1.0 (+https://github.com/Root-Functional-Medicine/rootscribe)", + "USER-AGENT": "rootscribe/0.1.0 (+https://github.com/Root-Functional-Medicine/rootscribe)", + }, + }); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + + // Only the locked lowercase user-agent should remain; no case-variant + // keys should have leaked through. + const uaKeys = Object.keys(headers).filter( + (k) => k.toLowerCase() === "user-agent", + ); + expect(uaKeys).toEqual(["user-agent"]); + + // And no header value anywhere in the merged object should contain + // the bot-pattern signal — belt + suspenders. + for (const value of Object.values(headers)) { + expect(value).not.toContain("rootscribe/"); + expect(value).not.toMatch(/^\S+\/\S+\s+\(\+https?:\/\//); + } + }); + it("uses authOverride over the configured token when provided", async () => { const fetchMock = vi .fn() diff --git a/server/src/plaud/client.ts b/server/src/plaud/client.ts index faf0bfe..9882e37 100644 --- a/server/src/plaud/client.ts +++ b/server/src/plaud/client.ts @@ -54,12 +54,23 @@ const USER_AGENT = export async function plaudFetch(pathOrUrl: string, init: FetchInit = {}): Promise { const url = pathOrUrl.startsWith("http") ? pathOrUrl : `${getPlaudApiBase()}${pathOrUrl}`; const token = init.authOverride ?? getToken(); + // HTTP header names are case-insensitive (RFC 7230 §3.2) but JS object + // keys are case-sensitive strings. If a caller passes "User-Agent" (or + // any other casing), a plain spread would leave both their key AND our + // locked "user-agent" in the object — and the fetch Headers init + // *concatenates* same-named entries, sending Cloudflare the combined + // value (e.g. "rootscribe/0.1.0 (+url), Mozilla/..."). Strip any + // case-variant of user-agent from caller headers before merging so the + // lock is structural across all casings. See DEVX-314. + const sanitizedCallerHeaders = Object.fromEntries( + Object.entries(init.headers ?? {}).filter( + ([key]) => key.toLowerCase() !== "user-agent", + ), + ); const headers: Record = { accept: "application/json", authorization: `Bearer ${token}`, - ...init.headers, - // user-agent is locked AFTER the spread so callers cannot override it - // back to a bot-pattern UA that Cloudflare would block. See DEVX-314. + ...sanitizedCallerHeaders, "user-agent": USER_AGENT, }; // Default JSON content type for methods that likely send a body. From b3a32ffdff4664eaaeac4087408834d6ba9c3280 Mon Sep 17 00:00:00 2001 From: Allen Ahner Date: Sat, 16 May 2026 12:13:37 -0400 Subject: [PATCH 6/7] DEVX-314 Bump 0.1.0 literal version strings and fix CHANGELOG link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #16 round-3 review feedback from Copilot: bumping the five package.json files to 0.1.1 was incomplete — three runtime constants plus one README doc string still hard-coded "0.1.0": - inbox-mcp/src/index.ts:30 — MCP server `version` metadata exposed to clients (now "0.1.1") - server/src/webhook/post.ts:102 and :184 — outbound webhook User-Agent header on both prod and test paths (now "rootscribe/0.1.1") - README.md:112 — documented webhook UA (now "rootscribe/0.1.1") Without these bumps, 0.1.1 would have shipped advertising itself as 0.1.0 on every webhook, MCP handshake, and in the README. The structurally correct fix is to derive these values from package.json at runtime/build time — that's scoped under DEVX-314 follow-up. A comment in inbox-mcp/src/index.ts marks the source-of-truth invariant. Also fix the CHANGELOG.md `[0.1.0]` reference link Copilot flagged: it previously pointed at `da7ae11...v0.1.1` which INCLUDED the hotfix commits, contradicting the section text. Since 0.1.0 was never tagged, the cleanest fix is to drop the link entirely and leave HTML comments explaining why. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 5 ++++- README.md | 2 +- inbox-mcp/src/index.ts | 4 +++- server/src/webhook/post.ts | 4 ++-- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaf5997..10750f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -176,4 +176,7 @@ Notable compatibility fixes that rode along with the upgrade: to the upstream fork. [0.1.1]: https://github.com/Root-Functional-Medicine/rootscribe/releases/tag/v0.1.1 -[0.1.0]: https://github.com/Root-Functional-Medicine/rootscribe/compare/da7ae11...v0.1.1 + + + + diff --git a/README.md b/README.md index 3f63198..75f5bc4 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Each recording gets its own folder under your chosen recordings directory: - `content` is only present on `transcript_ready` events. Both fields are nullable — if Plaud didn't generate a summary for a recording, `summary_markdown` will be `null`. - Webhook consumers should treat `(id, event)` as idempotent. `audio_ready` always fires before `transcript_ready`; on recordings that are already fully transcribed when first seen, both fire back-to-back in the same poll cycle. -- Custom headers on every webhook: `User-Agent: rootscribe/0.1.0` and `X-RootScribe-Event: audio_ready|transcript_ready`. +- Custom headers on every webhook: `User-Agent: rootscribe/0.1.1` and `X-RootScribe-Event: audio_ready|transcript_ready`. ## n8n workflows diff --git a/inbox-mcp/src/index.ts b/inbox-mcp/src/index.ts index 16b6911..2f070c5 100644 --- a/inbox-mcp/src/index.ts +++ b/inbox-mcp/src/index.ts @@ -27,7 +27,9 @@ import { const server = new McpServer( { name: "rootscribe-inbox", - version: "0.1.0", + // Keep in sync with inbox-mcp/package.json. Deriving from + // package.json at runtime is scoped under DEVX-314 follow-up. + version: "0.1.1", }, { capabilities: { diff --git a/server/src/webhook/post.ts b/server/src/webhook/post.ts index 325bf89..a3af10d 100644 --- a/server/src/webhook/post.ts +++ b/server/src/webhook/post.ts @@ -99,7 +99,7 @@ async function fireRaw( method: "POST", headers: { "content-type": "application/json", - "user-agent": "rootscribe/0.1.0", + "user-agent": "rootscribe/0.1.1", "x-rootscribe-event": event, }, body, @@ -181,7 +181,7 @@ export async function testWebhook( method: "POST", headers: { "content-type": "application/json", - "user-agent": "rootscribe/0.1.0", + "user-agent": "rootscribe/0.1.1", "x-rootscribe-event": "transcript_ready", "x-rootscribe-test": "1", }, From c8a6ed24dae80c3057bb4a7762052f7fe19a7340 Mon Sep 17 00:00:00 2001 From: Allen Ahner Date: Sun, 17 May 2026 08:55:33 -0400 Subject: [PATCH 7/7] DEVX-314 CHANGELOG: bump regression-test count from two to three MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #16 round-4 review feedback from Copilot: the CHANGELOG [0.1.1] section said "Two regression tests ... default-headers path AND caller-override path" but the PR actually added three tests — the case-variant override test landed in e9abdbe and was not reflected in the release notes I wrote earlier. Update the bullet to list all three covered paths explicitly: default-headers, lowercase caller-override, and case-variant caller-override (User-Agent / USER-AGENT). Keeps the release notes and test file in sync within the same PR that added the coverage. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10750f9..b21a19d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,10 +22,12 @@ hygiene needed to publish. `init.headers` spread inside `plaudFetch`, so callers cannot override it back to a bot-pattern UA. The previous merge order would have let a future caller defeat the regression test by passing their own header. -- Two regression tests in `server/src/plaud/client.test.ts` forbid any UA - matching `^name/ver (+http...)` on **both** the default-headers path - AND the caller-override path. Intentionally stricter than pinning the - Chrome string so DEVX-314's follow-up env-var work doesn't break them. +- Three regression tests in `server/src/plaud/client.test.ts` forbid any + UA matching `^name/ver (+http...)` across the **default-headers** path, + the **lowercase caller-override** path, and the **case-variant + caller-override** path (`User-Agent`, `USER-AGENT`). Intentionally + stricter than pinning the Chrome string so DEVX-314's follow-up + env-var work doesn't break them. ### Added