diff --git a/CLAUDE.md b/CLAUDE.md index ae330c3..2d42484 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,13 +5,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project status Five packages are merged to main and working: -- `@gitmarks/core` (`packages/core/`) — schemas, GitHub Contents API client with optimistic concurrency, ULID/URL helpers (incl. opt-in tracking-param stripping), pure mutation helpers, example fixtures. 65 unit tests. -- `@gitmarks/extension-shared` (`packages/extension-shared/`) — canonical owner of the cross-browser extension code: popup, options, background, all of `src/lib/`, and the chrome/browser stub. 96 unit tests live here. Consumed by both browser shells via `workspace:*`. Uses `browser.*` via `webextension-polyfill`. +- `@gitmarks/core` (`packages/core/`) — schemas, GitHub Contents API client with optimistic concurrency, ULID/URL helpers (incl. opt-in tracking-param stripping), pure mutation helpers, example fixtures. 77 unit tests. +- `@gitmarks/extension-shared` (`packages/extension-shared/`) — canonical owner of the cross-browser extension code: popup, options, background, all of `src/lib/`, and the chrome/browser stub. 100 unit tests live here. Consumed by both browser shells via `workspace:*`. Uses `browser.*` via `webextension-polyfill`. - `@gitmarks/extension-chrome` (`packages/extension-chrome/`) — Chrome MV3 shell. Manifest + Vite/crxjs build + Playwright e2e (4 passing, 2 skipped — see issue history for the activeTab/Playwright limitation). Source files are thin entries that re-export from `extension-shared` via its `exports` map. - `@gitmarks/extension-firefox` (`packages/extension-firefox/`) — Firefox MV3 shell. Manifest + plain Vite build + manual smoke test (Playwright Firefox doesn't reliably drive WebExtensions). Targets Firefox 121+ for MV3 SW parity. Load via `about:debugging` → "Load Temporary Add-on". -- `@gitmarks/web` (`packages/web/`) — Vite + React + Tailwind SPA. List, search, tag management, bulk operations, trash, Netscape HTML export. Talks directly to GitHub via `@gitmarks/core`. Hash routing (`#/setup`, `#/`, `#/tags`, `#/trash`). 105 unit + component tests. +- `@gitmarks/web` (`packages/web/`) — Vite + React + Tailwind SPA. List, search, tag management, bulk operations, trash, Netscape HTML export. Talks directly to GitHub via `@gitmarks/core`. Hash routing (`#/setup`, `#/`, `#/tags`, `#/trash`). 109 unit + component tests. -Total: 272 unit + component tests across the monorepo, plus 6 Playwright e2e (4 passing, 2 skipped) in the Chrome shell. +Total: 286 unit + component tests across the monorepo, plus 6 Playwright e2e (4 passing, 2 skipped) in the Chrome shell. Pending packages (in dependency order): Safari. @@ -32,6 +32,10 @@ These are spec-level constraints; don't violate without an explicit discussion: - **IDs are ULIDs generated client-side.** Native browser node IDs are not stable across reinstalls — the extension maintains a `{ulid: chrome_node_id}` map in `chrome.storage.local`, rebuilt by URL match. - **Folder ↔ string path:** `Bookmarks Bar` ↔ `""` (root), `Other Bookmarks` ↔ `"_other"`, nested folders joined with `/`. Folder structure is derived from bookmarks, not stored separately. - **Loop suppression:** when applying a remote change to `chrome.bookmarks`, register the URL in an in-memory TTL map for ~2s so our own listener doesn't echo it back to GitHub. +- **URL safety:** Bookmark URLs are checked against an allowlist of safe schemes (`isSafeBookmarkUrl` in `@gitmarks/core`) at three points: (a) save time in the extension's `buildBookmark` factory, (b) render time in the web UI's `BookmarkRow`, and (c) the extension's `apply-remote` boundary that writes remote entries into the native bookmark tree. Unsafe schemes (`javascript:`, `data:`, etc.) are rejected/skipped. +- **Remote file validation:** `useGitmarksData` re-validates `bookmarks.json` and `tags.json` through Zod (`bookmarksFileSchema` / `tagsFileSchema`) after reading from GitHub. Malformed remote data surfaces as an error rather than rendering attacker-controlled fields. +- **Tag color guard:** `TagChip` regex-validates the color string before placing it into a CSS `style` object; malformed colors fall back to a default. +- **CSP:** Web UI's `index.html` and both extension manifests carry an explicit Content-Security-Policy restricting `connect-src` to `https://api.github.com`, disallowing inline scripts, framing, and form actions. ## Architecture by package @@ -59,7 +63,7 @@ Cross-browser source — owns all popup, options, background, and `src/lib/` mod - `folder-path.ts` — tree node ↔ `"Research/AI"` path conversion - `id-mapping.ts` — bidirectional `{ulid: chromeNodeId}` map - `suppression.ts` — in-memory URL + nodeId TTL maps (2s) to prevent loop-back - - `apply-remote.ts` — push a `BookmarksFile` state into `browser.bookmarks` + - `apply-remote.ts` — push a `BookmarksFile` state into `browser.bookmarks`; filters unsafe URL schemes (`javascript:`, `data:`, etc.) via `isSafeBookmarkUrl` from core - `reconcile.ts` — merge local tree and remote file by URL on cold start - `listeners.ts` — `browser.bookmarks.*` listeners with 500ms global debounce, batched flush - `background-core.ts` — dependency-injected `runMaybeReconcile` and `runPollRemoteOnce` (testable orchestration extracted from the SW entry) diff --git a/README.md b/README.md index 2b16ee7..aadab32 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ you control. **Status:** Chrome extension is functional end-to-end (save via toolbar button, two-way sync with the native bookmark tree, 5-min poll for remote changes, automatic conflict retry). Firefox MV3 add-on shipping the same -source as Chrome via a shared package. Web UI v1 (read-side: list, search, -tag management) deploys as a static SPA. Web UI v2 (bulk ops + trash + -export) and Safari are next in the roadmap. See `spec.md` for the full design. +source as Chrome via a shared package. Web UI (list, search, tag management, +bulk operations, trash, Netscape HTML export, sign out) deploys as a static +SPA. Safari is next in the roadmap. See `spec.md` for the full design. ## Features (Chrome, today) @@ -26,7 +26,7 @@ export) and Safari are next in the roadmap. See `spec.md` for the full design. on the next 5-minute poll - Concurrent edits from multiple devices reconcile automatically via GitHub's file SHA + optimistic retry-replay -- 272 automated unit + component tests + 6 Playwright e2e (against real Chromium) +- 286 automated unit + component tests + 6 Playwright e2e (against real Chromium) - Optional **tracking-param stripping** (utm_*, fbclid, gclid, etc.) at save time — opt-in via settings ## Packages @@ -34,10 +34,10 @@ export) and Safari are next in the roadmap. See `spec.md` for the full design. | Package | Role | |---|---| | `@gitmarks/core` | Shared TypeScript library: schemas (Zod), GitHub Contents API client with optimistic concurrency, ULID + URL helpers, pure mutation helpers | -| `@gitmarks/extension-shared` | Cross-browser extension source — popup, options, background, lib/ helpers. Consumed by both browser shells via `workspace:*`. 96 unit tests live here. | +| `@gitmarks/extension-shared` | Cross-browser extension source — popup, options, background, lib/ helpers. Consumed by both browser shells via `workspace:*`. 100 unit tests live here. | | `@gitmarks/extension-chrome` | Chrome MV3 shell. Manifest + Vite/crxjs build + Playwright e2e. Thin entry files import from `extension-shared`. | | `@gitmarks/extension-firefox` | Firefox MV3 shell. Manifest + plain Vite build. Same source as Chrome via `extension-shared`. Load via `about:debugging`. | -| `@gitmarks/web` | Static SPA — list, search, tag management. Vite + React + Tailwind. Talks directly to GitHub via `@gitmarks/core`. Deploys to GitHub Pages or Cloudflare Pages. | +| `@gitmarks/web` | Static SPA — list, search, tag management, bulk operations, trash, Netscape HTML export, sign out. Vite + React + Tailwind. Talks directly to GitHub via `@gitmarks/core`. Deploys to GitHub Pages or Cloudflare Pages. | ## Quick start (Chrome extension) @@ -72,6 +72,16 @@ the manual smoke test checklist, and architecture notes. - **No telemetry.** The extension only talks to `api.github.com`. That's enforced by the MV3 manifest's `host_permissions`. +### PAT lifecycle / revocation + +When you stop using gitmarks (uninstall the extension, clear browser data, or switch machines): + +1. **Revoke the PAT on github.com.** Settings → Developer settings → Personal access tokens → Fine-grained tokens → find the one named for your bookmarks repo → **Delete**. This is the only authoritative way to invalidate the credential. +2. **Web UI:** click **Sign out** in the header. This clears `localStorage` on your current machine. (It does NOT revoke the token on GitHub — see step 1.) +3. **Extension:** uninstalling the extension removes its `chrome.storage.local` entry on that machine. The token on GitHub remains valid until you revoke it. + +Treat the PAT like a saved password. If a machine is lost or compromised, revoke immediately on github.com. + ## Development ```bash diff --git a/docs/superpowers/plans/2026-05-26-gitmarks-hygiene-and-hardening.md b/docs/superpowers/plans/2026-05-26-gitmarks-hygiene-and-hardening.md new file mode 100644 index 0000000..3edd450 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-gitmarks-hygiene-and-hardening.md @@ -0,0 +1,756 @@ +# Hygiene + Security Follow-ups Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close four open follow-up issues in one pass — HTML deduplication across extension shells (#33), `chrome.*` → `browser.*` type-position migration (#34), test assertion namespace cleanup (#35), and the remaining defense-in-depth security follow-ups from the web UI v2 audit (#39). + +**Architecture:** Each issue is small and isolated; we land them as a single hardening branch with one commit per task. No new packages; no new dependencies. The work touches all three extension packages plus the web package. + +**Tech Stack:** Same as the existing monorepo — TypeScript 5.4, Vite 5, Vitest 2, `webextension-polyfill@0.12.0`. + +**Branch:** `feat/hygiene-and-hardening` + +--- + +## File Structure + +Files modified (no new packages): + +``` +packages/extension-chrome/ + package.json # MODIFY: copy-html script + run before vite build + scripts/copy-html.mjs # NEW + src/popup.html # DELETE (now copied at build time) + src/options.html # DELETE + .gitignore # MODIFY: ignore src/popup.html, src/options.html + manifest.config.ts # MODIFY: explicit content_security_policy.extension_pages + package.json # MODIFY: drop @types/chrome + tsconfig.json # MODIFY: drop chrome from types + +packages/extension-firefox/ + package.json # MODIFY: copy-html script + scripts/copy-html.mjs # NEW + src/popup.html # DELETE + src/options.html # DELETE + .gitignore # MODIFY + manifest.json # MODIFY: explicit content_security_policy.extension_pages + package.json # MODIFY: drop @types/chrome + tsconfig.json # MODIFY: drop chrome from types + +packages/extension-shared/ + src/lib/apply-remote.ts # MODIFY: type ref + skip unsafe URLs from remote + src/lib/listeners.ts # MODIFY: type refs + src/lib/reconcile.ts # MODIFY: type ref + test/setup.ts # MODIFY: type refs + jsdoc note + test/apply-remote.test.ts # MODIFY: chrome.* → browser.*; add unsafe-URL skip test + test/listeners.test.ts # MODIFY: chrome.* → browser.* + test/reconcile.test.ts # MODIFY: chrome.* → browser.* + package.json # MODIFY: drop @types/chrome + tsconfig.json # MODIFY: drop chrome from types + +packages/web/ + index.html # MODIFY: CSP meta tag + src/components/Layout.tsx # MODIFY: optional onSignOut + Sign out button + src/routes/{ListPage,TagsPage,TrashPage}.tsx # MODIFY: wire onSignOut + test/components.Layout.test.tsx # MODIFY: test onSignOut + +README.md # MODIFY: PAT lifecycle section +packages/extension-chrome/README.md # MODIFY: PAT revocation note in uninstall +packages/extension-firefox/README.md # MODIFY: PAT revocation note +CLAUDE.md # MODIFY: status updates, security posture summary +``` + +--- + +## Task 1: HTML deduplication (#33) + +Single source of truth: `packages/extension-shared/src/{popup,options}.html`. Each shell gets a `copy-html.mjs` script that copies the shared HTML into its own `src/` before Vite builds. The copies are gitignored. + +**Files:** +- Delete: `packages/extension-chrome/src/{popup,options}.html` +- Delete: `packages/extension-firefox/src/{popup,options}.html` +- Create: `packages/extension-chrome/scripts/copy-html.mjs` +- Create: `packages/extension-firefox/scripts/copy-html.mjs` +- Modify: `packages/extension-chrome/package.json` (prebuild hook + dev-time copy) +- Modify: `packages/extension-firefox/package.json` +- Modify: both `.gitignore` files + +- [ ] **Step 1: Create the copy script in `packages/extension-chrome/scripts/copy-html.mjs`** + +```javascript +import { copyFileSync, mkdirSync, existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const shellRoot = resolve(here, ".."); +const sharedHtmlDir = resolve(shellRoot, "../extension-shared/src"); +const targetDir = resolve(shellRoot, "src"); + +if (!existsSync(sharedHtmlDir)) { + throw new Error(`shared html dir not found: ${sharedHtmlDir}`); +} +mkdirSync(targetDir, { recursive: true }); +for (const file of ["popup.html", "options.html"]) { + copyFileSync(resolve(sharedHtmlDir, file), resolve(targetDir, file)); +} +console.log("[chrome] copied popup.html + options.html from extension-shared"); +``` + +- [ ] **Step 2: Same script for Firefox** — `packages/extension-firefox/scripts/copy-html.mjs` (identical except the log prefix says `[firefox]`). + +- [ ] **Step 3: Wire the script via `prebuild` + `predev` hooks** + +In `packages/extension-chrome/package.json`, modify `scripts`: + +```json +"scripts": { + "predev": "node ./scripts/copy-html.mjs", + "dev": "vite", + "prebuild": "node ./scripts/copy-html.mjs", + "build": "vite build", + "preview": "vite preview", + "pretypecheck": "node ./scripts/copy-html.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "e2e": "node ./scripts/copy-html.mjs && playwright test" +} +``` + +For `packages/extension-firefox/package.json`, similar additions plus the existing manifest copy: + +```json +"scripts": { + "predev": "node ./scripts/copy-html.mjs", + "dev": "vite", + "prebuild": "node ./scripts/copy-html.mjs", + "build": "vite build && node ./scripts/copy-manifest.mjs", + "preview": "vite preview", + "pretypecheck": "node ./scripts/copy-html.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest" +} +``` + +Adapt to whatever each `package.json` currently contains — the goal is "copy-html runs before any command that reads `src/*.html`." + +- [ ] **Step 4: Delete the duplicate files** + +```bash +rm packages/extension-chrome/src/popup.html packages/extension-chrome/src/options.html +rm packages/extension-firefox/src/popup.html packages/extension-firefox/src/options.html +``` + +- [ ] **Step 5: Update `.gitignore` in each shell** + +Append to `packages/extension-chrome/.gitignore` and `packages/extension-firefox/.gitignore`: + +``` +src/popup.html +src/options.html +``` + +- [ ] **Step 6: Verify each build still works** + +```bash +pnpm --filter @gitmarks/core build +pnpm --filter @gitmarks/extension-chrome build +pnpm --filter @gitmarks/extension-firefox build +``` + +Both shells emit `dist/popup.html` + `dist/options.html` (or the crxjs-mangled equivalents). The hidden `src/popup.html` reappears after `prebuild` runs — that's fine; it's now a build artifact. + +Run the full pipeline: + +```bash +pnpm typecheck +pnpm test +``` + +All green. + +- [ ] **Step 7: Commit** + +```bash +git checkout -b feat/hygiene-and-hardening +git add packages/extension-chrome packages/extension-firefox +git commit -m "fix(ext): dedupe popup.html + options.html via copy-html prebuild script" +``` + +--- + +## Task 2: Type-position migration `chrome.*` → `browser.*` (#34) + +Five files in `extension-shared` still have `chrome.bookmarks.*` / `chrome.runtime.LastError` type annotations from before the `browser.*` value-position migration. Plus `test/setup.ts` references `chrome.bookmarks.BookmarkCreateArg` etc. + +**Strategy:** import the `Bookmarks` and `Runtime` namespaces from `webextension-polyfill` and use them in type position. After migration, drop `@types/chrome` from all three extension packages' devDeps and tsconfig `types` arrays. + +**Files:** +- Modify: `packages/extension-shared/src/lib/apply-remote.ts` +- Modify: `packages/extension-shared/src/lib/listeners.ts` +- Modify: `packages/extension-shared/src/lib/reconcile.ts` +- Modify: `packages/extension-shared/test/setup.ts` +- Modify: `packages/extension-shared/tsconfig.json` (drop `"chrome"` from types) +- Modify: `packages/extension-shared/package.json` (drop `@types/chrome`) +- Modify: `packages/extension-chrome/tsconfig.json` +- Modify: `packages/extension-chrome/package.json` +- Modify: `packages/extension-firefox/tsconfig.json` +- Modify: `packages/extension-firefox/package.json` + +- [ ] **Step 1: Migrate type-position refs in `apply-remote.ts`** + +Find: +```typescript +let current: chrome.bookmarks.BookmarkTreeNode | undefined; +``` + +Replace by adding to the imports at the top: +```typescript +import type { Bookmarks } from "webextension-polyfill"; +``` + +And changing the line to: +```typescript +let current: Bookmarks.BookmarkTreeNode | undefined; +``` + +- [ ] **Step 2: Migrate type-position refs in `listeners.ts`** + +Add the same `import type { Bookmarks } from "webextension-polyfill";` at the top. + +Replace: +- `chrome.bookmarks.BookmarkTreeNode` → `Bookmarks.BookmarkTreeNode` +- `chrome.bookmarks.BookmarkChangeInfo` → `Bookmarks.OnChangedChangeInfoType` +- `chrome.bookmarks.BookmarkMoveInfo` → `Bookmarks.OnMovedMoveInfoType` +- `chrome.bookmarks.BookmarkRemoveInfo` → `Bookmarks.OnRemovedRemoveInfoType` + +(The polyfill's type names differ from `@types/chrome`'s — verify the exact names against `node_modules/webextension-polyfill/index.d.ts` if the typecheck fails.) + +- [ ] **Step 3: Migrate type-position ref in `reconcile.ts`** + +Add `import type { Bookmarks } from "webextension-polyfill";` and replace `chrome.bookmarks.BookmarkTreeNode` → `Bookmarks.BookmarkTreeNode`. + +- [ ] **Step 4: Migrate `test/setup.ts`** + +The current setup.ts uses `chrome.bookmarks.BookmarkCreateArg`, `chrome.bookmarks.BookmarkTreeNode`, `chrome.runtime.LastError`. Add: + +```typescript +import type { Bookmarks, Runtime } from "webextension-polyfill"; +``` + +Replace: +- `chrome.bookmarks.BookmarkCreateArg` → `Bookmarks.CreateDetails` +- `chrome.bookmarks.BookmarkTreeNode` → `Bookmarks.BookmarkTreeNode` +- `chrome.runtime.LastError` → `Runtime.PropertyLastErrorType` (or whatever the polyfill exports — verify) + +The stub's runtime `lastError` field can be typed as `Runtime.PropertyLastErrorType | undefined` or simply `{ message: string } | undefined` if the polyfill doesn't expose a matching type. + +- [ ] **Step 5: Drop `@types/chrome` from devDeps + tsconfig** + +In `packages/extension-shared/package.json`, `packages/extension-chrome/package.json`, `packages/extension-firefox/package.json`: +- Remove `"@types/chrome": "^0.0.268"` from `devDependencies`. + +In each package's `tsconfig.json`: +- Remove `"chrome"` from the `compilerOptions.types` array. If `types` is now empty, drop the key entirely. + +- [ ] **Step 6: Verify** + +```bash +pnpm install # syncs the dropped dep +pnpm --filter @gitmarks/core build +pnpm typecheck +pnpm test +pnpm build +``` + +All green. `chrome.*` type-position refs should be gone from `extension-shared`. + +Sanity-check via grep: `grep -RIn "chrome\." packages/extension-shared/src` should return zero hits for type-position uses (it's OK if value uses still appear in `popup.ts` etc. via the polyfill — but they shouldn't, since FF-2 migrated those). + +- [ ] **Step 7: Commit** + +```bash +git add packages/extension-shared packages/extension-chrome packages/extension-firefox +git commit -m "refactor(ext-shared): migrate chrome.* type refs to webextension-polyfill namespace; drop @types/chrome" +``` + +--- + +## Task 3: Test assertion namespace cleanup (#35) + +14 assertions across 3 test files reference `chrome.bookmarks.create` / `chrome.runtime.openOptionsPage` etc. directly. They pass today because the test setup stubs both `globalThis.chrome` and `globalThis.browser` to the same object. Production calls `browser.*`; rewrite the assertions to match. + +**Files:** +- Modify: `packages/extension-shared/test/apply-remote.test.ts` +- Modify: `packages/extension-shared/test/listeners.test.ts` +- Modify: `packages/extension-shared/test/reconcile.test.ts` + +- [ ] **Step 1: Find and replace in each test file** + +For each of the three files, replace every `chrome.bookmarks.X` reference with `browser.bookmarks.X`. Run `grep -n "chrome\." packages/extension-shared/test/` to enumerate. Each `expect(chrome.bookmarks.create).toHaveBeenCalled...` becomes `expect(browser.bookmarks.create)...`. + +Use the Edit tool. Examples: + +```typescript +// before +expect(chrome.bookmarks.create).toHaveBeenCalledWith({...}); +// after +expect(browser.bookmarks.create).toHaveBeenCalledWith({...}); +``` + +(`browser` is already a globalThis stub from `test/setup.ts`.) + +`settings.test.ts` and `machine-id.test.ts` use `chrome.storage.local.*` in their assertions — migrate those too. + +- [ ] **Step 2: Verify** + +```bash +pnpm --filter @gitmarks/extension-shared test +``` + +96 tests should still pass — the underlying stub object is the same; we just changed the access path. + +Also run `grep -RIn "chrome\." packages/extension-shared/test` to confirm zero remaining hits (except in any intentional polyfill-shim test). + +- [ ] **Step 3: Commit** + +```bash +git add packages/extension-shared/test +git commit -m "test(ext-shared): assert against browser.* mock paths to match production calls" +``` + +--- + +## Task 4: CSP on web UI + extension manifests (#39 item 1+2) + +Defense-in-depth content security policies. + +**Files:** +- Modify: `packages/web/index.html` +- Modify: `packages/extension-chrome/manifest.config.ts` +- Modify: `packages/extension-firefox/manifest.json` + +- [ ] **Step 1: Add CSP meta tag to `packages/web/index.html`** + +Inside the `
` block, before `