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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b21a19d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,184 @@ +# 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.1] — 2026-05-16 + +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. +- 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 + +- `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`). 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 + +#### 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 + +- 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 + +- 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.1]: https://github.com/Root-Functional-Medicine/rootscribe/releases/tag/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/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/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/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/server/src/plaud/client.test.ts b/server/src/plaud/client.test.ts index a15d275..dfea4ac 100644 --- a/server/src/plaud/client.test.ts +++ b/server/src/plaud/client.test.ts @@ -122,7 +122,66 @@ 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("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("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 () => { diff --git a/server/src/plaud/client.ts b/server/src/plaud/client.ts index d5971c3..9882e37 100644 --- a/server/src/plaud/client.ts +++ b/server/src/plaud/client.ts @@ -44,16 +44,34 @@ 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}`; 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", - "user-agent": USER_AGENT, authorization: `Bearer ${token}`, - ...init.headers, + ...sanitizedCallerHeaders, + "user-agent": USER_AGENT, }; // Default JSON content type for methods that likely send a body. if (init.body && !headers["content-type"]) { 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", }, 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": {