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
8 changes: 0 additions & 8 deletions .mcp.json

This file was deleted.

184 changes: 184 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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:**
`<github-username>/DEVX-<ticket>-<kebab-case-summary>`.
- **Commit messages:** start with the ticket key — `DEVX-<N> Short description`.
- **PR titles:** mirror commit subjects — `DEVX-<N>: 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
<!-- [0.1.0] is intentionally unlinked: that version was never tagged. -->
<!-- It's documented above as the unreleased pre-hotfix baseline only. -->
<!-- A link like `da7ae11...v0.1.1` would include 0.1.1's hotfix commits, -->
<!-- contradicting the section text. -->
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion inbox-mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rootscribe/inbox-mcp",
"version": "0.1.0",
"version": "0.1.1",
Comment thread
allenahner marked this conversation as resolved.
"private": true,
"type": "module",
"main": "./dist/index.js",
Expand Down
4 changes: 3 additions & 1 deletion inbox-mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rootscribe/server",
"version": "0.1.0",
"version": "0.1.1",
Comment thread
allenahner marked this conversation as resolved.
"private": true,
"type": "module",
"main": "./dist/index.js",
Expand Down
61 changes: 60 additions & 1 deletion server/src/plaud/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,66 @@ describe("plaudFetch — URL + headers", () => {
const headers = init.headers as Record<string, string>;
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?:\/\//);
});

Comment thread
allenahner marked this conversation as resolved.
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<string, string>;
const ua = headers["user-agent"];
expect(ua).not.toMatch(/^\S+\/\S+\s+\(\+https?:\/\//);
expect(ua).not.toContain("rootscribe/");
Comment thread
allenahner marked this conversation as resolved.
});

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<string, string>;

// 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 () => {
Expand Down
24 changes: 21 additions & 3 deletions server/src/plaud/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
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<string, string> = {
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"]) {
Expand Down
4 changes: 2 additions & 2 deletions server/src/webhook/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
},
Expand Down
2 changes: 1 addition & 1 deletion shared/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rootscribe/shared",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rootscribe/web",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"scripts": {
Expand Down
Loading